diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f62ef..d0ef337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +# Unreleased (0.4.0) + +**Declared change — Apple platforms only:** + +- The library now builds against **native Combine** on Apple platforms via + `#if canImport(Combine)`; WASI (and any platform without Combine) keeps OpenCombine. + Apple consumers receive native `Future`, `AnyCancellable`, `Scheduler`, and + `TopLevelDecoder` types from `JSPromise.publisher`, `JSScheduler`, and `JSValueDecoder`. + This changes type identity on Apple platforms (e.g. `PromisePublisher` now conforms to + `Combine.Publisher` instead of `OpenCombine.Publisher`) and is a declared break for any + Apple-platform consumer that pipelined these types into OpenCombine operators. WASI/WASM + consumers are unaffected. The package still depends on OpenCombine unconditionally; + full removal is tracked for 1.0 ([#11](https://github.com/IGRSoft/OpenCombineJS/issues/11), + [#15](https://github.com/IGRSoft/OpenCombineJS/issues/15)) + +**Additions:** + +- Async/await bridge APIs alongside the Combine publishers (strictly additive — nothing is + deprecated): `JSScheduler.sleep(for:)` suspends via a one-shot `setTimeout`, and + `JSScheduler.timer(interval:)` returns an `AsyncStream` of repeating `setInterval` ticks + whose termination cancels the underlying JS timer. JavaScriptKit's existing + `JSPromise.value` (`JavaScriptEventLoop` module) is documented as the async counterpart + of `.publisher`. New wasm-gated differential tests prove the legacy Combine path and the + new async path yield identical outcomes for the same inputs + ([#13](https://github.com/IGRSoft/OpenCombineJS/issues/13)) +- `CONTRIBUTING.md` with host/wasm build-and-test instructions (including the + toolchain-mixing warning), the dependency version policy (minimum JavaScriptKit 0.54.1, + weekly upstream canaries, the `JSValueDecoder` deletion trigger), and the release/semver + policy ([#12](https://github.com/IGRSoft/OpenCombineJS/issues/12)) + # 0.3.0 (2026-06-11) This release fixes four long-standing `JSScheduler` bugs and adds the package's first automated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..08ac511 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to OpenCombineJS + +## Build & test + +Two lanes must stay green ([ci.yml](.github/workflows/ci.yml)). The host lane builds against +native Combine, the wasm lane against OpenCombine (`#if canImport(Combine)`, #11). + +**Host (macOS, Swift 6.3+ via Xcode):** + +```console +swift build +swift test # host-runnable tests; wasm-only suites self-gate via #if os(WASI) +``` + +**Wasm (official swift.org toolchain + version-matched wasm Swift SDK, Node ≥ 20):** + +```console +# One-time: install the wasm SDK matching your swift.org toolchain version, e.g. +swift sdk install "https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz" --checksum + +swift build --swift-sdk swift-6.3.2-RELEASE_wasm --build-tests +swift package --disable-sandbox --swift-sdk swift-6.3.2-RELEASE_wasm js test +``` + +Notes: + +- The Xcode toolchain has no wasm backend — select a swift.org toolchain for wasm builds + (e.g. `TOOLCHAINS=org.swift. swift build …` on macOS). Toolchain and SDK versions must + match exactly; checksums live in [ci.yml](.github/workflows/ci.yml). +- **Never mix Xcode and swift.org toolchains in one `.build`** — their host-tool modules are + incompatible. Run `swift package clean` whenever you switch between host and wasm invocations. +- `js test` needs `--disable-sandbox` (the PackageToJS plugin runs npm) and the default scratch + path (no `--scratch-path`). +- Run `swiftformat --lint .` (repo `.swiftformat`: 2-space indent) before committing; new Swift + files carry the Apache header. + +## Dependency version policy + +- Minimum **JavaScriptKit 0.54.1** (ships `JSPromise.value`, required by the async bridge, #13). +- Weekly canaries ([canary-upstreams.yml](.github/workflows/canary-upstreams.yml)) build against + the latest JavaScriptKit release and OpenCombine main. They are allowed to fail and auto-file + `canary-failure` issues instead of breaking CI. +- The moment JavaScriptKit ships a native `TopLevelDecoder` conformance, our retroactive one + becomes a duplicate-conformance build error: delete `Sources/OpenCombineJS/JSValueDecoder.swift` + and tag a minor release (#9). The JSKit canary exists to catch this early. + +## Release & semver policy + +- Every PR is gated by [api-contract.yml](.github/workflows/api-contract.yml): + `swift package diagnose-api-breaking-changes` against the **PR's base branch** — the gate + catches breakage introduced by the PR, not historical breakage. +- Intentional breaking changes must be **declared** in `CHANGELOG.md` (under + `# Unreleased (x.y.z)`) with the matching semver bump — never hidden. +- Cutting a release: run the digester against the last release tag, declare every reported + change, stamp the `Unreleased` CHANGELOG section with version + date, then tag. + +## Code of conduct + +This project follows the [SwiftWasm Code of Conduct](https://github.com/swiftwasm/.github/blob/main/CODE_OF_CONDUCT.md). diff --git a/Package.swift b/Package.swift index 4dbed39..54c3e8d 100644 --- a/Package.swift +++ b/Package.swift @@ -45,6 +45,12 @@ let package = Package( package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), + // Provides `JSPromise.value` (async) consumed by the differential tests (#13). + .product( + name: "JavaScriptEventLoop", + package: "JavaScriptKit", + condition: .when(platforms: [.wasi]) + ), ] ), ], diff --git a/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md b/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md index 2b9d411..758d13e 100644 --- a/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md +++ b/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md @@ -1,21 +1,55 @@ # ``OpenCombineJS`` -OpenCombine helpers for JavaScriptKit/WebAssembly APIs. +Combine and async/await helpers for JavaScriptKit/WebAssembly APIs. ## Overview -OpenCombineJS bridges [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) and -[OpenCombine](https://github.com/OpenCombine/OpenCombine) so you can use idiomatic Combine -pipelines in browser-targeting Swift packages compiled to WebAssembly (WASM). +OpenCombineJS bridges [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) and the +Combine ecosystem so you can use idiomatic Combine pipelines — and their async/await +counterparts — in browser-targeting Swift packages compiled to WebAssembly (WASM). -The library provides three components: +The library provides these components: | Component | Description | |---|---| | ``JSScheduler`` | A Combine `Scheduler` backed by `setTimeout`/`setInterval` that enables time-based operators in the browser. | -| `JSPromise.publisher` | A `Publisher` property on `JSPromise` that resolves or rejects in lock-step with the underlying JavaScript `Promise`. | +| ``JSScheduler/sleep(for:)`` / ``JSScheduler/timer(interval:)`` | Async/await counterparts of the scheduling APIs: a one-shot JS-timer delay and an `AsyncStream` of repeating ticks. | +| `JSPromise.publisher` | A `Publisher` property on `JSPromise` that resolves or rejects in lock-step with the underlying JavaScript `Promise`. Its async counterpart is JavaScriptKit's `JSPromise.value` (`import JavaScriptEventLoop`). | | `JSValueDecoder` (`TopLevelDecoder`) | A retroactive conformance that lets `JSValueDecoder` work with the `.decode(type:decoder:)` Combine operator. | +### Combine on Apple platforms, OpenCombine on WASI + +The library selects its Combine backend at compile time (`#if canImport(Combine)`): on +Apple platforms it builds against the **native Combine framework**, so `JSPromise.publisher`, +``JSScheduler``, and the `JSValueDecoder` conformance vend native `Combine` types; on WASI +(and any platform without Combine) it builds against +[OpenCombine](https://github.com/OpenCombine/OpenCombine). The two modules mirror each other +symbol-for-symbol — only the module identity differs, so the same consumer code compiles on +both backends. + +### Async/await usage + +`await`-based APIs require the `JavaScriptEventLoop` global executor on WASI +(`JavaScriptEventLoop.installGlobalExecutor()`), so suspended tasks are resumed by JS +timers and promises: + +```swift +import JavaScriptEventLoop + +JavaScriptEventLoop.installGlobalExecutor() + +let value = try await promise.value // async counterpart of .publisher + +let scheduler = JSScheduler() +await scheduler.sleep(for: .milliseconds(300)) // one-shot setTimeout delay + +for await _ in scheduler.timer(interval: .seconds(1)) { // repeating setInterval ticks + refresh() +} +``` + +The Combine surface is not deprecated: publishers and async APIs are supported side by side. + ### JavaScript event-loop context All code in OpenCombineJS runs on the single-threaded JavaScript event loop. There is no @@ -88,9 +122,19 @@ let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) { - ``JSScheduler/SchedulerTimeType/Stride`` - ``JSScheduler/SchedulerOptions`` +### Async Scheduling + +- ``JSScheduler/sleep(for:)`` +- ``JSScheduler/timer(interval:)`` + ### JavaScript Promise Integration OpenCombineJS extends `JSPromise` (from JavaScriptKit) with a `publisher` property and two nested types: `JSPromise.PromisePublisher` (the Combine publisher) and `JSPromise.PromiseError` (the typed rejection wrapper). Because `JSPromise` is declared in JavaScriptKit, its extension members appear in the inherited-symbols section of the generated archive rather than here. + +The async counterpart of `.publisher` is `JSPromise.value` (`get async throws(JSException)`), +shipped by JavaScriptKit's `JavaScriptEventLoop` module — not by this package. Both paths +observe the same promise settlement; on rejection the raw JS reason is available as +`PromiseError.value` (publisher path) or `JSException.thrownValue` (async path). diff --git a/Sources/OpenCombineJS/JSPromise.swift b/Sources/OpenCombineJS/JSPromise.swift index a1b5188..b7cba80 100644 --- a/Sources/OpenCombineJS/JSPromise.swift +++ b/Sources/OpenCombineJS/JSPromise.swift @@ -13,7 +13,18 @@ // limitations under the License. import JavaScriptKit + +// Dual Combine backend (issue #11): on Apple platforms the library is built against the +// native Combine framework, so consumers receive native `Future`, `AnyCancellable`, +// `Scheduler`, and `TopLevelDecoder` types; on WASI (and any platform without Combine) +// it is built against OpenCombine. All consumed symbols are mirrored 1:1 between the two +// modules — only the module identity differs. The host (macOS) test lane exercises the +// Combine backend; the wasm test lane exercises the OpenCombine backend. +#if canImport(Combine) +import Combine +#else import OpenCombine +#endif /// Extensions that expose a Combine `Publisher` interface on `JSPromise`. /// @@ -104,6 +115,22 @@ public extension JSPromise { /// .flatMap { $0.json().publisher } // chain further JS promises /// .sink { ... } /// ``` + /// + /// ## Async/await counterpart + /// + /// JavaScriptKit's `JavaScriptEventLoop` module ships `JSPromise.value` + /// (`get async throws(JSException)`), the async counterpart of this publisher: + /// + /// ```swift + /// import JavaScriptEventLoop + /// + /// let value = try await promise.value + /// ``` + /// + /// Both paths observe the same settlement: the resolved `JSValue`, or — on rejection — + /// the same raw JS reason (`PromiseError.value` here, `JSException.thrownValue` there). + /// `.publisher` is not deprecated and remains fully supported alongside the async path; + /// any soft-deprecation decision is deferred to 1.0 (issue #13). var publisher: PromisePublisher { .init(promise: self) } diff --git a/Sources/OpenCombineJS/JSScheduler+Async.swift b/Sources/OpenCombineJS/JSScheduler+Async.swift new file mode 100644 index 0000000..1adbc31 --- /dev/null +++ b/Sources/OpenCombineJS/JSScheduler+Async.swift @@ -0,0 +1,115 @@ +// Copyright 2020 OpenCombineJS contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Async/await bridge over JSScheduler's JS timer semantics (issue #13). +// Strictly additive: the Combine `Scheduler` surface is unchanged and not deprecated. + +// Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + +public extension JSScheduler { + /// Suspends the current task for the given interval, driven by a JavaScript timer. + /// + /// This is the async/await counterpart of ``JSScheduler/schedule(after:tolerance:options:_:)``. + /// The delay is implemented with `setTimeout` — **not** `Task.sleep` — so it follows exactly + /// the same JS macrotask semantics as the Combine scheduling APIs and shares the scheduler's + /// internal timer bookkeeping (the timer entry is removed when it fires; nothing leaks). + /// + /// The method never resumes early: the continuation is resumed by the JS timer callback, which + /// the JS runtime fires no earlier than the requested delay. Zero or negative intervals resume + /// on the next event-loop turn (JS timers clamp negative delays to `0`). + /// + /// ## Executor requirement (WASI) + /// + /// On WASI, `await`-ing requires the `JavaScriptEventLoop` global executor so suspended tasks + /// are resumed by JS timers/promises. Call `JavaScriptEventLoop.installGlobalExecutor()` once + /// at startup (the `JavaScriptEventLoopTestSupport` target installs it automatically in tests). + /// + /// - Parameter interval: How long to suspend, e.g. `.milliseconds(300)`. + func sleep(for interval: SchedulerTimeType.Stride) async { + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + schedule( + after: now.advanced(by: interval), + tolerance: minimumTolerance, + options: nil + ) { + continuation.resume() + } + } + } + + /// Returns an `AsyncStream` that yields once per `interval`, backed by the same repeating + /// JS timer (`setInterval`) semantics as + /// ``JSScheduler/schedule(after:interval:tolerance:options:_:)``. + /// + /// The first tick arrives one full `interval` after the call — the same first-fire semantics + /// as the Combine repeating schedule. The stream never finishes on its own; iteration ends + /// when the consumer breaks out of the loop or the consuming task is cancelled. Stream + /// termination cancels the underlying timer through the scheduler's token bookkeeping + /// (both the pending timeout and the interval timer are released; nothing leaks): + /// + /// ```swift + /// for await _ in scheduler.timer(interval: .milliseconds(500)) { + /// refresh() + /// if done { break } // cancels the underlying JS timer + /// } + /// ``` + /// + /// If the consumer is slower than the tick cadence, pending ticks are coalesced: the stream + /// buffers at most one tick (`bufferingNewest(1)`), mirroring how a UI-driven `setInterval` + /// callback would observe time, instead of queueing stale ticks unboundedly. + /// + /// ## Executor requirement (WASI) + /// + /// On WASI, iterating the stream requires the `JavaScriptEventLoop` global executor — see + /// ``JSScheduler/sleep(for:)``. + /// + /// - Parameter interval: The period between successive ticks. + /// - Returns: An infinite `AsyncStream` of `Void` ticks. + func timer(interval: SchedulerTimeType.Stride) -> AsyncStream<()> { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let cancellable = SendableCancellableBox( + schedule( + after: now, + interval: interval, + tolerance: minimumTolerance, + options: nil + ) { + continuation.yield(()) + } + ) + // `onTermination` must be `@Sendable`; the boxed `Cancellable` is not. This is safe on + // the single-threaded JS event loop (the same invariant documented on `JSScheduler`): + // termination handlers run on the only thread there is. + continuation.onTermination = { _ in + cancellable.cancellable.cancel() + } + } + } +} + +/// Wrapper that carries a non-`Sendable` `Cancellable` into the `@Sendable` stream-termination +/// handler. Safe because `JSScheduler` (and everything it schedules) lives on the +/// single-threaded JS event loop; see the `JSScheduler` class documentation. +private final class SendableCancellableBox: @unchecked Sendable { + let cancellable: any Cancellable + + init(_ cancellable: any Cancellable) { + self.cancellable = cancellable + } +} diff --git a/Sources/OpenCombineJS/JSScheduler.swift b/Sources/OpenCombineJS/JSScheduler.swift index 9a4b5af..1d02cd0 100644 --- a/Sources/OpenCombineJS/JSScheduler.swift +++ b/Sources/OpenCombineJS/JSScheduler.swift @@ -13,7 +13,13 @@ // limitations under the License. import JavaScriptKit + +// Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11). +#if canImport(Combine) +import Combine +#else import OpenCombine +#endif /// A Combine `Scheduler` backed by JavaScript timers (`setTimeout`/`setInterval`). /// diff --git a/Sources/OpenCombineJS/JSValueDecoder.swift b/Sources/OpenCombineJS/JSValueDecoder.swift index 43408b7..76ebcd4 100644 --- a/Sources/OpenCombineJS/JSValueDecoder.swift +++ b/Sources/OpenCombineJS/JSValueDecoder.swift @@ -13,7 +13,13 @@ // limitations under the License. import JavaScriptKit + +// Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11). +#if canImport(Combine) +import Combine +#else import OpenCombine +#endif /// Retroactive conformance that bridges JavaScriptKit's `JSValueDecoder` into the /// OpenCombine / Combine ecosystem. diff --git a/Sources/OpenCombineJSExample/main.swift b/Sources/OpenCombineJSExample/main.swift index 90b8b25..7c0a75f 100644 --- a/Sources/OpenCombineJSExample/main.swift +++ b/Sources/OpenCombineJSExample/main.swift @@ -19,9 +19,15 @@ // to register state and return — execution continues via the event loop thereafter. import JavaScriptKit -import OpenCombine import OpenCombineJS +// Dual Combine backend — see OpenCombineJS/JSPromise.swift for the rationale (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + // MARK: - Helpers /// Wraps the browser's `fetch` global as a typed Swift function. diff --git a/Tests/OpenCombineJSTests/DifferentialTests.swift b/Tests/OpenCombineJSTests/DifferentialTests.swift new file mode 100644 index 0000000..10f5267 --- /dev/null +++ b/Tests/OpenCombineJSTests/DifferentialTests.swift @@ -0,0 +1,273 @@ +// Copyright 2020 OpenCombineJS contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// DIFFERENTIAL TESTS (06-test-data-strategy.md §4, issue #13): drive the same input through +// the legacy Combine path and the new async/await path; observable outcomes must match +// (value, error mapping, tick cadence). WASI-only: live JS promises/timers and the +// JavaScriptEventLoop global executor (installed by JavaScriptEventLoopTestSupport) are +// required. `@testable import` is used so the suite can also verify that the async APIs +// reuse the scheduler's timer bookkeeping and leak nothing (issue #4 invariant). + +#if os(WASI) +import JavaScriptEventLoop +import JavaScriptKit +@testable import OpenCombineJS +import Testing + +// Dual Combine backend — see Sources/OpenCombineJS/JSPromise.swift (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + +// MARK: - Differential 1: JSPromise — .publisher (legacy) vs try await .value (new) + +struct DifferentialPromiseTests { + /// DT-JP-01 — a resolved promise yields the identical value on both paths. + @Test("DIFFERENTIAL: resolved JSPromise yields identical value via .publisher and .value") + func resolvedValueIdentical() async throws { + let expected = JSValue.string("differential-test-value") + let promise = JSPromise.resolve(expected) + + // Legacy path: Combine publisher. + var publisherValue: JSValue? + var publisherError: JSPromise.PromiseError? + var sink: AnyCancellable? + await confirmation("publisher path completes") { completed in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + sink = promise.publisher.sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { publisherError = error } + completed() + continuation.resume() + }, + receiveValue: { publisherValue = $0 } + ) + } + } + withExtendedLifetime(sink) {} + + // New path: async/await (JavaScriptKit's JSPromise.value). + let asyncValue = try await promise.value + + #expect(publisherError == nil, "publisher path must not fail for a resolved promise") + #expect(publisherValue == expected) + #expect(asyncValue == expected) + #expect(publisherValue == asyncValue, "DIFFERENTIAL FAILURE: paths diverged") + } + + /// DT-JP-02 — a rejected promise surfaces the identical raw JS rejection reason on both + /// paths: `PromiseError.value` (publisher) and `JSException.thrownValue` (async). + @Test("DIFFERENTIAL: rejected JSPromise maps to the identical error value on both paths") + func rejectedErrorIdentical() async { + let reason = JSValue.string("differential-rejection") + let promise = JSPromise.reject(reason) + + // Legacy path. + var publisherError: JSPromise.PromiseError? + var sink: AnyCancellable? + await confirmation("publisher failure received") { completed in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + sink = promise.publisher.sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { publisherError = error } + completed() + continuation.resume() + }, + receiveValue: { _ in } + ) + } + } + withExtendedLifetime(sink) {} + + // New path. `JSPromise.value` throws typed `JSException`. + var asyncThrownValue: JSValue? + do { + _ = try await promise.value + Issue.record("async path must throw for a rejected promise") + } catch { + asyncThrownValue = error.thrownValue + } + + #expect(publisherError?.value == reason) + #expect(asyncThrownValue == reason) + #expect( + publisherError?.value == asyncThrownValue, + "DIFFERENTIAL FAILURE: rejection reason diverged between paths" + ) + } + + /// DT-JP-03 — a promise that settles *after* both paths subscribed delivers the same value. + @Test("DIFFERENTIAL: pending-then-resolved JSPromise delivers the same value on both paths") + func pendingThenResolvedIdentical() async throws { + var resolveHandler: ((JSPromise.Result) -> ())? + let promise = JSPromise { resolveHandler = $0 } + let expected = JSValue.number(42) + + // Attach the publisher subscriber while the promise is still pending. + var publisherValue: JSValue? + let sink = promise.publisher.sink( + receiveCompletion: { _ in }, + receiveValue: { publisherValue = $0 } + ) + + // Resolve on a later event-loop turn, then await the async path; the await suspends + // until the timer fires and the promise settles, exercising the pending case. + var timer: JSTimer? + timer = JSTimer(millisecondsDelay: 10) { + resolveHandler?(.success(expected)) + } + let asyncValue = try await promise.value + withExtendedLifetime(timer) {} + withExtendedLifetime(sink) {} + + // Drain the microtask queue so the publisher delivery (registered first) is settled too. + await eventLoopSleep(milliseconds: 10) + + #expect(asyncValue == expected) + #expect(publisherValue == expected) + #expect(publisherValue == asyncValue, "DIFFERENTIAL FAILURE: paths diverged") + } +} + +// MARK: - Differential 2: JSScheduler — Combine repeating schedule vs timer(interval:) + +struct DifferentialSchedulerTests { + /// DT-SC-01 — both paths tick at a comparable cadence for the same interval. + /// Tolerance bands follow 06-test-data-strategy.md §4.2: per-tick band + /// [interval − 20 ms, interval + 200 ms] (generous upper bound for wasm event-loop + /// jitter), and ≤ 25 ms divergence between the two paths' mean cadences. + @Test("DIFFERENTIAL: Combine schedule and timer(interval:) tick at comparable cadence") + func tickCadenceComparable() async throws { + let scheduler = JSScheduler() + let intervalMs = 40.0 + let requiredTicks = 4 // 3 gaps ≥ the "≥3 ticks" requirement + + // Legacy path: Combine repeating schedule. + var combineTimes = [Double]() + var cancellable: (any Cancellable)? + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + var resumed = false + cancellable = scheduler.schedule( + after: scheduler.now, + interval: .milliseconds(Int(intervalMs)), + tolerance: .milliseconds(10), + options: nil + ) { + combineTimes.append(JSDate.now()) + if combineTimes.count == requiredTicks, !resumed { + resumed = true + continuation.resume() + } + } + } + cancellable?.cancel() + + // New path: AsyncStream ticks. + var asyncTimes = [Double]() + for await _ in scheduler.timer(interval: .milliseconds(Int(intervalMs))) { + asyncTimes.append(JSDate.now()) + if asyncTimes.count >= requiredTicks { break } + } + + #expect(combineTimes.count >= requiredTicks) + #expect(asyncTimes.count >= requiredTicks) + + // Per-tick band on the async path (the Combine path is pinned by SC-08). + for index in 1.. 0, "tick timestamps must be monotonically increasing") + #expect( + gap >= intervalMs - 20.0 && gap <= intervalMs + 200.0, + "async tick cadence \(gap)ms outside tolerance band for \(intervalMs)ms interval" + ) + } + + // Differential: mean cadences of the two paths must agree within the band. + let combineFirst = try #require(combineTimes.first) + let combineLast = try #require(combineTimes.last) + let asyncFirst = try #require(asyncTimes.first) + let asyncLast = try #require(asyncTimes.last) + let combineMean = (combineLast - combineFirst) / Double(combineTimes.count - 1) + let asyncMean = (asyncLast - asyncFirst) / Double(asyncTimes.count - 1) + #expect( + abs(combineMean - asyncMean) < 25.0, + "DIFFERENTIAL FAILURE: Combine cadence \(combineMean)ms vs async cadence \(asyncMean)ms" + ) + } + + /// DT-SC-02 — terminating the stream stops ticking and releases the underlying timers + /// through the scheduler's token bookkeeping (issue #4 invariant), exactly like + /// `Cancellable.cancel()` does on the Combine path. + @Test("DIFFERENTIAL: breaking out of timer(interval:) cancels the underlying JS timer") + func streamTerminationCancelsTimer() async { + let scheduler = JSScheduler() + var combineTicks = 0 + var asyncTicks = 0 + + // Combine path: cancel after 2 ticks. + var cancellable: (any Cancellable)? + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + var resumed = false + cancellable = scheduler.schedule( + after: scheduler.now, + interval: .milliseconds(20), + tolerance: .milliseconds(5), + options: nil + ) { + combineTicks += 1 + if combineTicks == 2, !resumed { + resumed = true + continuation.resume() + } + } + } + cancellable?.cancel() + let combineTicksAtCancel = combineTicks + + // Async path: break after 2 ticks. + for await _ in scheduler.timer(interval: .milliseconds(20)) { + asyncTicks += 1 + if asyncTicks >= 2 { break } + } + let asyncTicksAtBreak = asyncTicks + + // Drain window: several would-be intervals; neither path may keep ticking, and the + // scheduler's storage must be empty (stream termination released its timers). + await eventLoopSleep(milliseconds: 120) + + #expect(combineTicks == combineTicksAtCancel, "Combine path kept firing after cancel()") + #expect(asyncTicks == asyncTicksAtBreak, "async path kept firing after break") + #expect( + scheduler.scheduledTimers.isEmpty, + "stream termination must release the underlying timers (issue #4 invariant)" + ) + } + + /// DT-SC-03 — `sleep(for:)` must not return early, and must clean up its timer entry. + @Test("DIFFERENTIAL: sleep(for:) does not return early") + func sleepDoesNotReturnEarly() async { + let scheduler = JSScheduler() + let intervalMs = 60.0 + let start = JSDate.now() + await scheduler.sleep(for: .milliseconds(Int(intervalMs))) + let elapsed = JSDate.now() - start + // JS timers fire no earlier than the requested delay; the JSDate.now() reference is + // taken BEFORE sleep computes its target date, so strict >= must hold. + #expect(elapsed >= intervalMs, "sleep returned after \(elapsed)ms — early for \(intervalMs)ms") + #expect(scheduler.scheduledTimers.isEmpty, "sleep must not leak its one-shot timer entry") + } +} +#endif diff --git a/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift b/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift index 0718fa2..5b76093 100644 --- a/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift +++ b/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift @@ -17,10 +17,16 @@ #if os(WASI) import JavaScriptKit -import OpenCombine @testable import OpenCombineJS import Testing +// Dual Combine backend — see Sources/OpenCombineJS/JSPromise.swift (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + // MARK: - PromiseError struct PromiseErrorTests { diff --git a/Tests/OpenCombineJSTests/JSSchedulerTests.swift b/Tests/OpenCombineJSTests/JSSchedulerTests.swift index 4283d23..222ae46 100644 --- a/Tests/OpenCombineJSTests/JSSchedulerTests.swift +++ b/Tests/OpenCombineJSTests/JSSchedulerTests.swift @@ -19,10 +19,16 @@ #if os(WASI) import JavaScriptKit -import OpenCombine @testable import OpenCombineJS import Testing +// Dual Combine backend — see Sources/OpenCombineJS/JSPromise.swift (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + // MARK: - now and minimumTolerance struct JSSchedulerPropertiesTests { diff --git a/Tests/OpenCombineJSTests/JSValueDecoderTests.swift b/Tests/OpenCombineJSTests/JSValueDecoderTests.swift index 6ff08f4..ba1755f 100644 --- a/Tests/OpenCombineJSTests/JSValueDecoderTests.swift +++ b/Tests/OpenCombineJSTests/JSValueDecoderTests.swift @@ -18,10 +18,16 @@ #if os(WASI) import JavaScriptKit -import OpenCombine @testable import OpenCombineJS import Testing +// Dual Combine backend — see Sources/OpenCombineJS/JSPromise.swift (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + // MARK: - TopLevelDecoder conformance struct JSValueDecoderConformanceTests {