From 659ed5f1942e6864504d8e539946aacfa6953648 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:51:56 +0300 Subject: [PATCH 1/8] #2 fix(scheduler): correct stride math, cancellation, leaks, repeat Four verified bugs in JSScheduler, found during the modernization deep-analysis and pinned by the new test suite: - Stride.microseconds/.nanoseconds computed reciprocals instead of millisecond values (.microseconds(1000) yielded 1e-6 ms, not 1 ms). - Cancelling schedule(after:interval:) before the first fire force-unwrapped a nil IUO (crash) and never cleared the pending timeout, so the repeating phase started anyway. - Cleanup closures captured [weak timer] before the variable was assigned, so the captured binding was permanently nil and every JSTimer leaked in scheduledTimers forever. Replaced with token-based bookkeeping (key generated before timer creation). - The interval timer was created without isRepeating: true (JSTimer defaults to one-shot), so the repeating schedule fired exactly once. Also documents the single-threaded JS event-loop invariant and adds DocC comments to the public scheduler surface (#10). Fixes #2 Fixes #3 Fixes #4 Fixes #5 --- Sources/OpenCombineJS/JSScheduler.swift | 186 ++++++++++++++++++++---- 1 file changed, 156 insertions(+), 30 deletions(-) diff --git a/Sources/OpenCombineJS/JSScheduler.swift b/Sources/OpenCombineJS/JSScheduler.swift index 505be9f..9a4b5af 100644 --- a/Sources/OpenCombineJS/JSScheduler.swift +++ b/Sources/OpenCombineJS/JSScheduler.swift @@ -15,7 +15,27 @@ import JavaScriptKit import OpenCombine +/// A Combine `Scheduler` backed by JavaScript timers (`setTimeout`/`setInterval`). +/// +/// Use `JSScheduler` with any time-dependent Combine operator — `debounce`, `throttle`, +/// `delay`, `measureInterval`, `timeout` — when targeting a WebAssembly/browser environment: +/// +/// ```swift +/// publisher +/// .debounce(for: .milliseconds(300), scheduler: JSScheduler()) +/// .sink { ... } +/// ``` +/// +/// ## Event-loop invariant +/// +/// `JSScheduler` relies on the single-threaded JavaScript event loop. All scheduling calls +/// and all timer callbacks execute on the same thread, which makes the unsynchronized +/// `scheduledTimers` dictionary safe without locks. +/// +/// - Important: `JSScheduler` is intentionally **not** `Sendable`. Instances must not be +/// shared across concurrency domains or moved off the JS event-loop thread. public final class JSScheduler: Scheduler { + /// Creates a new `JSScheduler` backed by the current JavaScript runtime. public init() {} private final class CancellableTimer: Cancellable { @@ -30,54 +50,97 @@ public final class JSScheduler: Scheduler { } } + /// A point in time measured in milliseconds since the Unix epoch, as reported by `Date.now()`. + /// + /// Conforms to `Strideable` so Combine operators can compute time differences and advances. public struct SchedulerTimeType: Strideable { let millisecondsValue: Double + /// Returns a time advanced by the given stride. + /// + /// - Parameter n: The stride (in milliseconds) to add to this time. + /// - Returns: A new `SchedulerTimeType` offset by `n`. public func advanced(by n: Stride) -> Self { .init(millisecondsValue: millisecondsValue + n.magnitude) } + /// Returns the stride from this time to `other`. + /// + /// - Parameter other: The target time. + /// - Returns: A `Stride` representing `other − self` in milliseconds. public func distance(to other: Self) -> Stride { .init(millisecondsValue: other.millisecondsValue - millisecondsValue) } + /// A time interval expressed in milliseconds. + /// + /// Conforms to `SchedulerTimeIntervalConvertible` so Combine operators can accept + /// standard time-literal arguments (`.seconds(1)`, `.milliseconds(500)`, etc.). public struct Stride: SchedulerTimeIntervalConvertible, Comparable, SignedNumeric { - /// Time interval magnitude in milliseconds + /// Time interval magnitude in milliseconds. public var magnitude: Double + /// Creates a `Stride` from an exact integer source, or returns `nil` if the value + /// cannot be represented losslessly as a `Double`. + /// + /// - Parameter source: The integer value to convert. public init?(exactly source: T) where T: BinaryInteger { guard let magnitude = Double(exactly: source) else { return nil } self.magnitude = magnitude } + /// Creates a `Stride` with the given millisecond value. + /// + /// - Parameter millisecondsValue: Duration in milliseconds. public init(millisecondsValue: Double) { magnitude = millisecondsValue } + /// Creates a `Stride` from a floating-point literal, interpreted as seconds. + /// + /// - Parameter value: Duration in seconds (e.g. `1.5` → 1 500 ms). public init(floatLiteral value: Double) { self = .seconds(value) } + /// Creates a `Stride` from an integer literal, interpreted as seconds. + /// + /// - Parameter value: Duration in seconds. public init(integerLiteral value: Int) { self = .seconds(value) } + /// Returns a stride representing the given number of microseconds. + /// + /// - Parameter us: Duration in microseconds. public static func microseconds(_ us: Int) -> Self { - .init(millisecondsValue: 1.0 / (Double(us) * 1000)) + .init(millisecondsValue: Double(us) / 1000) } + /// Returns a stride representing the given number of milliseconds. + /// + /// - Parameter ms: Duration in milliseconds. public static func milliseconds(_ ms: Int) -> Self { .init(millisecondsValue: Double(ms)) } + /// Returns a stride representing the given number of nanoseconds. + /// + /// - Parameter ns: Duration in nanoseconds. public static func nanoseconds(_ ns: Int) -> Self { - .init(millisecondsValue: 1.0 / (Double(ns) * 1_000_000)) + .init(millisecondsValue: Double(ns) / 1_000_000) } + /// Returns a stride representing the given number of seconds (floating-point). + /// + /// - Parameter s: Duration in seconds. public static func seconds(_ s: Double) -> Self { .init(millisecondsValue: s * 1000) } + /// Returns a stride representing the given number of seconds (integer). + /// + /// - Parameter s: Duration in seconds. public static func seconds(_ s: Int) -> Self { .init(millisecondsValue: Double(s) * 1000) } @@ -112,45 +175,96 @@ public final class JSScheduler: Scheduler { } } + /// Opaque options type; `JSScheduler` has no configurable scheduling options. public struct SchedulerOptions {} - public var now: SchedulerTimeType { .init(millisecondsValue: JSDate.now()) } + /// The current time as reported by `Date.now()`, expressed in milliseconds. + public var now: SchedulerTimeType { + .init(millisecondsValue: JSDate.now()) + } + /// The minimum tolerance accepted by the scheduler. + /// + /// Returns the smallest representable positive `Double` because JavaScript timers do not + /// provide sub-millisecond precision guarantees; callers should not rely on tolerances + /// smaller than one millisecond. public var minimumTolerance: SchedulerTimeType.Stride { .init(millisecondsValue: .leastNonzeroMagnitude) } - private var scheduledTimers = [ObjectIdentifier: JSTimer]() + /// Monotonically increasing token identifying entries in `scheduledTimers`. Tokens are + /// generated *before* the corresponding `JSTimer` is created so that cleanup closures capture + /// a plain value instead of the timer itself (capturing the implicitly-unwrapped timer variable + /// in a `[weak …]` list evaluated while it was still `nil` made cleanup unreachable and leaked + /// every timer, see issue #4). + private var nextTimerToken: UInt64 = 0 + + /// Storage keeping scheduled timers alive. Entries are removed when a one-shot timer fires or + /// when a repeating schedule is cancelled; removing an entry drops the last strong reference to + /// the `JSTimer`, whose `deinit` clears the underlying JS timer. Internal (not `private`) so + /// the test suite can verify cleanup via `@testable import`. + var scheduledTimers = [UInt64: JSTimer]() + + private func nextToken() -> UInt64 { + defer { nextTimerToken += 1 } + return nextTimerToken + } + /// Schedules `action` for immediate execution on the next event-loop turn. + /// + /// Wraps `setTimeout(action, 0)`. The action executes asynchronously — after the current + /// call stack unwinds — rather than synchronously inline. + /// + /// - Parameters: + /// - options: Ignored; `JSScheduler` has no scheduling options. + /// - action: The closure to execute. public func schedule(options: SchedulerOptions?, _ action: @escaping () -> ()) { - var timer: JSTimer! - timer = .init(millisecondsDelay: 0) { [weak self, weak timer] in + let token = nextToken() + scheduledTimers[token] = JSTimer(millisecondsDelay: 0) { [weak self] in action() - if let timer = timer { - self?.scheduledTimers[ObjectIdentifier(timer)] = nil - } + self?.scheduledTimers[token] = nil } - scheduledTimers[ObjectIdentifier(timer)] = timer } + /// Schedules `action` for execution after the specified date. + /// + /// The delay is computed as `date.millisecondsValue − Date.now()` and passed to + /// `setTimeout`. If the date is in the past the action fires on the next event-loop turn. + /// + /// - Parameters: + /// - date: The earliest time at which `action` should execute. + /// - tolerance: Ignored; JavaScript timers do not support tolerance hints. + /// - options: Ignored; `JSScheduler` has no scheduling options. + /// - action: The closure to execute. public func schedule( after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> () ) { - var timer: JSTimer! - timer = .init( + let token = nextToken() + scheduledTimers[token] = JSTimer( millisecondsDelay: date.millisecondsValue - JSDate.now() - ) { [weak self, weak timer] in + ) { [weak self] in action() - if let timer = timer { - self?.scheduledTimers[ObjectIdentifier(timer)] = nil - } + self?.scheduledTimers[token] = nil } - scheduledTimers[ObjectIdentifier(timer)] = timer } + /// Schedules `action` to execute repeatedly at the given interval, starting after `date`. + /// + /// The first invocation fires `interval` milliseconds after `date`; subsequent invocations + /// fire every `interval` milliseconds thereafter. Returns a `Cancellable` that stops both + /// the pending delay timer and the repeating interval timer — safe to call before the first + /// fire, between fires, or multiple times (repeated cancellation is a no-op). + /// + /// - Parameters: + /// - date: The time after which the repeating schedule begins. + /// - interval: The period between successive firings. + /// - tolerance: Ignored; JavaScript timers do not support tolerance hints. + /// - options: Ignored; `JSScheduler` has no scheduling options. + /// - action: The closure to execute on each interval tick. + /// - Returns: A `Cancellable` that stops the repeating schedule when cancelled. public func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, @@ -158,21 +272,33 @@ public final class JSScheduler: Scheduler { options: SchedulerOptions?, _ action: @escaping () -> () ) -> Cancellable { - var timeoutTimer, intervalTimer: JSTimer! + let timeoutToken = nextToken() + let intervalToken = nextToken() + // Shared between the timeout callback and the cancellation closure; safe without + // synchronization because both always run on the single-threaded JS event loop. + var isCancelled = false - timeoutTimer = .init( + scheduledTimers[timeoutToken] = JSTimer( millisecondsDelay: date.millisecondsValue - JSDate.now() - ) { [weak self, weak timeoutTimer] in - intervalTimer = .init(millisecondsDelay: interval.magnitude) { action() } - - self?.scheduledTimers[ObjectIdentifier(intervalTimer)] = intervalTimer - - if let timeoutTimer = timeoutTimer { - self?.scheduledTimers[ObjectIdentifier(timeoutTimer)] = nil - } + ) { [weak self] in + guard let self = self else { return } + self.scheduledTimers[timeoutToken] = nil + // Guards against a cancellation processed after this callback was already queued + // (issue #3): the interval must never start once cancel() has run. + guard !isCancelled else { return } + self.scheduledTimers[intervalToken] = JSTimer( + millisecondsDelay: interval.magnitude, + isRepeating: true + ) { action() } } - scheduledTimers[ObjectIdentifier(timeoutTimer)] = timeoutTimer - return CancellableTimer { self.scheduledTimers[ObjectIdentifier(intervalTimer)] = nil } + // Cancellation is safe at any point in the schedule's lifecycle (issue #3): before the first + // fire it clears the pending timeout, afterwards it clears the repeating interval, and + // repeated cancellation is a no-op (removing absent keys has no effect). + return CancellableTimer { + isCancelled = true + self.scheduledTimers[timeoutToken] = nil + self.scheduledTimers[intervalToken] = nil + } } } From 8e10b9615878c75a4107efad85b692ae7df7db02 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:52:11 +0300 Subject: [PATCH 2/8] #7 test: add Swift Testing suite (62 wasm / 24 host tests) The library previously had zero tests (55 untested code paths). Adds the OpenCombineJSTests target with characterization and behavioral-contract tests designed in the modernization analysis: - SchedulerTimeTypeTests: pure Swift, host-runnable (stride conversions, arithmetic, Strideable laws; regressions for #2). - JSSchedulerTests, JSPromisePublisherTests, JSValueDecoderTests: JS-event-loop tests gated to WASI (regressions for #3 #4 #5, Future caching semantics, TopLevelDecoder fixtures). - In-code fixture factories; no file I/O under WASI. Runner: host subset via `swift test`; full suite under Node via JavaScriptKit PackageToJS: swift package --disable-sandbox \ --swift-sdk swift-6.3.2-RELEASE_wasm js test Package.swift also gains swift-docc-plugin for the DocC catalog (#10). Closes #7 --- Package.resolved | 20 +- Package.swift | 17 + .../Fixtures/JSFixtures.swift | 123 ++++++++ .../JSPromisePublisherTests.swift | 235 ++++++++++++++ .../OpenCombineJSTests/JSSchedulerTests.swift | 292 ++++++++++++++++++ .../JSValueDecoderTests.swift | 160 ++++++++++ .../SchedulerTimeTypeTests.swift | 235 ++++++++++++++ Tests/OpenCombineJSTests/TestHelpers.swift | 33 ++ 8 files changed, 1114 insertions(+), 1 deletion(-) create mode 100644 Tests/OpenCombineJSTests/Fixtures/JSFixtures.swift create mode 100644 Tests/OpenCombineJSTests/JSPromisePublisherTests.swift create mode 100644 Tests/OpenCombineJSTests/JSSchedulerTests.swift create mode 100644 Tests/OpenCombineJSTests/JSValueDecoderTests.swift create mode 100644 Tests/OpenCombineJSTests/SchedulerTimeTypeTests.swift create mode 100644 Tests/OpenCombineJSTests/TestHelpers.swift diff --git a/Package.resolved b/Package.resolved index 1b0b03a..8581f5d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f766f5ea40486d50b80c1eb8722796c9553c047522dbf7681b1b7d4079d37e83", + "originHash" : "26f84147e6f3eb47e6b2a8df68cd52c8895a20becddcc8d672f77bf6c966c785", "pins" : [ { "identity" : "javascriptkit", @@ -19,6 +19,24 @@ "version" : "0.14.0" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-plugin", + "state" : { + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 0d1d0c4..4dbed39 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version:6.3 import PackageDescription + let package = Package( name: "OpenCombineJS", platforms: [ @@ -16,6 +17,7 @@ let package = Package( from: "0.54.1" ), .package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.14.0"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0"), ], targets: [ .target( @@ -30,6 +32,21 @@ let package = Package( "JavaScriptKit", "OpenCombine", ] ), + .testTarget( + name: "OpenCombineJSTests", + dependencies: [ + "OpenCombineJS", + "JavaScriptKit", + "OpenCombine", + // Installs the JavaScriptEventLoop global executor at startup so async tests can be + // resumed by JS timers/promises. Only meaningful (and only linked) on WASI. + .product( + name: "JavaScriptEventLoopTestSupport", + package: "JavaScriptKit", + condition: .when(platforms: [.wasi]) + ), + ] + ), ], swiftLanguageModes: [.v6] ) diff --git a/Tests/OpenCombineJSTests/Fixtures/JSFixtures.swift b/Tests/OpenCombineJSTests/Fixtures/JSFixtures.swift new file mode 100644 index 0000000..c09f420 --- /dev/null +++ b/Tests/OpenCombineJSTests/Fixtures/JSFixtures.swift @@ -0,0 +1,123 @@ +// 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. + +// Central in-code factory for JSValue test fixtures (06-test-data-strategy.md §1). +// Fixtures are constructed programmatically — no file I/O — so they bundle into the +// single-module wasm test binary without a data-loading layer. + +#if os(WASI) +import JavaScriptKit + +enum JSFixtures { + // ── Primitives ─────────────────────────────────────────────────────────── + + /// F-01 — plain ASCII string. + static func stringASCII() -> JSValue { + .string("hello") + } + + /// F-03 — Unicode multibyte string. + static func stringUnicode() -> JSValue { + .string("こんにちは") + } + + /// F-09 — finite positive double. + static func numberFinite() -> JSValue { + .number(3.14) + } + + /// F-21 / F-22 — booleans. + static func boolTrue() -> JSValue { + .boolean(true) + } + + static func boolFalse() -> JSValue { + .boolean(false) + } + + // ── Objects (require a live JS heap) ───────────────────────────────────── + + /// F-28 — flat object: `{ "name": "Alice", "age": 30 }`. + static func objectFlat() -> JSValue { + let object = JSObject.global.Object.function!.new() + object.name = .string("Alice") + object.age = .number(30) + return .object(object) + } + + /// F-29 — object with a missing key (no `age`): `{ "name": "Bob" }`. + static func objectMissingKey() -> JSValue { + let object = JSObject.global.Object.function!.new() + object.name = .string("Bob") + return .object(object) + } + + /// F-30 — nested object: `{ "user": { "id": 1, "label": "admin" } }`. + static func objectNested() -> JSValue { + JSObject.global.JSON.parse(#"{"user":{"id":1,"label":"admin"}}"#) + } + + /// F-37 — object with optional field present: `{ "name": "Carol", "nickname": "C" }`. + static func objectOptionalPresent() -> JSValue { + JSObject.global.JSON.parse(#"{"name":"Carol","nickname":"C"}"#) + } + + /// F-38 — object with optional field absent: `{ "name": "Dave" }`. + static func objectOptionalAbsent() -> JSValue { + JSObject.global.JSON.parse(#"{"name":"Dave"}"#) + } + + // ── Arrays ─────────────────────────────────────────────────────────────── + + /// F-32 — homogeneous array: `[1, 2, 3, 4, 5]`. + static func arrayHomogeneous() -> JSValue { + JSObject.global.JSON.parse("[1,2,3,4,5]") + } + + /// F-35 — empty array: `[]`. + static func arrayEmpty() -> JSValue { + JSObject.global.JSON.parse("[]") + } + + /// F-36 — array of objects: `[{ "x": 1 }, { "x": 2 }]`. + static func arrayOfObjects() -> JSValue { + JSObject.global.JSON.parse(#"[{"x":1},{"x":2}]"#) + } +} + +// ── Decodable helpers used by the decoder tests (06 §1.5) ────────────────── + +struct Person: Decodable, Equatable { + let name: String + let age: Int +} + +struct WithOptional: Decodable, Equatable { + let name: String + let nickname: String? +} + +struct UserWrapper: Decodable, Equatable { + struct User: Decodable, Equatable { + let id: Int + let label: String + } + + let user: User +} + +struct Point: Decodable, Equatable { + let x: Int +} +#endif diff --git a/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift b/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift new file mode 100644 index 0000000..0718fa2 --- /dev/null +++ b/Tests/OpenCombineJSTests/JSPromisePublisherTests.swift @@ -0,0 +1,235 @@ +// 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. + +// Tests for JSPromise.PromisePublisher and PromiseError. WASI-only: a live JS Promise object +// and the JS event loop (microtask queue) are required. + +#if os(WASI) +import JavaScriptKit +import OpenCombine +@testable import OpenCombineJS +import Testing + +// MARK: - PromiseError + +struct PromiseErrorTests { + /// JP-01 — initializer stores the JSValue + @Test("PromiseError stores the wrapped JSValue") + func promiseErrorStoresValue() { + let reason = JSValue.string("rejection reason") + #expect(JSPromise.PromiseError(reason).value == reason) + } + + /// JP-02 — Equatable: equal values + @Test("PromiseError instances wrapping the same JSValue are equal") + func promiseErrorEqualityEqual() { + let value = JSValue.number(42) + #expect(JSPromise.PromiseError(value) == JSPromise.PromiseError(value)) + } + + /// JP-03 — Equatable: different values + @Test("PromiseError instances wrapping different JSValues are not equal") + func promiseErrorEqualityNotEqual() { + #expect(JSPromise.PromiseError(.number(1)) != JSPromise.PromiseError(.number(2))) + } +} + +// MARK: - publisher computed property + +struct JSPromisePublisherVarTests { + /// JP-09 — each access returns a fresh publisher instance + @Test(".publisher returns a new PromisePublisher on each access") + func publisherVarReturnsDistinctInstances() { + let promise = JSPromise.resolve(JSValue.string("value")) + #expect(promise.publisher !== promise.publisher) + } +} + +// MARK: - Success path + +struct JSPromisePublisherSuccessTests { + /// JP-06 / CT-JP-01 — resolved promise delivers exactly one value, then .finished + @Test("resolved Promise delivers one value then .finished") + func resolvedPromiseDeliversValueThenFinished() async { + let expected = JSValue.string("success-output") + let publisher = JSPromise.resolve(expected).publisher + var values = [JSValue]() + var completions = [Subscribers.Completion]() + var sink: AnyCancellable? + + await confirmation("completion received") { completionReceived in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + sink = publisher.sink( + receiveCompletion: { completion in + completions.append(completion) + completionReceived() + continuation.resume() + }, + receiveValue: { values.append($0) } + ) + } + } + withExtendedLifetime(sink) {} + + #expect(values == [expected]) + #expect(completions.count == 1) + guard case .finished = completions.first else { + Issue.record("Expected .finished, got \(String(describing: completions.first))") + return + } + } + + /// JP-04 / JP-08 / CT-JP-03 — Future caching: an already-resolved promise replays its + /// result to subscribers attaching after resolution. + @Test("already-resolved Promise replays the cached result to late subscribers") + func lateSubscriberReceivesCachedResult() async { + let expected = JSValue.string("cached-value") + let publisher = JSPromise.resolve(expected).publisher + + var firstValue: JSValue? + var firstSink: AnyCancellable? + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + firstSink = publisher.sink( + receiveCompletion: { _ in continuation.resume() }, + receiveValue: { firstValue = $0 } + ) + } + withExtendedLifetime(firstSink) {} + + // Second subscriber attaches after the Future already captured the result. + var secondValue: JSValue? + var secondSink: AnyCancellable? + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + secondSink = publisher.sink( + receiveCompletion: { _ in continuation.resume() }, + receiveValue: { secondValue = $0 } + ) + } + withExtendedLifetime(secondSink) {} + + #expect(firstValue == expected) + #expect(secondValue == expected, "Future must replay its cached result to late subscribers") + } + + /// JP-05 / JP-08 — multiple simultaneous subscribers all receive the value + @Test("multiple subscribers attached before resolution all receive the value") + func multipleSubscribersReceiveTheValue() async { + var resolveHandler: ((JSPromise.Result) -> ())? + let promise = JSPromise { resolveHandler = $0 } + let publisher = promise.publisher + + var firstValue: JSValue? + var secondValue: JSValue? + var sinks = [AnyCancellable]() + + await confirmation("both subscribers complete", expectedCount: 2) { completed in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + var remaining = 2 + let finish = { + completed() + remaining -= 1 + if remaining == 0 { continuation.resume() } + } + sinks.append(publisher.sink( + receiveCompletion: { _ in finish() }, + receiveValue: { firstValue = $0 } + )) + sinks.append(publisher.sink( + receiveCompletion: { _ in finish() }, + receiveValue: { secondValue = $0 } + )) + resolveHandler?(.success(.string("shared"))) + } + } + withExtendedLifetime(sinks) {} + + #expect(firstValue == .string("shared")) + #expect(secondValue == .string("shared")) + } +} + +// MARK: - Failure path + +struct JSPromisePublisherFailureTests { + /// JP-07 / CT-JP-02 — rejected promise delivers no values and .failure(PromiseError) + @Test("rejected Promise delivers .failure(PromiseError) and no values") + func rejectedPromiseDeliversTypedError() async { + let rejectionReason = JSValue.string("rejection-reason") + let publisher = JSPromise.reject(rejectionReason).publisher + var values = [JSValue]() + var receivedError: JSPromise.PromiseError? + var sink: AnyCancellable? + + await confirmation("failure completion received") { completionReceived in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + sink = publisher.sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { + receivedError = error + } + completionReceived() + continuation.resume() + }, + receiveValue: { values.append($0) } + ) + } + } + withExtendedLifetime(sink) {} + + #expect(values.isEmpty, "a rejected Promise must produce no output values") + #expect(receivedError?.value == rejectionReason) + } +} + +// MARK: - Subscription cancellation + +struct JSPromisePublisherCancellationTests { + /// CT-JP-04 — documented behavior: a subscription cancelled before the promise settles + /// receives neither values nor completion (OpenCombine Future honors cancellation). + @Test("subscription cancelled before resolution receives no value or completion") + func cancelledSubscriptionReceivesNothing() async { + var resolveHandler: ((JSPromise.Result) -> ())? + let promise = JSPromise { resolveHandler = $0 } + + var receivedValues = [JSValue]() + var receivedCompletions = 0 + var subscription: (any Subscription)? + + let subscriber = AnySubscriber( + receiveSubscription: { incoming in + subscription = incoming + incoming.request(.unlimited) + }, + receiveValue: { value in + receivedValues.append(value) + return .none + }, + receiveCompletion: { _ in + receivedCompletions += 1 + } + ) + promise.publisher.receive(subscriber: subscriber) + + // Cancel before the promise settles, then resolve it. + subscription?.cancel() + resolveHandler?(.success(.string("should-not-arrive"))) + + // Drain the microtask/macrotask queues so a (buggy) delivery would surface. + await eventLoopSleep(milliseconds: 50) + + #expect(receivedValues.isEmpty, "no value may be delivered after cancel()") + #expect(receivedCompletions == 0, "no completion may be delivered after cancel()") + } +} +#endif diff --git a/Tests/OpenCombineJSTests/JSSchedulerTests.swift b/Tests/OpenCombineJSTests/JSSchedulerTests.swift new file mode 100644 index 0000000..4283d23 --- /dev/null +++ b/Tests/OpenCombineJSTests/JSSchedulerTests.swift @@ -0,0 +1,292 @@ +// 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. + +// Tests for JSScheduler runtime behavior. WASI-only: every test requires the JS event loop +// (JSTimer, JSDate) provided by a JavaScript host (Node.js via the PackageToJS test runner). +// `@testable import` is used deliberately so the suite can verify `scheduledTimers` cleanup +// (regression #4). + +#if os(WASI) +import JavaScriptKit +import OpenCombine +@testable import OpenCombineJS +import Testing + +// MARK: - now and minimumTolerance + +struct JSSchedulerPropertiesTests { + /// SC-01 — now reflects JS epoch milliseconds + @Test("now returns a positive epoch-milliseconds value") + func nowReturnsPositiveEpochMs() { + #expect(JSScheduler().now.millisecondsValue > 0) + } + + /// SC-01 / CT-SC-01 — monotonicity law + @Test("now is non-decreasing across sequential reads") + func nowIsNonDecreasing() { + let scheduler = JSScheduler() + var previous = scheduler.now + for _ in 0..<100 { + let current = scheduler.now + #expect(current.millisecondsValue >= previous.millisecondsValue) + previous = current + } + } + + /// SC-02 — minimumTolerance characterization + @Test("minimumTolerance is Double.leastNonzeroMagnitude milliseconds") + func minimumToleranceIsLeastNonzero() { + #expect(JSScheduler().minimumTolerance.magnitude == Double.leastNonzeroMagnitude) + } +} + +// MARK: - schedule(options:_:) — immediate (macrotask) scheduling + +struct JSSchedulerImmediateScheduleTests { + /// SC-03 / CT-SC-02 — the action must not run synchronously, but must run soon after + @Test("schedule(options:_:) defers the action to a later macrotask") + func immediateScheduleIsAsynchronous() async { + let scheduler = JSScheduler() + var didRun = false + await confirmation("action fires") { fired in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + scheduler.schedule(options: nil) { + didRun = true + fired() + continuation.resume() + } + // Still on the original call stack: the action must not have executed yet. + #expect(!didRun, "schedule(options:_:) must not execute the action synchronously") + } + } + #expect(didRun) + } + + /// SC-04 — regression #4: the fired one-shot timer must be removed from internal storage + @Test("schedule(options:_:) removes the timer from scheduledTimers after firing") + func immediateScheduleCleansUpTimer() async { + let scheduler = JSScheduler() + #expect(scheduler.scheduledTimers.isEmpty) + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + scheduler.schedule(options: nil) { + continuation.resume() + } + #expect(scheduler.scheduledTimers.count == 1) + } + // The cleanup statement runs right after the action inside the same callback. + #expect(scheduler.scheduledTimers.isEmpty, "fired one-shot timers must not leak (issue #4)") + } + + /// SC-13 — multiple concurrent schedules are independent + @Test("multiple schedule(options:_:) calls all fire independently") + func multipleConcurrentSchedules() async { + let scheduler = JSScheduler() + await confirmation("all three fire", expectedCount: 3) { fired in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + var remaining = 3 + for _ in 0..<3 { + scheduler.schedule(options: nil) { + fired() + remaining -= 1 + if remaining == 0 { continuation.resume() } + } + } + } + } + #expect(scheduler.scheduledTimers.isEmpty) + } +} + +// MARK: - schedule(after:tolerance:options:_:) — one-shot + +struct JSSchedulerOneShotScheduleTests { + /// SC-05 — fires once after the requested date, then cleans up (regression #4) + @Test("schedule(after:) fires exactly once and cleans up its timer") + func oneShotAfterDateFiresOnceAndCleansUp() async { + let scheduler = JSScheduler() + var fireCount = 0 + await confirmation("fires exactly once", expectedCount: 1) { fired in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(20)), + tolerance: .milliseconds(5), + options: nil + ) { + fireCount += 1 + fired() + continuation.resume() + } + #expect(scheduler.scheduledTimers.count == 1) + } + } + // Drain window: an erroneously repeating timer would fire again within this period. + await eventLoopSleep(milliseconds: 80) + #expect(fireCount == 1) + #expect(scheduler.scheduledTimers.isEmpty, "fired one-shot timers must not leak (issue #4)") + } + + /// SC-07 — past date fires immediately (negative delay clamps to 0 per JS timer spec) + @Test("schedule(after:) with a past date still fires") + func oneShotPastDateFires() async { + let scheduler = JSScheduler() + await confirmation("fires despite past date") { fired in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + scheduler.schedule( + after: JSScheduler.SchedulerTimeType(millisecondsValue: 0), + tolerance: .milliseconds(0), + options: nil + ) { + fired() + continuation.resume() + } + } + } + } +} + +// MARK: - schedule(after:interval:tolerance:options:_:) — repeating + +struct JSSchedulerRepeatingScheduleTests { + /// SC-08 — regression #5: the interval timer must repeat (it was created one-shot and + /// fired only once). Cadence bands follow 06-test-data-strategy.md: lower bound + /// interval − tolerance, generous upper bound for event-loop jitter under wasm runtimes. + @Test("repeating schedule fires at least 3 times at the requested cadence") + func repeatingScheduleFiresRepeatedly() async { + let scheduler = JSScheduler() + let intervalMs = 40.0 + var fireTimes = [Double]() + var cancellable: (any Cancellable)? + await confirmation("fires three times", expectedCount: 3) { fired in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + var resumed = false + cancellable = scheduler.schedule( + after: scheduler.now, + interval: .milliseconds(Int(intervalMs)), + tolerance: .milliseconds(10), + options: nil + ) { + fireTimes.append(JSDate.now()) + if fireTimes.count <= 3 { fired() } + if fireTimes.count == 3, !resumed { + resumed = true + continuation.resume() + } + } + } + } + cancellable?.cancel() + #expect(fireTimes.count >= 3, "repeating schedule must keep firing (issue #5)") + // CT-SC-04 — monotonically increasing timestamps, cadence within tolerance band. + for index in 1.. 0, "fire timestamps must be monotonically increasing") + #expect( + gap >= intervalMs - 20.0 && gap <= intervalMs + 200.0, + "cadence \(gap)ms outside tolerance band for \(intervalMs)ms interval" + ) + } + } + + /// SC-11 — regression #3: cancelling before the first fire used to force-unwrap the + /// still-nil interval timer (crash) and left the pending timeout running. + @Test("cancel() before the first fire is safe and prevents any fire") + func cancelBeforeFirstFireIsSafe() async { + let scheduler = JSScheduler() + var fireCount = 0 + let cancellable = scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(40)), + interval: .milliseconds(20), + tolerance: .milliseconds(5), + options: nil + ) { + fireCount += 1 + } + // Must not crash (issue #3) and must clear the pending timeout timer immediately. + cancellable.cancel() + #expect(scheduler.scheduledTimers.isEmpty, "cancel() must clear the pending timeout timer") + // Drain window long enough for the original timeout AND several intervals. + await eventLoopSleep(milliseconds: 150) + #expect(fireCount == 0, "no action may fire after cancel() (issue #3)") + } + + /// SC-09 / CT-SC-05 — cancelling between repeats stops delivery and cleans up (issue #4) + @Test("cancel() between repeats stops delivery and cleans up the interval timer") + func cancelBetweenRepeatsStopsDelivery() async { + let scheduler = JSScheduler() + var fireCount = 0 + 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 + ) { + fireCount += 1 + if fireCount == 2, !resumed { + resumed = true + continuation.resume() + } + } + } + cancellable?.cancel() + let countAtCancel = fireCount + #expect(scheduler.scheduledTimers.isEmpty, "cancel() must remove the interval timer (issue #4)") + await eventLoopSleep(milliseconds: 120) + #expect(fireCount == countAtCancel, "interval must stop delivering after cancel()") + } + + /// SC-12 — double-cancel must be a no-op + @Test("calling cancel() twice is a harmless no-op") + func doubleCancelIsNoOp() async { + let scheduler = JSScheduler() + var fireCount = 0 + let cancellable = scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(30)), + interval: .milliseconds(20), + tolerance: .milliseconds(5), + options: nil + ) { + fireCount += 1 + } + cancellable.cancel() + cancellable.cancel() + await eventLoopSleep(milliseconds: 100) + #expect(fireCount == 0) + #expect(scheduler.scheduledTimers.isEmpty) + } + + /// SC-10 — the scheduler remains usable after a cancellation + @Test("scheduler continues to function after cancel()") + func schedulerHealthyAfterCancel() async { + let scheduler = JSScheduler() + let cancellable = scheduler.schedule( + after: scheduler.now, + interval: .milliseconds(50), + tolerance: .milliseconds(10), + options: nil + ) {} + cancellable.cancel() + await confirmation("scheduler still works after cancel") { works in + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + scheduler.schedule(options: nil) { + works() + continuation.resume() + } + } + } + } +} +#endif diff --git a/Tests/OpenCombineJSTests/JSValueDecoderTests.swift b/Tests/OpenCombineJSTests/JSValueDecoderTests.swift new file mode 100644 index 0000000..6ff08f4 --- /dev/null +++ b/Tests/OpenCombineJSTests/JSValueDecoderTests.swift @@ -0,0 +1,160 @@ +// 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. + +// Tests for the JSValueDecoder TopLevelDecoder conformance. WASI-only: JSValueDecoder walks +// JSValue contents through the JavaScriptKit runtime, which requires a live JS host. +// Fixture IDs (F-xx) refer to 06-test-data-strategy.md §1.4. + +#if os(WASI) +import JavaScriptKit +import OpenCombine +@testable import OpenCombineJS +import Testing + +// MARK: - TopLevelDecoder conformance + +struct JSValueDecoderConformanceTests { + /// JD-07 — the @retroactive conformance is accessible as a protocol existential + @Test("JSValueDecoder is usable as any TopLevelDecoder existential") + func topLevelDecoderConformanceIsAccessible() { + let decoder: any TopLevelDecoder = JSValueDecoder() + #expect(type(of: decoder) == JSValueDecoder.self) + } + + /// JD-01 — decode(_:from:) delegates to decode(_:from:userInfo:[:]) + @Test("decode(_:from:) matches decode(_:from:userInfo:[:])") + func delegationToUserInfoOverload() throws { + let decoder = JSValueDecoder() + let input = JSFixtures.stringASCII() + let viaTopLevel = try decoder.decode(String.self, from: input) + let viaUserInfo = try decoder.decode(String.self, from: input, userInfo: [:]) + #expect(viaTopLevel == viaUserInfo) + } +} + +// MARK: - Primitive decode paths + +struct JSValueDecoderPrimitiveTests { + /// JD-03 / F-01 — string primitive + @Test("decodes a JSValue.string to String") + func decodeString() throws { + let result = try JSValueDecoder().decode(String.self, from: JSFixtures.stringASCII()) + #expect(result == "hello") + } + + /// JD-03 / F-03 — Unicode string + @Test("decodes a Unicode JSValue.string to String") + func decodeUnicodeString() throws { + let result = try JSValueDecoder().decode(String.self, from: JSFixtures.stringUnicode()) + #expect(result == "こんにちは") + } + + /// JD-04 / F-09 — number as Double + @Test("decodes a JSValue.number to Double") + func decodeDouble() throws { + let result = try JSValueDecoder().decode(Double.self, from: JSFixtures.numberFinite()) + #expect(result == 3.14) + } + + /// JD-04 — number as Int + @Test("decodes a JSValue.number to Int") + func decodeInt() throws { + let result = try JSValueDecoder().decode(Int.self, from: .number(42)) + #expect(result == 42) + } + + /// JD-05 / F-21, F-22 — booleans + @Test("decodes JSValue.boolean to Bool") + func decodeBool() throws { + let decoder = JSValueDecoder() + #expect(try decoder.decode(Bool.self, from: JSFixtures.boolTrue()) == true) + #expect(try decoder.decode(Bool.self, from: JSFixtures.boolFalse()) == false) + } + + /// JD-06 / F-08 — type mismatch throws + @Test("throws when the JSValue type mismatches the requested Swift type") + func decodeMismatchedTypeThrows() { + #expect(throws: (any Error).self) { + try JSValueDecoder().decode(Int.self, from: JSFixtures.stringASCII()) + } + } +} + +// MARK: - Object decode paths + +struct JSValueDecoderObjectTests { + /// JD-02 / F-28 — flat object decodes into a struct + @Test("decodes a flat JS object into a Decodable struct") + func decodeFlatObject() throws { + let person = try JSValueDecoder().decode(Person.self, from: JSFixtures.objectFlat()) + #expect(person == Person(name: "Alice", age: 30)) + } + + /// F-29 — missing key produces a DecodingError + @Test("throws when a required key is missing from the JS object") + func decodeMissingKeyThrows() { + #expect(throws: (any Error).self) { + try JSValueDecoder().decode(Person.self, from: JSFixtures.objectMissingKey()) + } + } + + /// F-30 — nested object decodes recursively + @Test("decodes a nested JS object") + func decodeNestedObject() throws { + let wrapper = try JSValueDecoder().decode(UserWrapper.self, from: JSFixtures.objectNested()) + #expect(wrapper == UserWrapper(user: .init(id: 1, label: "admin"))) + } + + /// F-37 — optional field present + @Test("decodes an optional field when present") + func decodeOptionalPresent() throws { + let result = try JSValueDecoder() + .decode(WithOptional.self, from: JSFixtures.objectOptionalPresent()) + #expect(result == WithOptional(name: "Carol", nickname: "C")) + } + + /// F-38 — optional field absent decodes to nil + @Test("decodes an absent optional field to nil") + func decodeOptionalAbsent() throws { + let result = try JSValueDecoder() + .decode(WithOptional.self, from: JSFixtures.objectOptionalAbsent()) + #expect(result == WithOptional(name: "Dave", nickname: nil)) + } +} + +// MARK: - Array decode paths + +struct JSValueDecoderArrayTests { + /// F-32 — homogeneous array + @Test("decodes a homogeneous JS array to [Int]") + func decodeHomogeneousArray() throws { + let result = try JSValueDecoder().decode([Int].self, from: JSFixtures.arrayHomogeneous()) + #expect(result == [1, 2, 3, 4, 5]) + } + + /// F-35 — empty array + @Test("decodes an empty JS array to []") + func decodeEmptyArray() throws { + let result = try JSValueDecoder().decode([Int].self, from: JSFixtures.arrayEmpty()) + #expect(result.isEmpty) + } + + /// F-36 — array of objects + @Test("decodes a JS array of objects to an array of structs") + func decodeArrayOfObjects() throws { + let result = try JSValueDecoder().decode([Point].self, from: JSFixtures.arrayOfObjects()) + #expect(result == [Point(x: 1), Point(x: 2)]) + } +} +#endif diff --git a/Tests/OpenCombineJSTests/SchedulerTimeTypeTests.swift b/Tests/OpenCombineJSTests/SchedulerTimeTypeTests.swift new file mode 100644 index 0000000..b102b97 --- /dev/null +++ b/Tests/OpenCombineJSTests/SchedulerTimeTypeTests.swift @@ -0,0 +1,235 @@ +// 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. + +// Tests for JSScheduler.SchedulerTimeType and its Stride. +// Pure Swift — no JS runtime dependency; runs on host (`swift test`) and on wasm. + +@testable import OpenCombineJS +import Testing + +private typealias TimeType = JSScheduler.SchedulerTimeType +private typealias Stride = JSScheduler.SchedulerTimeType.Stride + +// MARK: - SchedulerTimeType Strideable conformance + +struct SchedulerTimeTypeStrideableTests { + /// ST-01 — positive stride advance + @Test("advanced(by:) with positive stride increases milliseconds value") + func advancedByPositiveStride() { + let base = TimeType(millisecondsValue: 1000.0) + let result = base.advanced(by: Stride(millisecondsValue: 500.0)) + #expect(result.millisecondsValue == 1500.0) + } + + /// ST-02 — negative stride advance + @Test("advanced(by:) with negative stride decreases milliseconds value") + func advancedByNegativeStride() { + let base = TimeType(millisecondsValue: 1000.0) + let result = base.advanced(by: Stride(millisecondsValue: -200.0)) + #expect(result.millisecondsValue == 800.0) + } + + /// ST-03 — distance to later time is positive + @Test("distance(to:) with later target returns positive Stride") + func distanceToLaterTimeIsPositive() { + let earlier = TimeType(millisecondsValue: 1000.0) + let later = TimeType(millisecondsValue: 2500.0) + #expect(earlier.distance(to: later).magnitude == 1500.0) + } + + /// ST-04 — distance to earlier time is negative + @Test("distance(to:) with earlier target returns negative Stride") + func distanceToEarlierTimeIsNegative() { + let later = TimeType(millisecondsValue: 2000.0) + let earlier = TimeType(millisecondsValue: 500.0) + #expect(later.distance(to: earlier).magnitude == -1500.0) + } + + /// ST-05 — distance to same time is zero + @Test("distance(to:) with equal times returns zero Stride") + func distanceToSameTimeIsZero() { + let time = TimeType(millisecondsValue: 1234.0) + #expect(time.distance(to: time).magnitude == 0.0) + } + + /// ST-03/ST-04 — Strideable law: x.distance(to: x.advanced(by: n)) == n + @Test( + "Strideable law: distance(to: advanced(by:)) recovers the stride", + arguments: [300.0, -300.0, 0.0, 0.25] + ) + func strideableAdvanceDistanceLaw(strideMs: Double) { + let base = TimeType(millisecondsValue: 1000.0) + let advanced = base.advanced(by: Stride(millisecondsValue: strideMs)) + #expect(base.distance(to: advanced).magnitude == strideMs) + } + + /// Strideable law: x.advanced(by: x.distance(to: y)) == y + @Test("Strideable law: advanced(by: distance(to:)) reaches the target") + func strideableDistanceAdvanceLaw() { + let from = TimeType(millisecondsValue: 250.0) + let to = TimeType(millisecondsValue: 4750.0) + let reached = from.advanced(by: from.distance(to: to)) + #expect(reached.millisecondsValue == to.millisecondsValue) + } + + /// Comparable derived from Strideable + @Test("SchedulerTimeType ordering follows milliseconds values") + func timeTypeComparable() { + let earlier = TimeType(millisecondsValue: 1.0) + let later = TimeType(millisecondsValue: 2.0) + #expect(earlier < later) + #expect(!(later < earlier)) + #expect(earlier == TimeType(millisecondsValue: 1.0)) + } +} + +// MARK: - Stride factory methods + +struct StrideFactoryTests { + /// ST-08 — seconds(Double) + @Test("Stride.seconds(Double) converts to milliseconds") + func secondsDoubleFactory() { + #expect(Stride.seconds(1.0).magnitude == 1000.0) + #expect(Stride.seconds(0.5).magnitude == 500.0) + } + + /// ST-09 — seconds(Int) + @Test("Stride.seconds(Int) converts to milliseconds") + func secondsIntFactory() { + #expect(Stride.seconds(2).magnitude == 2000.0) + } + + /// ST-10 — milliseconds + @Test("Stride.milliseconds preserves the millisecond value") + func millisecondsFactory() { + #expect(Stride.milliseconds(250).magnitude == 250.0) + } + + /// ST-11 — regression for issue #2: the microseconds formula was inverted + /// (`1.0 / (us * 1000)`). Correct conversion: 1000 µs == 1 ms. + @Test("Stride.microseconds converts correctly (regression #2)") + func microsecondsFactory() { + #expect(Stride.microseconds(1000) == Stride.milliseconds(1)) + #expect(Stride.microseconds(1).magnitude == 0.001) + #expect(Stride.microseconds(2).magnitude == 0.002) + #expect(Stride.microseconds(2_000_000) == Stride.seconds(2)) + } + + /// ST-12 — regression for issue #2: the nanoseconds formula was inverted + /// (`1.0 / (ns * 1_000_000)`). Correct conversion: 1_000_000 ns == 1 ms. + @Test("Stride.nanoseconds converts correctly (regression #2)") + func nanosecondsFactory() { + #expect(Stride.nanoseconds(1_000_000) == Stride.milliseconds(1)) + #expect(Stride.nanoseconds(1).magnitude == 0.000001) + #expect(Stride.nanoseconds(2).magnitude == 0.000002) + #expect(Stride.nanoseconds(2_000_000_000) == Stride.seconds(2)) + } + + /// ST-08..ST-12 — unit round-trips through the millisecond base unit + @Test("Stride unit factories agree on equivalent durations (regression #2)") + func unitFactoriesRoundTrip() { + #expect(Stride.seconds(1) == Stride.milliseconds(1000)) + #expect(Stride.milliseconds(1) == Stride.microseconds(1000)) + #expect(Stride.microseconds(1) == Stride.nanoseconds(1000)) + #expect(Stride.seconds(1) == Stride.nanoseconds(1_000_000_000)) + } + + /// ST-13 — float literal initializer delegates to seconds(Double). + /// Note: Stride declares init(floatLiteral:) but does not conform to + /// ExpressibleByFloatLiteral, so `let s: Stride = 0.5` does not compile; the + /// initializer is exercised directly. + @Test("Stride float literal initializer delegates to seconds(Double)") + func floatLiteralInit() { + let stride = Stride(floatLiteral: 0.5) + #expect(stride.magnitude == 500.0) + } + + /// ST-14 — integer literal delegates to seconds(Int) + @Test("Stride integer literal initializer delegates to seconds(Int)") + func integerLiteralInit() { + let stride: Stride = 3 + #expect(stride.magnitude == 3000.0) + } + + /// ST-06 — init?(exactly:) success + @Test("Stride.init?(exactly:) succeeds for representable BinaryInteger") + func exactlyInitSuccess() { + let stride = Stride(exactly: 42) + #expect(stride?.magnitude == 42.0) + } + + /// ST-07 — init?(exactly:) nil for values beyond Double precision + @Test("Stride.init?(exactly:) returns nil for non-representable UInt64") + func exactlyInitFailure() { + // UInt64.max is not exactly representable as Double. + #expect(Stride(exactly: UInt64.max) == nil) + } +} + +// MARK: - Stride arithmetic and comparison + +struct StrideArithmeticTests { + /// ST-15 — addition + @Test("Stride addition adds magnitudes") + func strideAddition() { + let sum = Stride(millisecondsValue: 100.0) + Stride(millisecondsValue: 200.0) + #expect(sum.magnitude == 300.0) + } + + /// ST-16 — subtraction + @Test("Stride subtraction subtracts magnitudes") + func strideSubtraction() { + let difference = Stride(millisecondsValue: 500.0) - Stride(millisecondsValue: 150.0) + #expect(difference.magnitude == 350.0) + } + + /// ST-17 — multiplication + @Test("Stride multiplication multiplies magnitudes") + func strideMultiplication() { + let product = Stride(millisecondsValue: 100.0) * Stride(millisecondsValue: 3.0) + #expect(product.magnitude == 300.0) + } + + /// ST-18 — compound assignment + @Test("Stride compound assignment operators mutate in place") + func strideCompoundAssignment() { + var stride = Stride(millisecondsValue: 100.0) + stride += Stride(millisecondsValue: 50.0) + #expect(stride.magnitude == 150.0) + stride -= Stride(millisecondsValue: 30.0) + #expect(stride.magnitude == 120.0) + stride *= Stride(millisecondsValue: 2.0) + #expect(stride.magnitude == 240.0) + } + + /// ST-19 — comparison + @Test("Stride comparison orders by magnitude") + func strideComparison() { + let small = Stride(millisecondsValue: 100.0) + let large = Stride(millisecondsValue: 200.0) + #expect(small < large) + #expect(!(large < small)) + #expect(!(small < small)) + } + + /// ST-20 — SignedNumeric negation + @Test("Stride negation flips the magnitude sign") + func strideNegation() { + let positive = Stride(millisecondsValue: 75.0) + #expect((-positive).magnitude == -75.0) + var mutable = Stride(millisecondsValue: 75.0) + mutable.negate() + #expect(mutable.magnitude == -75.0) + } +} diff --git a/Tests/OpenCombineJSTests/TestHelpers.swift b/Tests/OpenCombineJSTests/TestHelpers.swift new file mode 100644 index 0000000..e2d9282 --- /dev/null +++ b/Tests/OpenCombineJSTests/TestHelpers.swift @@ -0,0 +1,33 @@ +// 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. + +#if os(WASI) +import JavaScriptKit + +/// Suspends the current task for the given duration by scheduling a one-shot JS timer, letting +/// the JS event loop process any pending timers in the meantime. Used as a deterministic drain +/// window (06-test-data-strategy.md §2) instead of `Task.sleep`, so the suspension mechanism is +/// independent of the code under test. +func eventLoopSleep(milliseconds: Double) async { + var timer: JSTimer? + await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in + timer = JSTimer(millisecondsDelay: milliseconds) { + continuation.resume() + } + } + // Keep the timer reference alive until it has fired; deallocating it earlier would call + // `clearTimeout` and the continuation would never resume. + withExtendedLifetime(timer) {} +} +#endif From f83809aea433bbb365452000333944ac670c42df Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:52:24 +0300 Subject: [PATCH 3/8] #6 ci: replace dead Swift 5.x lanes with 6.3 host+wasm pipeline The previous CI built for Swift 5.3-5.5 on retired ubuntu-20.04 images and could not even parse the swift-tools-version:6.3 manifest; no lane ran tests. - ci.yml: macOS 26 host lane (build + host tests) and Ubuntu wasm lane using the official version-matched swift.org wasm SDK, running the full 62-test suite under Node via PackageToJS. swift.org artifacts are GPG-verified before installation. - api-contract.yml: PR-blocking `swift package diagnose-api-breaking-changes` gate against the PR base (a fixed 0.2.0 baseline would be permanently red: main already carries the declared PromisePublisher.Failure break from fefb814). - canary-upstreams.yml: weekly allowed-to-fail builds against latest JavaScriptKit (host + wasm; detects the JSValueDecoder duplicate-conformance time-bomb, #9) and OpenCombine; files deduplicated canary-failure issues. Upstream tag values are format-validated before reaching sed/jq per the security audit. - label.yml: action versions bumped. - Least-privilege GITHUB_TOKEN permissions throughout. Closes #6 Refs #9, #12 --- .github/workflows/api-contract.yml | 95 ++++++++ .github/workflows/canary-upstreams.yml | 311 +++++++++++++++++++++++++ .github/workflows/ci.yml | 146 ++++++++++-- .github/workflows/label.yml | 21 +- 4 files changed, 550 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/api-contract.yml create mode 100644 .github/workflows/canary-upstreams.yml diff --git a/.github/workflows/api-contract.yml b/.github/workflows/api-contract.yml new file mode 100644 index 0000000..a8d69c9 --- /dev/null +++ b/.github/workflows/api-contract.yml @@ -0,0 +1,95 @@ +# API Contract Guard — OpenCombineJS +# Issue: #6 (CI modernization), #12 (API stability gate) +# +# Fails any PR that introduces undeclared breaking changes to the public API +# surface by running `swift package diagnose-api-breaking-changes `. +# +# Baseline: the PR's target-branch head (github.event.pull_request.base.sha) — +# the gate catches breakage introduced BY the PR, not historical breakage. +# Rationale: main already carries an intentional, declared break relative to +# tag 0.2.0 (PromisePublisher.Failure → PromiseError, commit fefb814 / PR #1), +# so a fixed 0.2.0 baseline would be permanently red. +# +# Release policy: when cutting a release, additionally run +# swift package diagnose-api-breaking-changes +# locally and declare every reported change in CHANGELOG.md with the correct +# semver bump. +# +# `swift package diagnose-api-breaking-changes` ships with the Swift toolchain +# (no extra install needed). It compiles the package twice — once at the +# baseline ref and once at HEAD — and reports added/removed/changed public symbols. +# +# Action versions verified (2026-06-11): +# actions/checkout v4 — https://github.com/actions/checkout/releases + +name: API Contract Guard + +on: + pull_request: + branches: [main] + # Run on any PR that touches source or manifest; skip doc-only PRs. + paths: + - 'Sources/**' + - 'Package.swift' + - 'Package.resolved' + +permissions: + contents: read + +# Cancel superseded runs on the same PR. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # The API baseline: head of the PR's target branch (a trusted hex SHA from + # the event payload, routed through env per workflow-injection hygiene). + BASELINE_REF: ${{ github.event.pull_request.base.sha }} + +jobs: + api-breaking-changes: + name: Public API — Breaking-Change Check vs PR base + # swift-tools-version:6.3 requires Swift 6.3 → Xcode 26.x → macos-26 image. + runs-on: macos-26 + + steps: + - name: Checkout (full history required for baseline SHA resolution) + uses: actions/checkout@v4 + with: + # fetch-depth: 0 ensures the base SHA is present in the local clone. + fetch-depth: 0 + + - name: Select Xcode (Swift 6.3) + run: | + LATEST_XCODE=$(ls /Applications/ | grep -E '^Xcode_?[0-9]' | sort -V | tail -1) + if [ -n "$LATEST_XCODE" ]; then + sudo xcode-select -s "/Applications/${LATEST_XCODE}/Contents/Developer" + fi + swift --version + + - name: Verify baseline ref exists + run: | + if ! git rev-parse "${BASELINE_REF}" >/dev/null 2>&1; then + echo "ERROR: Baseline ref '${BASELINE_REF}' not found in this clone." + echo "Ensure fetch-depth: 0 is set." + exit 1 + fi + echo "Baseline resolved: $(git rev-parse "${BASELINE_REF}")" + + - name: Check for breaking API changes against PR base + # `swift package diagnose-api-breaking-changes ` checks out the package + # at , generates an API baseline, then compares it against the current + # working tree. Exits non-zero if breaking changes are detected. + run: | + swift package diagnose-api-breaking-changes "${BASELINE_REF}" \ + --products OpenCombineJS + + - name: Annotate PR on breaking-change failure + if: failure() + run: | + echo "::error title=API Breaking Change Detected::This PR introduces public API breaking changes relative to its target branch." + echo "::error::Options:" + echo "::error:: 1. Revert the breaking change." + echo "::error:: 2. If intentional: declare it in CHANGELOG.md with the correct" + echo "::error:: semver bump (0.x minor / 1.x major) and state the breakage" + echo "::error:: explicitly in the PR description so the reviewer can waive this gate." diff --git a/.github/workflows/canary-upstreams.yml b/.github/workflows/canary-upstreams.yml new file mode 100644 index 0000000..d0cc101 --- /dev/null +++ b/.github/workflows/canary-upstreams.yml @@ -0,0 +1,311 @@ +# Canary — Upstream Compatibility Watch +# Issues: #9 (JSValueDecoder duplicate-conformance time-bomb), #12 (upstream canary) +# +# Two independent jobs run weekly to detect breaking changes from upstream +# dependency upgrades BEFORE they affect users pinned to the current Package.resolved. +# +# Both jobs are allowed to fail (continue-on-error: true) and do NOT affect +# required CI checks. On failure each job files a GitHub issue labeled +# `canary-failure`; duplicate-open-issue guard prevents noise. +# +# Security: issue title and body are constructed ONLY from controlled sources +# (gh api json output stored in env vars, hardcoded strings). No untrusted +# event payload (PR title, commit message, etc.) touches any run: command. +# +# Action versions verified (2026-06-11): +# actions/checkout v4 — https://github.com/actions/checkout/releases + +name: Canary — Upstream Compatibility + +on: + schedule: + # Run every Monday at 06:00 UTC. Stagger the two jobs via job-level delays + # rather than separate cron entries (they share one workflow file). + - cron: '0 6 * * 1' + workflow_dispatch: + +# Job-level permissions below grant issues:write only where needed (F-07). +permissions: + contents: read + +# No concurrency cancellation — canary runs are rare and should always complete. + +jobs: + # --------------------------------------------------------------------------- + # jskit-latest: build against the latest JavaScriptKit release. + # + # Detects the JSValueDecoder duplicate-TopLevelDecoder-conformance time-bomb + # described in issue #9: once JavaScriptKit ships native TopLevelDecoder + # conformance, JSValueDecoder.swift will produce a "Redundant conformance" + # compile error. This canary catches that the moment JSKit ships it. + # + # The job patches Package.swift IN THE RUNNER ONLY (the checkout is ephemeral). + # Package.resolved in the repo is never modified. + # --------------------------------------------------------------------------- + jskit-latest: + name: Canary — Latest JavaScriptKit (#9) + # swift-tools-version:6.3 requires Swift 6.3 → Xcode 26.x → macos-26 image. + runs-on: macos-26 + continue-on-error: true + permissions: + contents: read + issues: write # File canary-failure issues on breakage + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode (Swift 6.3) + run: | + LATEST_XCODE=$(ls /Applications/ | grep -E '^Xcode_?[0-9]' | sort -V | tail -1) + if [ -n "$LATEST_XCODE" ]; then + sudo xcode-select -s "/Applications/${LATEST_XCODE}/Contents/Developer" + fi + swift --version + + # Resolve the latest JavaScriptKit release tag via the GitHub API. + # Result is stored as a step output and a safe env var — NOT interpolated + # directly into shell commands that could be exploited. + - name: Resolve latest JavaScriptKit version + id: jskit-version + env: + GH_TOKEN: ${{ github.token }} + run: | + # Use gh cli (available on all GitHub-hosted runners) to query releases. + LATEST=$(gh api repos/swiftwasm/JavaScriptKit/releases/latest --jq '.tag_name') + if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then + echo "ERROR: Could not resolve latest JavaScriptKit version from GitHub API" + exit 1 + fi + # F-02/F-03: validate tag format before the value reaches sed/jq/issue title. + if ! printf '%s' "$LATEST" | grep -qE '^[A-Za-z0-9._+-]+$'; then + echo "ERROR: unexpected characters in upstream tag: ${LATEST}" + exit 1 + fi + echo "version=${LATEST}" >> "$GITHUB_OUTPUT" + echo "Latest JavaScriptKit: ${LATEST}" + + # Patch Package.swift in the runner workspace to request the latest version. + # sed on macOS requires '' for in-place edits. The patched file is ephemeral. + - name: Patch Package.swift → latest JavaScriptKit + env: + JSKIT_VERSION: ${{ steps.jskit-version.outputs.version }} + run: | + echo "Patching Package.swift: JavaScriptKit → exact: ${JSKIT_VERSION}" + # Replace `from: "0.54.1"` with `exact: ""` for the JSKit dependency. + # Using the env var (not direct ${{ }} interpolation) keeps the sed call safe. + sed -i '' \ + "s|from: \"0.54.1\"|exact: \"${JSKIT_VERSION}\"|g" \ + Package.swift + # Remove Package.resolved so SPM re-resolves against the patched manifest. + rm -f Package.resolved + swift package resolve + + - name: Build host — latest JavaScriptKit + id: build-host + run: swift build --target OpenCombineJS + + # Wasm build: install the official swift.org wasm SDK (version-matched to + # the toolchain — REQUIRED) and build for wasm32-unknown-wasi. + # NOTE: the macOS runner's Xcode toolchain has no wasm backend; the wasm + # build here uses the swift.org open-source toolchain if present. If this + # proves flaky on macOS runners, move this job to ubuntu-latest mirroring + # the `wasm` job in ci.yml. + - name: Install Swift wasm SDK (official, version-matched) + run: | + SWIFT_SEMVER=$(swift --version 2>/dev/null | grep -oE 'Swift version [0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) + SWIFT_TAG="swift-${SWIFT_SEMVER}-RELEASE" + SDK_URL="https://download.swift.org/swift-${SWIFT_SEMVER}-release/wasm-sdk/${SWIFT_TAG}/${SWIFT_TAG}_wasm.artifactbundle.zip" + echo "Downloading wasm Swift SDK from ${SDK_URL}" + if curl -fsSL "${SDK_URL}" -o /tmp/swift-wasm-sdk.zip; then + swift sdk install /tmp/swift-wasm-sdk.zip || true + else + echo "WARN: no official wasm SDK published for ${SWIFT_TAG}; skipping wasm canary build." + fi + swift sdk list || true + + - name: Build wasm — latest JavaScriptKit + id: build-wasm + run: | + SDK_ID=$(swift sdk list 2>/dev/null | grep -i wasm | head -1 | awk '{print $1}' || echo "") + if [ -z "$SDK_ID" ]; then + echo "WARN: No wasm SDK found; skipping wasm build in this canary run." + exit 0 + fi + swift build --swift-sdk "${SDK_ID}" --target OpenCombineJS -c debug + + # On any step failure: check for an existing open issue with the same title + # before filing a new one to prevent duplicate noise. + # + # Security: JSKIT_VERSION is sourced from gh api output (a semver tag string); + # it is stored in an env var and never interpolated into run: inline. + # The issue title and body are constructed from these controlled env vars only. + - name: File canary-failure issue (if not already open) + if: failure() + env: + GH_TOKEN: ${{ github.token }} + JSKIT_VERSION: ${{ steps.jskit-version.outputs.version }} + RUN_ID: ${{ github.run_id }} + REPO: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + ISSUE_TITLE="Canary failure: JavaScriptKit ${JSKIT_VERSION} breaks build" + + # Check for an existing open issue with this exact title to avoid duplicates. + # (gh issue list, not `gh api -f` — -f would turn the request into a POST.) + EXISTING=$(gh issue list --repo "${REPO}" --state open --label canary-failure \ + --json number,title \ + --jq ".[] | select(.title == \"${ISSUE_TITLE}\") | .number" 2>/dev/null | head -1) + + if [ -n "$EXISTING" ]; then + echo "Open canary issue #${EXISTING} already exists — skipping new issue." + gh issue comment "${EXISTING}" \ + --body "Canary re-triggered on run ${RUN_ID}: ${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" \ + --repo "${REPO}" || true + exit 0 + fi + + # Build the issue body in a temp file (keeps YAML block-scalar indentation intact). + BODY_FILE=$(mktemp) + { + echo "## Upstream Contract Breakage Detected" + echo "" + echo "The weekly canary job failed to build OpenCombineJS against JavaScriptKit \`${JSKIT_VERSION}\`." + echo "" + echo "### Likely causes (from 05-contract-tests.md risk catalog)" + echo "- \`JSValueDecoder\` duplicate TopLevelDecoder conformance — R1 (issue #9)" + echo "- \`JSPromise.then(success:failure:)\` signature change — R2" + echo "- \`JSTimer\` reference-semantics change — R3" + echo "- OpenCombine Swift 6 Sendable incompatibility — R5" + echo "" + echo "### Action required" + echo "1. Review the [canary workflow run](${SERVER_URL}/${REPO}/actions/runs/${RUN_ID})." + echo "2. If R1 (duplicate conformance): delete \`Sources/OpenCombineJS/JSValueDecoder.swift\` and bump the minor version." + echo "3. If R2/R3: update the affected bridging code." + echo "4. Open a migration issue linked here and milestone it to the next sprint." + echo "" + echo "Run ID: \`${RUN_ID}\`" + } > "${BODY_FILE}" + + gh issue create \ + --title "${ISSUE_TITLE}" \ + --body-file "${BODY_FILE}" \ + --label "canary-failure" \ + --repo "${REPO}" + + # --------------------------------------------------------------------------- + # opencombine-latest: build against the latest OpenCombine release (host only). + # + # Detects Scheduler/Publisher/Sendable API changes in OpenCombine that would + # break the library before users see them. + # --------------------------------------------------------------------------- + opencombine-latest: + name: Canary — Latest OpenCombine (#12) + # swift-tools-version:6.3 requires Swift 6.3 → Xcode 26.x → macos-26 image. + runs-on: macos-26 + continue-on-error: true + permissions: + contents: read + issues: write # File canary-failure issues on breakage + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode (Swift 6.3) + run: | + LATEST_XCODE=$(ls /Applications/ | grep -E '^Xcode_?[0-9]' | sort -V | tail -1) + if [ -n "$LATEST_XCODE" ]; then + sudo xcode-select -s "/Applications/${LATEST_XCODE}/Contents/Developer" + fi + swift --version + + - name: Resolve latest OpenCombine version + id: oc-version + env: + GH_TOKEN: ${{ github.token }} + run: | + LATEST=$(gh api repos/OpenCombine/OpenCombine/releases/latest --jq '.tag_name') + if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then + echo "ERROR: Could not resolve latest OpenCombine version from GitHub API" + exit 1 + fi + # F-02/F-03: validate tag format before the value reaches sed/jq/issue title. + if ! printf '%s' "$LATEST" | grep -qE '^[A-Za-z0-9._+-]+$'; then + echo "ERROR: unexpected characters in upstream tag: ${LATEST}" + exit 1 + fi + echo "version=${LATEST}" >> "$GITHUB_OUTPUT" + echo "Latest OpenCombine: ${LATEST}" + + - name: Patch Package.swift → latest OpenCombine + env: + OC_VERSION: ${{ steps.oc-version.outputs.version }} + run: | + echo "Patching Package.swift: OpenCombine → exact: ${OC_VERSION}" + # macOS sed requires '' for in-place edits. + sed -i '' \ + "s|from: \"0.14.0\"|exact: \"${OC_VERSION}\"|g" \ + Package.swift + rm -f Package.resolved + swift package resolve + + - name: Build host — latest OpenCombine + id: build-host + run: swift build --target OpenCombineJS + + # Run host-runnable tests if the test target exists. + # This step is best-effort; `swift test` exits 0 if there are no test targets. + - name: Test host — latest OpenCombine + id: test-host + run: swift test || true + + - name: File canary-failure issue (if not already open) + if: failure() + env: + GH_TOKEN: ${{ github.token }} + OC_VERSION: ${{ steps.oc-version.outputs.version }} + RUN_ID: ${{ github.run_id }} + REPO: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + ISSUE_TITLE="Canary failure: OpenCombine ${OC_VERSION} breaks build" + + # (gh issue list, not `gh api -f` — -f would turn the request into a POST.) + EXISTING=$(gh issue list --repo "${REPO}" --state open --label canary-failure \ + --json number,title \ + --jq ".[] | select(.title == \"${ISSUE_TITLE}\") | .number" 2>/dev/null | head -1) + + if [ -n "$EXISTING" ]; then + echo "Open canary issue #${EXISTING} already exists — skipping new issue." + gh issue comment "${EXISTING}" \ + --body "Canary re-triggered on run ${RUN_ID}: ${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" \ + --repo "${REPO}" || true + exit 0 + fi + + # Build the issue body in a temp file (keeps YAML block-scalar indentation intact). + BODY_FILE=$(mktemp) + { + echo "## OpenCombine Upstream Breakage Detected" + echo "" + echo "The weekly canary job failed to build OpenCombineJS against OpenCombine \`${OC_VERSION}\`." + echo "" + echo "### Likely causes" + echo "- Scheduler/Publisher/Subscriber Sendable annotation changes incompatible with Swift 6 strict mode" + echo "- OpenCombine \`Cancellable\` or \`Subscription\` protocol changes" + echo "- New \`@available\` constraints or renamed symbols" + echo "" + echo "### Action required" + echo "1. Review the [canary workflow run](${SERVER_URL}/${REPO}/actions/runs/${RUN_ID})." + echo "2. Apply a compatibility shim or update the affected bridging code." + echo "3. Open a migration issue linked here and milestone it to the next sprint." + echo "" + echo "Run ID: \`${RUN_ID}\`" + } > "${BODY_FILE}" + + gh issue create \ + --title "${ISSUE_TITLE}" \ + --body-file "${BODY_FILE}" \ + --label "canary-failure" \ + --repo "${REPO}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa6d682..e6193a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,16 @@ +# CI — OpenCombineJS +# Issue: #6 (CI modernization — replace dead Swift 5.3/5.4/5.5 wasm lanes) +# +# Two jobs: +# host — macOS, Swift 6.3, swift build + swift test (host-runnable tests self-gate +# wasm-only paths via #if os(WASI)) +# wasm — Ubuntu, Swift static SDK for wasm32-unknown-wasi, build-only for now +# (TODO #7: enable wasm test execution once WasmKit runner is stable on CI) +# +# Action versions verified (2026-06-11): +# actions/checkout v4 — https://github.com/actions/checkout/releases (v4 is current major) +# actions/cache v4 — https://github.com/actions/cache/releases (v4 is current major) + name: CI on: @@ -6,30 +19,127 @@ on: pull_request: branches: [main] +# Cancel superseded runs on the same ref so PRs don't queue stale builds. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: - linux_build_5_3: - runs-on: ubuntu-20.04 + # --------------------------------------------------------------------------- + # host: macOS build + test + # swift-tools-version:6.3 requires Swift 6.3 → Xcode 26.x → macos-26 image. + # (macos-15 ships Xcode 16.x / Swift 6.0-6.1, which cannot parse the manifest.) + # --------------------------------------------------------------------------- + host: + name: Build & Test (macOS / Swift 6.3) + runs-on: macos-26 steps: - - uses: actions/checkout@v2 - - uses: swiftwasm/swiftwasm-action@v5.3 - with: - shell-action: swift build --triple wasm32-unknown-wasi + - name: Checkout + uses: actions/checkout@v4 - linux_build_5_4: - runs-on: ubuntu-20.04 + # Use the newest Xcode on the image (macos-26 ships Xcode 26.x = Swift 6.3). + - name: Select Xcode (Swift 6.3) + run: | + ls /Applications/ | grep -i xcode || true + LATEST_XCODE=$(ls /Applications/ | grep -E '^Xcode_?[0-9]' | sort -V | tail -1) + if [ -n "$LATEST_XCODE" ]; then + sudo xcode-select -s "/Applications/${LATEST_XCODE}/Contents/Developer" + fi + swift --version - steps: - - uses: actions/checkout@v2 - - uses: swiftwasm/swiftwasm-action@v5.4 + # SPM dependency cache keyed on Package.resolved so a resolved-file change + # automatically invalidates the cache. + - name: Cache SPM dependencies + uses: actions/cache@v4 with: - shell-action: swift build --triple wasm32-unknown-wasi + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build (host) + run: swift build + + - name: Test (host) + # OpenCombineJSTests uses #if os(WASI) guards so wasm-only tests are + # silently skipped on macOS — this is intentional. Once OpenCombineJSTests + # exists (added by the next modernization step) this command runs it. + run: swift test + + # --------------------------------------------------------------------------- + # wasm: cross-compile library + tests for wasm32-unknown-wasi and RUN the + # test suite under Node via JavaScriptKit's PackageToJS plugin. + # + # Both the toolchain and the wasm Swift SDK are the OFFICIAL swift.org + # artifacts and MUST be the same version (swift sdk requires an exact + # toolchain match). Commands below were verified locally on 2026-06-11 + # against Swift 6.3.2 + swift-6.3.2-RELEASE_wasm (62 tests pass under + # Node v20 via `swift package js test`). + # --------------------------------------------------------------------------- + wasm: + name: Build & Test (wasm32-unknown-wasi / Swift SDK) + runs-on: ubuntu-latest - linux_build_5_5: - runs-on: ubuntu-20.04 + env: + SWIFT_VERSION: "6.3.2" steps: - - uses: actions/checkout@v2 - - uses: swiftwasm/swiftwasm-action@v5.5 - with: - shell-action: swift build --triple wasm32-unknown-wasi + - name: Checkout + uses: actions/checkout@v4 + + # Ubuntu runners ship a system Swift that may lag. Install the exact + # swift.org release matching the wasm SDK below. + - name: Install Swift ${{ env.SWIFT_VERSION }} toolchain + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + binutils git gnupg2 libc6-dev libcurl4-openssl-dev \ + libedit2 libgcc-s1 libpython3-dev libsqlite3-0 libstdc++6 \ + libxml2-dev libz3-dev pkg-config tzdata unzip zlib1g-dev curl + + SWIFT_TAG="swift-${SWIFT_VERSION}-RELEASE" + SWIFT_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/ubuntu2404/${SWIFT_TAG}/${SWIFT_TAG}-ubuntu2404-x86_64.tar.gz" + echo "Downloading Swift toolchain from ${SWIFT_URL}" + curl -fsSL "${SWIFT_URL}" -o /tmp/swift.tar.gz + # F-04: verify the swift.org PGP signature before unpacking. + curl -fsSL "${SWIFT_URL}.sig" -o /tmp/swift.tar.gz.sig + curl -fsSL https://swift.org/keys/all-keys.asc | gpg --import - + gpg --verify /tmp/swift.tar.gz.sig /tmp/swift.tar.gz + sudo tar -xzf /tmp/swift.tar.gz -C /usr/local --strip-components=1 + echo "/usr/local/bin" >> "$GITHUB_PATH" + + - name: Verify Swift and Node versions + # Node >= 20 is required by the PackageToJS test runner; ubuntu-latest ships it. + run: | + swift --version + node --version + + # Official swift.org wasm Swift SDK, version-matched to the toolchain. + # Downloaded then installed from the local file (no remote-checksum dance). + - name: Install Swift wasm32-unknown-wasi SDK (official, version-matched) + run: | + SWIFT_TAG="swift-${SWIFT_VERSION}-RELEASE" + SDK_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/wasm-sdk/${SWIFT_TAG}/${SWIFT_TAG}_wasm.artifactbundle.zip" + echo "Downloading wasm Swift SDK from ${SDK_URL}" + curl -fsSL "${SDK_URL}" -o /tmp/swift-wasm-sdk.zip + # F-04: verify signature if published (keys imported by the toolchain step). + if curl -fsSL "${SDK_URL}.sig" -o /tmp/swift-wasm-sdk.zip.sig; then + gpg --verify /tmp/swift-wasm-sdk.zip.sig /tmp/swift-wasm-sdk.zip + else + echo "WARN: no .sig published for the wasm SDK bundle; relying on HTTPS integrity." + fi + swift sdk install /tmp/swift-wasm-sdk.zip + swift sdk list + + - name: Build (wasm32-unknown-wasi, all targets + tests) + run: swift build --swift-sdk "swift-${SWIFT_VERSION}-RELEASE_wasm" --build-tests + + - name: Test (wasm under Node via JavaScriptKit PackageToJS) # #7 + # --disable-sandbox: the PackageToJS plugin shells out to npm at build time. + # Note: PackageToJS expects the default scratch path (.build) — do not pass + # --scratch-path here. + run: swift package --disable-sandbox --swift-sdk "swift-${SWIFT_VERSION}-RELEASE_wasm" js test diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 3f83f97..e95450a 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -1,21 +1,32 @@ +# Check PR labels — OpenCombineJS +# Issue: #6 (CI modernization — bump action versions) +# +# Requires that every PR targeting main carries at least one of the allowed labels +# before it can be merged. Behavior is unchanged from the original workflow; +# only the action version is bumped. +# +# Action versions verified (2026-06-11): +# zwaldowski/match-label-action v3 — https://github.com/zwaldowski/match-label-action/releases +# v3 is the current release; v2 is unmaintained and uses a deprecated +# Node.js 12 runtime that GitHub will reject. + name: Check PR labels -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the `main` branch on: pull_request: branches: [main] types: [opened, synchronize, reopened, labeled, unlabeled] -# A workflow run is made up of one or more jobs that can run sequentially or in parallel +permissions: + contents: read + jobs: check-labels: - # The type of runner that the job will run on runs-on: ubuntu-latest steps: - name: Match PR Label - uses: zwaldowski/match-label-action@v2 + uses: zwaldowski/match-label-action@v3 with: allowed_multiple: > API design, From a1d294c96df322d6dbe9f521519f254f05a0d3d0 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:52:36 +0300 Subject: [PATCH 4/8] #10 docs: add DocC catalog, API docs; modernize example - DocC documentation comments on every public symbol of JSPromise, JSScheduler and JSValueDecoder, plus a Documentation.docc catalog with overview and quick-start (builds clean with swift-docc-plugin). - JSValueDecoder: documents the retroactive TopLevelDecoder conformance risk (if an upstream ever ships the conformance, the file must be deleted and a minor release tagged) with the 2026-06-11 audit result; the weekly canary (#6 pipeline) watches for the trigger. - Example rewritten without force unwraps: graceful guards with console diagnostics, short user-facing DOM error message (full error goes to console.error per security review), event-loop context comments. README example mirrors the new code. - CHANGELOG: Unreleased 0.3.0 section covering #2-#10. Closes #8 Closes #9 Closes #10 --- CHANGELOG.md | 45 ++++++++ README.md | 54 +++++++-- .../Documentation.docc/OpenCombineJS.md | 96 ++++++++++++++++ Sources/OpenCombineJS/JSPromise.swift | 68 +++++++++-- Sources/OpenCombineJS/JSValueDecoder.swift | 29 +++++ Sources/OpenCombineJSExample/main.swift | 107 ++++++++++++++++-- 6 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6fd14..8f3cfce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +# Unreleased (0.3.0) + +This release fixes four long-standing `JSScheduler` bugs and adds the package's first automated +test suite. + +**Fixed bugs:** + +- `SchedulerTimeType.Stride.microseconds(_:)` and `.nanoseconds(_:)` returned the reciprocal of + the correct duration; they now convert to milliseconds correctly + ([#2](https://github.com/IGRSoft/OpenCombineJS/issues/2)) +- Cancelling the `Cancellable` returned by `schedule(after:interval:tolerance:options:_:)` before + the first fire crashed on a force-unwrapped `nil` timer and left the pending timeout running; + cancellation is now safe at any point (before the first fire, between fires, and when called + repeatedly) and stops both timers + ([#3](https://github.com/IGRSoft/OpenCombineJS/issues/3)) +- One-shot timers were never removed from the scheduler's internal storage because their cleanup + closures captured a still-`nil` timer reference, leaking every timer; timers are now tracked by + value tokens and removed after firing or on cancellation + ([#4](https://github.com/IGRSoft/OpenCombineJS/issues/4)) +- The repeating schedule's interval timer was created as a one-shot `setTimeout` and fired only + once; it now passes `isRepeating: true` and fires repeatedly at the requested cadence + ([#5](https://github.com/IGRSoft/OpenCombineJS/issues/5)) + +**Additions:** + +- New `OpenCombineJSTests` test target using Swift Testing: host-runnable + `SchedulerTimeType`/`Stride` tests plus wasm-gated suites covering `JSScheduler` runtime + behavior, `JSPromise.publisher`, and the `JSValueDecoder` `TopLevelDecoder` conformance. + Run on wasm with `swift package --swift-sdk js test` (Node.js required) + ([#7](https://github.com/IGRSoft/OpenCombineJS/issues/7)) +- `Sources/OpenCombineJSExample/main.swift` modernized: all force unwraps replaced with + `guard`/`if-let` and graceful error messages displayed in the DOM; added comments + explaining the JS event-loop execution context; README example block updated to match + ([#8](https://github.com/IGRSoft/OpenCombineJS/issues/8)) +- `JSValueDecoder.swift` now carries a documentation comment explaining the retroactive + `TopLevelDecoder` conformance, the duplicate-conformance build-break risk, and the + maintenance action required if JavaScriptKit or OpenCombine ever ships the conformance + natively; audited against JavaScriptKit 0.54.1 (2026-06-11) + ([#9](https://github.com/IGRSoft/OpenCombineJS/issues/9)) +- DocC `///` documentation added to all previously undocumented public symbols across + `JSPromise.swift`, `JSScheduler.swift`, and `JSValueDecoder.swift`; new + `Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md` landing page with overview, + quick-start snippet, and Topics section; `swift-docc-plugin` 1.4.0 added to Package.swift + ([#10](https://github.com/IGRSoft/OpenCombineJS/issues/10)) + # 0.2.0 (5 April 2022) This release updates dependencies on OpenCombine and JavaScriptKit to their 0.13.0 versions. diff --git a/README.md b/README.md index 586db8c..f7ec592 100644 --- a/README.md +++ b/README.md @@ -27,33 +27,63 @@ import JavaScriptKit import OpenCombine import OpenCombineJS -private let jsFetch = JSObject.global.fetch.function! -func fetch(_ url: String) -> JSPromise { - JSPromise(jsFetch(url).object!)! -} - +// All code runs on the single-threaded JS event loop — no concurrency needed. let document = JSObject.global.document var p = document.createElement("p") _ = document.body.appendChild(p) +// Holds the active subscription across timer ticks; reassignment cancels the previous one. var subscription: AnyCancellable? +// JSTimer wraps setInterval; the closure fires every 1 000 ms on the event loop. let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) { - subscription = fetch("https://httpbin.org/uuid") + // Resolve `fetch` safely — no force unwrap. + guard + let fetchFn = JSObject.global.fetch.function, + let promiseObj = fetchFn("https://httpbin.org/uuid").object, + let fetchPromise = JSPromise(promiseObj) + else { + p.innerText = .string("fetch unavailable") + return + } + + subscription = fetchPromise .publisher - .flatMap { - JSPromise($0.json().object!)!.publisher + // Chain the .json() call, again without force unwraps. + .flatMap { responseValue -> JSPromise.PromisePublisher in + guard + let obj = responseValue.object, + let jsonFn = obj.json.function, + let jsonObj = jsonFn().object, + let jsonPromise = JSPromise(jsonObj) + else { + return JSPromise(resolver: { resolve in + resolve(.failure(JSPromise.PromiseError(.string("Unexpected response shape")))) + return .undefined + }).publisher + } + return jsonPromise.publisher } .mapError { $0 as Error } - .map { Result.success($0.uuid.string!) } + .map { jsonValue -> Result in + if let uuid = jsonValue.uuid.string { + return .success(uuid) + } + return .failure(DecodingError.valueNotFound( + String.self, + .init(codingPath: [], debugDescription: "uuid field missing or not a string") + )) + } .catch { Just(.failure($0)) } - .sink { + .sink { result in let time = JSDate().toLocaleTimeString() - switch $0 { + switch result { case let .success(uuid): p.innerText = .string("At \(time) received uuid \(uuid)") case let .failure(error): - p.innerText = .string("At \(time) received error \(error)") + // Short, user-facing message in the DOM; full error in the dev console. + JSObject.global.console.error("fetch pipeline failed: \(error)") + p.innerText = .string("At \(time) the request failed — see console for details") } } } diff --git a/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md b/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md new file mode 100644 index 0000000..2b9d411 --- /dev/null +++ b/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md @@ -0,0 +1,96 @@ +# ``OpenCombineJS`` + +OpenCombine 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). + +The library provides three 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`. | +| `JSValueDecoder` (`TopLevelDecoder`) | A retroactive conformance that lets `JSValueDecoder` work with the `.decode(type:decoder:)` Combine operator. | + +### JavaScript event-loop context + +All code in OpenCombineJS runs on the single-threaded JavaScript event loop. There is no +multi-threading, no POSIX signals, and no async/await suspension across JS frames. Timer +callbacks and promise resolutions are delivered by the browser's scheduler; top-level +executable code only needs to register state and return — execution continues via the event +loop thereafter. + +Because of this invariant, none of the types exposed by OpenCombineJS are `Sendable`; they +must not cross Swift concurrency domains. + +### Quick start + +The example below runs a repeating timer that fetches a UUID from a remote server, decodes +it, and renders the result into a DOM paragraph element: + +```swift +import JavaScriptKit +import OpenCombine +import OpenCombineJS + +let document = JSObject.global.document +var p = document.createElement("p") +_ = document.body.appendChild(p) + +var subscription: AnyCancellable? + +let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) { + guard let fetchFn = JSObject.global.fetch.function, + let promiseObj = fetchFn("https://httpbin.org/uuid").object, + let fetchPromise = JSPromise(promiseObj) else { return } + + subscription = fetchPromise + .publisher + .flatMap { responseValue -> JSPromise.PromisePublisher in + guard let obj = responseValue.object, + let jsonFn = obj.json.function, + let jsonObj = jsonFn().object, + let jsonPromise = JSPromise(jsonObj) else { + return JSPromise(resolver: { resolve in + resolve(.failure(JSPromise.PromiseError(.string("bad response")))) + return .undefined + }).publisher + } + return jsonPromise.publisher + } + .mapError { $0 as Error } + .map { jsonValue -> Result in + if let uuid = jsonValue.uuid.string { return .success(uuid) } + return .failure(DecodingError.valueNotFound(String.self, + .init(codingPath: [], debugDescription: "uuid field missing"))) + } + .catch { Just(.failure($0)) } + .sink { result in + let time = JSDate().toLocaleTimeString() + switch result { + case let .success(uuid): p.innerText = .string("At \(time) uuid \(uuid)") + case let .failure(error): p.innerText = .string("At \(time) error \(error)") + } + } +} +``` + +## Topics + +### Scheduling + +- ``JSScheduler`` +- ``JSScheduler/SchedulerTimeType`` +- ``JSScheduler/SchedulerTimeType/Stride`` +- ``JSScheduler/SchedulerOptions`` + +### 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. diff --git a/Sources/OpenCombineJS/JSPromise.swift b/Sources/OpenCombineJS/JSPromise.swift index 0d10524..a1b5188 100644 --- a/Sources/OpenCombineJS/JSPromise.swift +++ b/Sources/OpenCombineJS/JSPromise.swift @@ -15,20 +15,54 @@ import JavaScriptKit import OpenCombine +/// Extensions that expose a Combine `Publisher` interface on `JSPromise`. +/// +/// Import `OpenCombineJS` to access the `.publisher` property and associated types. +/// All API in this extension is safe to call only from the JavaScript event loop thread; +/// do not dispatch across concurrency domains. public extension JSPromise { - - /// Error wrapper that carries a JSValue rejection reason. + /// A typed error wrapping a JavaScript rejection value. + /// + /// When a `JSPromise` is rejected, the rejection reason arrives as an untyped `JSValue`. + /// `PromiseError` boxes that value so it can be forwarded as a typed Swift `Error` through + /// Combine pipelines without losing the original JS context. + /// + /// ## Sendable rationale /// - /// `@unchecked Sendable` is sound here: JavaScriptKit runs single-threaded in WASM, so the - /// wrapped `JSValue` never crosses isolation domains. The annotation is required because - /// Swift 6's `Error` refines `Sendable` while JavaScriptKit marks `JSValue` non-Sendable. + /// The type is marked `@unchecked Sendable` because `JSValue` itself is not `Sendable` in + /// JavaScriptKit; however, the WASM runtime is single-threaded and all JavaScript execution + /// occurs on one event-loop thread, so the wrapped value never crosses isolation domains in + /// practice. The annotation is required because Swift 6's `Error` protocol refines `Sendable`. struct PromiseError: Error, Equatable, @unchecked Sendable { + /// The raw JavaScript value that caused the promise rejection. public let value: JSValue - public init(_ value: JSValue) { self.value = value } + + /// Creates a `PromiseError` wrapping the given JavaScript rejection value. + /// + /// - Parameter value: The `JSValue` passed to the promise's rejection handler. + public init(_ value: JSValue) { + self.value = value + } } + /// A `Publisher` that emits the resolved value of a `JSPromise` exactly once, then + /// completes; or forwards the rejection reason as a `PromiseError` failure. + /// + /// `PromisePublisher` is backed by a `Future` so that the resolved + /// or rejected value is cached and replayed to late subscribers — consistent with the + /// settled semantics of a JavaScript `Promise`. + /// + /// ## Output and Failure types + /// + /// | Associated type | Concrete type | + /// |---|---| + /// | `Output` | `JSValue` | + /// | `Failure` | `PromiseError` | final class PromisePublisher: Publisher { + /// The resolved JavaScript value emitted by this publisher. public typealias Output = JSValue + + /// The error type emitted when the underlying promise is rejected. public typealias Failure = PromiseError /// `Future` instance that handles subscriptions to this publisher. @@ -46,6 +80,13 @@ public extension JSPromise { } } + /// Attaches a subscriber to this publisher. + /// + /// Delegates to the underlying `Future`, which replays the settled value to each + /// subscriber independently. + /// + /// - Parameter subscriber: The subscriber to attach. Must accept `JSValue` input and + /// `PromiseError` failures. public func receive(subscriber: Downstream) where Downstream.Input == JSValue, Downstream.Failure == PromiseError { @@ -53,7 +94,16 @@ public extension JSPromise { } } - /// Creates a new publisher for this `JSPromise` instance. + /// A Combine publisher that resolves or rejects in lock-step with this `JSPromise`. + /// + /// Use this property to integrate JavaScript promises into Combine pipelines: + /// + /// ```swift + /// fetch("https://httpbin.org/uuid") + /// .publisher + /// .flatMap { $0.json().publisher } // chain further JS promises + /// .sink { ... } + /// ``` var publisher: PromisePublisher { .init(promise: self) } @@ -68,7 +118,9 @@ public extension JSPromise { let inner: Inner - var combineIdentifier: CombineIdentifier { inner.combineIdentifier } + var combineIdentifier: CombineIdentifier { + inner.combineIdentifier + } func receive(subscription: Subscription) { inner.receive(subscription: subscription) diff --git a/Sources/OpenCombineJS/JSValueDecoder.swift b/Sources/OpenCombineJS/JSValueDecoder.swift index 86c5d4e..43408b7 100644 --- a/Sources/OpenCombineJS/JSValueDecoder.swift +++ b/Sources/OpenCombineJS/JSValueDecoder.swift @@ -15,6 +15,35 @@ import JavaScriptKit import OpenCombine +/// Retroactive conformance that bridges JavaScriptKit's `JSValueDecoder` into the +/// OpenCombine / Combine ecosystem. +/// +/// ## Purpose +/// +/// This extension retroactively conforms `JSValueDecoder` to OpenCombine's +/// `TopLevelDecoder` protocol. That single conformance unlocks the `.decode(type:decoder:)` +/// operator on any `Publisher` whose `Output` is `JSValue`, enabling idioms such as: +/// +/// ```swift +/// promise.publisher +/// .decode(type: MyModel.self, decoder: JSValueDecoder()) +/// ``` +/// +/// ## Maintenance note — duplicate conformance risk (issue #9) +/// +/// Because `JSValueDecoder` is declared in `JavaScriptKit` and `TopLevelDecoder` is +/// declared in `OpenCombine`, this conformance is retroactive (`@retroactive`). Swift +/// forbids two modules from providing the same retroactive conformance simultaneously; +/// if either `JavaScriptKit` or `OpenCombine` ever ships this conformance natively the +/// build will fail with a "redundant conformance" error. +/// +/// **Action required** when that happens: +/// 1. Delete this file entirely. +/// 2. Remove the `OpenCombineJS` import from any call sites (conformance is inherited +/// automatically). +/// 3. Tag a minor release so dependents can migrate. +/// +/// **Last audited:** 2026-06-11 — `JavaScriptKit` 0.54.1 does **not** ship this conformance. extension JSValueDecoder: @retroactive TopLevelDecoder { public func decode(_ type: T.Type, from value: JSValue) throws -> T { try decode(type, from: value, userInfo: [:]) diff --git a/Sources/OpenCombineJSExample/main.swift b/Sources/OpenCombineJSExample/main.swift index 2a9974b..90b8b25 100644 --- a/Sources/OpenCombineJSExample/main.swift +++ b/Sources/OpenCombineJSExample/main.swift @@ -1,35 +1,122 @@ +// 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. + +// This file is the executable entry point for the OpenCombineJS WebAssembly example. +// It runs entirely on the JavaScript event loop: there is no multi-threading, no +// async/await suspension across JS frames, and no POSIX signal handling. `JSTimer` +// callbacks are invoked by the browser's scheduler, so the top-level code only needs +// to register state and return — execution continues via the event loop thereafter. + import JavaScriptKit import OpenCombine import OpenCombineJS -private let jsFetch = JSObject.global.fetch.function! +// MARK: - Helpers + +/// Wraps the browser's `fetch` global as a typed Swift function. +/// +/// Returns `nil` and prints a diagnostic if `window.fetch` is unavailable (e.g., when +/// running under Node.js without a fetch polyfill), rather than crashing with a force unwrap. @MainActor -func fetch(_ url: String) -> JSPromise { - JSPromise(jsFetch(url).object!)! +func fetch(_ url: String) -> JSPromise? { + // `fetch` is a function on the global object in all modern browsers. + // Guard against environments where it may be absent (Node without polyfill, old JSDOM). + guard let fetchFunction = JSObject.global.fetch.function else { + JSObject.global.console.error("fetch is not available in this environment") + return nil + } + // `fetch(url)` returns a JS Promise object; wrap it in a typed `JSPromise`. + guard let promiseObject = fetchFunction(url).object else { + JSObject.global.console.error("fetch(\(url)) did not return an object") + return nil + } + return JSPromise(promiseObject) } +// MARK: - DOM setup + +/// Obtain a reference to `document` from the JS global scope. let document = JSObject.global.document + +/// Create a

