From 328e5171f79d508150e14dfb10995306493f06ba Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:39:45 +0300 Subject: [PATCH 1/3] #11 feat!: use native Combine on Apple platforms All OpenCombine symbols consumed by this library (Publisher, Future, Subscriber, Subscription, Scheduler, Cancellable, TopLevelDecoder, CombineIdentifier, Subscribers.*) are mirrored 1:1 by Apple Combine, so the only barrier to native-Combine consumption was module identity. Replacing the unconditional 'import OpenCombine' with '#if canImport(Combine) import Combine #else import OpenCombine #endif' lets Apple-platform consumers integrate JSPromise.publisher, JSScheduler and JSValueDecoder directly into native Combine pipelines, and is the strangler-fig entry point for the eventual OpenCombine exit (#15). The host (macOS) test lane now exercises the Combine backend; the wasm lane keeps exercising OpenCombine. The package dependency on OpenCombine stays unconditional until Wave 3. API digester vs main (expected, declared): PromisePublisher.receive(subscriber:) generic signature changed from 'Downstream : OpenCombine.Subscriber' to 'Downstream : Combine.Subscriber'. BREAKING CHANGE: on Apple platforms the library's Combine types are now Combine module types, not OpenCombine ones. Apple consumers that fed these into OpenCombine operators must switch to native Combine. WASI consumers are unaffected. --- CHANGELOG.md | 15 +++++++++++++++ Sources/OpenCombineJS/JSPromise.swift | 11 +++++++++++ Sources/OpenCombineJS/JSScheduler.swift | 6 ++++++ Sources/OpenCombineJS/JSValueDecoder.swift | 6 ++++++ Sources/OpenCombineJSExample/main.swift | 8 +++++++- .../JSPromisePublisherTests.swift | 8 +++++++- Tests/OpenCombineJSTests/JSSchedulerTests.swift | 8 +++++++- .../OpenCombineJSTests/JSValueDecoderTests.swift | 8 +++++++- 8 files changed, 66 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f62ef..0ebbdba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# 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)) + # 0.3.0 (2026-06-11) This release fixes four long-standing `JSScheduler` bugs and adds the package's first automated diff --git a/Sources/OpenCombineJS/JSPromise.swift b/Sources/OpenCombineJS/JSPromise.swift index a1b5188..6c2d4fe 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`. /// 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/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 { From e5140a5e540630007dbef74f287bb751bb5830d1 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:55:17 +0300 Subject: [PATCH 2/3] #13 feat: add async timer and sleep APIs alongside Combine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modern consumers expect an async/await surface; the strangler-fig plan keeps Combine publishers fully supported while growing async siblings. JSScheduler.sleep(for:) suspends via the scheduler's own one-shot setTimeout path (not Task.sleep) so async and Combine delays share JS macrotask semantics and timer bookkeeping. JSScheduler.timer(interval:) exposes the repeating schedule as an AsyncStream whose termination cancels the underlying JS timer through the same token bookkeeping, so breaking out of iteration leaks nothing. JSPromise already has an async counterpart upstream (JSPromise.value in JavaScriptEventLoop, JSKit 0.54.1) — documented as the counterpart of .publisher instead of duplicating it here. Wasm-gated differential tests (06-test-data-strategy.md §4) prove the legacy Combine path and the new async path agree: identical resolved value and rejection reason for the same JSPromise, comparable tolerance-banded tick cadence for schedule vs timer(interval:), no early return from sleep(for:), and empty timer storage after stream termination. Wasm suite grows 62 -> 68 tests; host stays 24. No deprecations: soft-deprecation of .publisher is deferred to the 1.0 decision per issue #13. --- CHANGELOG.md | 11 + Package.swift | 6 + .../Documentation.docc/OpenCombineJS.md | 56 +++- Sources/OpenCombineJS/JSPromise.swift | 16 + Sources/OpenCombineJS/JSScheduler+Async.swift | 115 ++++++++ .../DifferentialTests.swift | 273 ++++++++++++++++++ 6 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 Sources/OpenCombineJS/JSScheduler+Async.swift create mode 100644 Tests/OpenCombineJSTests/DifferentialTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ebbdba..2bad1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ 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)) + # 0.3.0 (2026-06-11) This release fixes four long-standing `JSScheduler` bugs and adds the package's first automated 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 6c2d4fe..b7cba80 100644 --- a/Sources/OpenCombineJS/JSPromise.swift +++ b/Sources/OpenCombineJS/JSPromise.swift @@ -115,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/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 From 0b4ed7ee91511c9efbbabe497eed2a97773da1a7 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:56:18 +0300 Subject: [PATCH 3/3] #12 docs: add CONTRIBUTING with build, test and policy guide The dependency-currency work (#12) introduced weekly canaries and an API gate, but the policies they enforce lived only in workflow comments and implementation logs. CONTRIBUTING.md makes them discoverable for contributors: exact host and wasm build/test commands (including the toolchain-mixing clean requirement and PackageToJS sandbox/scratch-path gotchas), the minimum JavaScriptKit version and canary failure handling, the JSValueDecoder deletion trigger (#9), and the release/semver flow (digest against PR base, declared breaks in CHANGELOG, stamp then tag). --- CHANGELOG.md | 4 ++++ CONTRIBUTING.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bad1f9..d0ef337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ 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) 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).