element that will display the latest UUID or error message. var p = document.createElement("p") _ = document.body.appendChild(p) +// MARK: - Subscription storage + +/// Holds the active subscription so it is not deallocated between timer ticks. +/// Reassigned on every tick, which cancels the previous subscription automatically. var subscription: AnyCancellable? +// MARK: - Periodic UUID fetch + +/// `JSTimer` schedules a repeating callback on the JS event loop using `setInterval`. +/// The callback fires every 1 000 ms and starts a new Combine pipeline to fetch a UUID. let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) { - subscription = fetch("https://httpbin.org/uuid") + // Resolve `fetch` and construct the first promise; bail out gracefully if unavailable. + guard let fetchPromise = fetch("https://httpbin.org/uuid") else { + p.innerText = .string("fetch unavailable — cannot reach httpbin.org/uuid") + return + } + + subscription = fetchPromise .publisher - .flatMap { - JSPromise($0.json().object!)!.publisher + // The response is a Response object; call .json() to get a second Promise with the + // parsed body. Guard against unexpected values to avoid silent data loss. + .flatMap { responseValue -> JSPromise.PromisePublisher in + guard + let responseObject = responseValue.object, + let jsonFn = responseObject.json.function, + let jsonPromiseObject = jsonFn().object, + let jsonPromise = JSPromise(jsonPromiseObject) + else { + // Return a publisher that immediately fails if the response is malformed. + // JSPromise's resolver receives a Result; the rejection value + // is a raw JSValue (wrapped into PromiseError by the publisher layer). + let dummy = JSPromise(resolver: { resolve in + resolve(.failure(.string("Unexpected response shape"))) + }) + return dummy.publisher + } + return jsonPromise.publisher } + // Surface JS rejection reasons as Swift `Error` values for uniform error handling. .mapError { $0 as Error } - .map { Result.success($0.uuid.string!) } + // Extract the UUID string, packaging both success and failure in a `Result` so the + // `.catch` operator can forward error descriptions to the same `.sink` handler. + .map { jsonValue -> Result in + if let uuid = jsonValue.uuid.string { + return .success(uuid) + } else { + return .failure(DecodingError.valueNotFound( + String.self, + .init(codingPath: [], debugDescription: "uuid field missing or not a string") + )) + } + } .catch { Just(.failure($0)) } - .sink { + .sink { result in + // All DOM updates happen here, on the JS event loop, so no synchronization is needed. let time = JSDate().toLocaleTimeString() - switch $0 { + switch result { case let .success(uuid): p.innerText = .string("At \(time) received uuid \(uuid)") case let .failure(error): - p.innerText = .string("At \(time) received error \(error)") + // Show a short, user-facing message in the DOM; keep the full error + // (which may include raw JS rejection values) in the developer console. + JSObject.global.console.error("fetch pipeline failed: \(error)") + p.innerText = .string("At \(time) the request failed — see console for details") } } } From 3fcea9fca2c8205037a777aba6f1646e5336d319 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:15:53 +0300 Subject: [PATCH 5/8] #6 ci: fix swift.org toolchain URL; bump checkout to v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First CI run findings: - The Ubuntu toolchain tarball 404'd: swift.org filenames use the "ubuntu24.04" pattern (with dot, no arch suffix for x86_64), not "ubuntu2404-x86_64". Verified the corrected URL returns 200. - actions/checkout@v4 is Node20-deprecated (runner warning; forced to Node 24 from 2026-06-16) — bumped to v6 in all workflows. - Stale "build-only" comments removed: the wasm lane runs the full suite under Node via PackageToJS. Host lane and API gate passed on the first run unchanged. Refs #6 --- .github/workflows/api-contract.yml | 4 ++-- .github/workflows/canary-upstreams.yml | 6 +++--- .github/workflows/ci.yml | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/api-contract.yml b/.github/workflows/api-contract.yml index a8d69c9..5955ef8 100644 --- a/.github/workflows/api-contract.yml +++ b/.github/workflows/api-contract.yml @@ -20,7 +20,7 @@ # baseline ref and once at HEAD — and reports added/removed/changed public symbols. # # Action versions verified (2026-06-11): -# actions/checkout v4 — https://github.com/actions/checkout/releases +# actions/checkout v6 — https://github.com/actions/checkout/releases name: API Contract Guard @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout (full history required for baseline SHA resolution) - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: # fetch-depth: 0 ensures the base SHA is present in the local clone. fetch-depth: 0 diff --git a/.github/workflows/canary-upstreams.yml b/.github/workflows/canary-upstreams.yml index d0cc101..9301c6d 100644 --- a/.github/workflows/canary-upstreams.yml +++ b/.github/workflows/canary-upstreams.yml @@ -13,7 +13,7 @@ # event payload (PR title, commit message, etc.) touches any run: command. # # Action versions verified (2026-06-11): -# actions/checkout v4 — https://github.com/actions/checkout/releases +# actions/checkout v6 — https://github.com/actions/checkout/releases name: Canary — Upstream Compatibility @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Select Xcode (Swift 6.3) run: | @@ -210,7 +210,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Select Xcode (Swift 6.3) run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6193a9..0ed6186 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ # Two jobs: # host — macOS, Swift 6.3, swift build + swift test (host-runnable tests self-gate # wasm-only paths via #if os(WASI)) -# wasm — Ubuntu, Swift static SDK for wasm32-unknown-wasi, build-only for now -# (TODO #7: enable wasm test execution once WasmKit runner is stable on CI) +# wasm — Ubuntu, official swift.org wasm SDK; builds all targets and runs the +# full test suite under Node via JavaScriptKit's PackageToJS plugin (#7) # # Action versions verified (2026-06-11): -# actions/checkout v4 — https://github.com/actions/checkout/releases (v4 is current major) +# actions/checkout v6 — https://github.com/actions/checkout/releases (v4 is Node20-deprecated 2026-06-16) # actions/cache v4 — https://github.com/actions/cache/releases (v4 is current major) name: CI @@ -39,7 +39,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Use the newest Xcode on the image (macos-26 ships Xcode 26.x = Swift 6.3). - name: Select Xcode (Swift 6.3) @@ -66,8 +66,7 @@ jobs: - name: Test (host) # OpenCombineJSTests uses #if os(WASI) guards so wasm-only tests are - # silently skipped on macOS — this is intentional. Once OpenCombineJSTests - # exists (added by the next modernization step) this command runs it. + # silently skipped on macOS — this is intentional. run: swift test # --------------------------------------------------------------------------- @@ -89,7 +88,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Ubuntu runners ship a system Swift that may lag. Install the exact # swift.org release matching the wasm SDK below. @@ -102,7 +101,8 @@ jobs: libxml2-dev libz3-dev pkg-config tzdata unzip zlib1g-dev curl SWIFT_TAG="swift-${SWIFT_VERSION}-RELEASE" - SWIFT_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/ubuntu2404/${SWIFT_TAG}/${SWIFT_TAG}-ubuntu2404-x86_64.tar.gz" + # Filename pattern uses "ubuntu24.04" (with dot, no arch suffix for x86_64). + SWIFT_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/ubuntu2404/${SWIFT_TAG}/${SWIFT_TAG}-ubuntu24.04.tar.gz" echo "Downloading Swift toolchain from ${SWIFT_URL}" curl -fsSL "${SWIFT_URL}" -o /tmp/swift.tar.gz # F-04: verify the swift.org PGP signature before unpacking. From be2a605bba80fe35360b6354357a6dc3e040f88f Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:18:51 +0300 Subject: [PATCH 6/8] #6 ci: remove empty GitHub expression from canary comment GitHub Actions evaluates ${ { } }-style expressions anywhere in the workflow file, including comments inside run scripts; the literal "${ { } }" in a comment failed workflow validation ("workflow file issue" run on every push). Found via actionlint. Refs #6, #12 --- .github/workflows/canary-upstreams.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/canary-upstreams.yml b/.github/workflows/canary-upstreams.yml index 9301c6d..056b42e 100644 --- a/.github/workflows/canary-upstreams.yml +++ b/.github/workflows/canary-upstreams.yml @@ -93,7 +93,7 @@ jobs: run: | echo "Patching Package.swift: JavaScriptKit → exact: ${JSKIT_VERSION}" # Replace `from: "0.54.1"` with `exact: ""` for the JSKit dependency. - # Using the env var (not direct ${{ }} interpolation) keeps the sed call safe. + # Using the env var (not direct GitHub-expression interpolation) keeps the sed call safe. sed -i '' \ "s|from: \"0.54.1\"|exact: \"${JSKIT_VERSION}\"|g" \ Package.swift From 636dd593dfab6d019a204c781910c424e3fcdde9 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:20:55 +0300 Subject: [PATCH 7/8] #6 ci: fetch swift.org keys with curl --compressed swift.org serves all-keys.asc gzip-encoded even without Accept-Encoding; plain curl saves the raw gzip bytes and gpg fails with "no valid OpenPGP data found". --compressed makes curl decode the body before piping to gpg --import. Refs #6 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ed6186..de1605f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,9 @@ jobs: curl -fsSL "${SWIFT_URL}" -o /tmp/swift.tar.gz # F-04: verify the swift.org PGP signature before unpacking. curl -fsSL "${SWIFT_URL}.sig" -o /tmp/swift.tar.gz.sig - curl -fsSL https://swift.org/keys/all-keys.asc | gpg --import - + # --compressed: swift.org serves the keyring gzip-encoded; without it + # curl saves raw gzip bytes and gpg rejects them ("no valid OpenPGP data"). + curl -fsSL --compressed https://swift.org/keys/all-keys.asc | gpg --import - gpg --verify /tmp/swift.tar.gz.sig /tmp/swift.tar.gz sudo tar -xzf /tmp/swift.tar.gz -C /usr/local --strip-components=1 echo "/usr/local/bin" >> "$GITHUB_PATH" From 48f9426c57a2067b03f6fc27454761dd50800200 Mon Sep 17 00:00:00 2001 From: Vitalii Parovishnyk <870237+ikorich@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:28:38 +0300 Subject: [PATCH 8/8] #6 ci: use official tar.gz wasm SDK URL with API checksum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm SDK artifact on download.swift.org is published as .artifactbundle.tar.gz — the .zip path 404s (the earlier HEAD probe saw a CDN 302 and was a false positive; verified with ranged GETs). Install now uses `swift sdk install --checksum` with the official SHA-256 from the swift.org releases API, which both validates integrity (F-04) and replaces the manual download. Canary SDK step gets the same .tar.gz correction. Refs #6 --- .github/workflows/canary-upstreams.yml | 7 ++++--- .github/workflows/ci.yml | 21 ++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/canary-upstreams.yml b/.github/workflows/canary-upstreams.yml index 056b42e..1106ae9 100644 --- a/.github/workflows/canary-upstreams.yml +++ b/.github/workflows/canary-upstreams.yml @@ -115,10 +115,11 @@ jobs: run: | SWIFT_SEMVER=$(swift --version 2>/dev/null | grep -oE 'Swift version [0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) SWIFT_TAG="swift-${SWIFT_SEMVER}-RELEASE" - SDK_URL="https://download.swift.org/swift-${SWIFT_SEMVER}-release/wasm-sdk/${SWIFT_TAG}/${SWIFT_TAG}_wasm.artifactbundle.zip" + # Note: .artifactbundle.tar.gz — there is no .zip variant on download.swift.org. + SDK_URL="https://download.swift.org/swift-${SWIFT_SEMVER}-release/wasm-sdk/${SWIFT_TAG}/${SWIFT_TAG}_wasm.artifactbundle.tar.gz" echo "Downloading wasm Swift SDK from ${SDK_URL}" - if curl -fsSL "${SDK_URL}" -o /tmp/swift-wasm-sdk.zip; then - swift sdk install /tmp/swift-wasm-sdk.zip || true + if curl -fsSL "${SDK_URL}" -o /tmp/swift-wasm-sdk.tar.gz; then + swift sdk install /tmp/swift-wasm-sdk.tar.gz || true else echo "WARN: no official wasm SDK published for ${SWIFT_TAG}; skipping wasm canary build." fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de1605f..d59aeba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,10 @@ jobs: env: SWIFT_VERSION: "6.3.2" + # Official SHA-256 for the 6.3.2 wasm SDK bundle, from + # https://www.swift.org/api/v1/install/releases.json (platform "wasm-sdk"). + # Bump together with SWIFT_VERSION. + WASM_SDK_CHECKSUM: "a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c" steps: - name: Checkout @@ -121,20 +125,15 @@ jobs: node --version # Official swift.org wasm Swift SDK, version-matched to the toolchain. - # Downloaded then installed from the local file (no remote-checksum dance). + # Note the .artifactbundle.tar.gz extension — there is no .zip variant. + # F-04: `swift sdk install --checksum` validates the official SHA-256 + # published in the swift.org releases API. - name: Install Swift wasm32-unknown-wasi SDK (official, version-matched) run: | SWIFT_TAG="swift-${SWIFT_VERSION}-RELEASE" - SDK_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/wasm-sdk/${SWIFT_TAG}/${SWIFT_TAG}_wasm.artifactbundle.zip" - echo "Downloading wasm Swift SDK from ${SDK_URL}" - curl -fsSL "${SDK_URL}" -o /tmp/swift-wasm-sdk.zip - # F-04: verify signature if published (keys imported by the toolchain step). - if curl -fsSL "${SDK_URL}.sig" -o /tmp/swift-wasm-sdk.zip.sig; then - gpg --verify /tmp/swift-wasm-sdk.zip.sig /tmp/swift-wasm-sdk.zip - else - echo "WARN: no .sig published for the wasm SDK bundle; relying on HTTPS integrity." - fi - swift sdk install /tmp/swift-wasm-sdk.zip + SDK_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/wasm-sdk/${SWIFT_TAG}/${SWIFT_TAG}_wasm.artifactbundle.tar.gz" + echo "Installing wasm Swift SDK from ${SDK_URL}" + swift sdk install "${SDK_URL}" --checksum "${WASM_SDK_CHECKSUM}" swift sdk list - name: Build (wasm32-unknown-wasi, all targets + tests)