diff --git a/CHANGELOG.md b/CHANGELOG.md index df9516c..c37f2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# Unreleased (0.5.0) + +**Additions (strictly non-breaking — the public API is additive only):** + +- New virtual-clock seam for deterministic scheduling: the `JSClockSource` protocol + (current time + a timer factory mirroring `JSTimer`'s shape, returning a + `JSClockCancellable` token) and the production `DefaultJSClockSource` backed by + `JSDate.now()`/`JSTimer`. `JSScheduler` routes every scheduling path — immediate, + one-shot, repeating, and the async `sleep(for:)`/`timer(interval:)` bridges — through + the seam. The new `JSScheduler.init(clock:)` injects a custom clock; the existing + `init()` is unchanged and keeps the default behavior + ([#14](https://github.com/IGRSoft/OpenCombineJS/issues/14)) +- Deterministic scheduler tests via a manually advanced `VirtualClock` test double; the + exact-value timing suites need no JavaScript runtime and now run on the host lane as + well as on wasm ([#14](https://github.com/IGRSoft/OpenCombineJS/issues/14)) + +**Documentation:** + +- `JSScheduler`'s concurrency model is now an explicit documented contract: the class is + intentionally **not** `Sendable` (its unsynchronized state is only safe on the + single-threaded JS event loop, and Apple-side Combine could legally call a `Sendable` + scheduler from arbitrary threads, so `@unchecked Sendable` was evaluated and rejected). + The module overview gains a "Deterministic testing" guide for the clock seam + ([#14](https://github.com/IGRSoft/OpenCombineJS/issues/14)) + # 0.4.0 (2026-06-11) **Declared change — Apple platforms only:** diff --git a/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md b/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md index 758d13e..90a174c 100644 --- a/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md +++ b/Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md @@ -14,6 +14,7 @@ The library provides these components: |---|---| | ``JSScheduler`` | A Combine `Scheduler` backed by `setTimeout`/`setInterval` that enables time-based operators in the browser. | | ``JSScheduler/sleep(for:)`` / ``JSScheduler/timer(interval:)`` | Async/await counterparts of the scheduling APIs: a one-shot JS-timer delay and an `AsyncStream` of repeating ticks. | +| ``JSClockSource`` | The injectable time-and-timer seam behind ``JSScheduler``; swap in a virtual clock for deterministic tests. ``DefaultJSClockSource`` is the production implementation. | | `JSPromise.publisher` | A `Publisher` property on `JSPromise` that resolves or rejects in lock-step with the underlying JavaScript `Promise`. Its async counterpart is JavaScriptKit's `JSPromise.value` (`import JavaScriptEventLoop`). | | `JSValueDecoder` (`TopLevelDecoder`) | A retroactive conformance that lets `JSValueDecoder` work with the `.decode(type:decoder:)` Combine operator. | @@ -50,6 +51,42 @@ for await _ in scheduler.timer(interval: .seconds(1)) { // repeating setInterva The Combine surface is not deprecated: publishers and async APIs are supported side by side. +### Deterministic testing with a custom clock + +``JSScheduler`` performs all time observation and timer creation through the ``JSClockSource`` +seam. ``JSScheduler/init()`` uses ``DefaultJSClockSource`` (`JSDate.now()` + `JSTimer`); +``JSScheduler/init(clock:)`` accepts any conforming implementation. Because **every** +scheduling path flows through the seam — immediate, one-shot, repeating, and the async +``JSScheduler/sleep(for:)``/``JSScheduler/timer(interval:)`` bridges — injecting a manually +advanced clock makes scheduler behavior fully deterministic: no real timers, no jitter +tolerance bands, and no JavaScript runtime required, so such tests can run on any platform: + +```swift +final class VirtualClock: JSClockSource { + private(set) var now: Double = 0 + // makeTimer(millisecondsDelay:isRepeating:callback:) records pending timers; + // advance(by:) fires the due ones in order and moves `now`. +} + +let clock = VirtualClock() +let scheduler = JSScheduler(clock: clock) + +var fired = false +scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(50)), + tolerance: scheduler.minimumTolerance, + options: nil +) { fired = true } + +clock.advance(by: 49) // fired == false — not due yet +clock.advance(by: 1) // fired == true — exactly on time +``` + +The package's own test suite contains a complete reference implementation +(`Tests/OpenCombineJSTests/VirtualClock.swift`) that mirrors JavaScript timer semantics: +due-time ordering with creation-order ties, negative delays clamping to zero, and repeating +timers firing once per elapsed period. + ### JavaScript event-loop context All code in OpenCombineJS runs on the single-threaded JavaScript event loop. There is no @@ -127,6 +164,13 @@ let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) { - ``JSScheduler/sleep(for:)`` - ``JSScheduler/timer(interval:)`` +### Clock Injection + +- ``JSClockSource`` +- ``JSClockCancellable`` +- ``DefaultJSClockSource`` +- ``JSScheduler/init(clock:)`` + ### JavaScript Promise Integration OpenCombineJS extends `JSPromise` (from JavaScriptKit) with a `publisher` property and two diff --git a/Sources/OpenCombineJS/JSClockSource.swift b/Sources/OpenCombineJS/JSClockSource.swift new file mode 100644 index 0000000..7944836 --- /dev/null +++ b/Sources/OpenCombineJS/JSClockSource.swift @@ -0,0 +1,110 @@ +// 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. + +// Virtual-clock seam for JSScheduler (issue #14). Strictly additive: the default +// implementation reproduces the historical JSDate.now()/JSTimer behavior exactly. + +import JavaScriptKit + +/// A cancellation token for a timer created by a ``JSClockSource``. +/// +/// ``JSScheduler`` keeps the token alive while the timer is pending and calls ``cancel()`` +/// exactly when the timer must stop firing (on explicit cancellation, or as harmless cleanup +/// after a one-shot timer has already fired — implementations must treat cancelling a fired +/// or already-cancelled timer as a no-op, mirroring JavaScript's `clearTimeout`). +/// +/// Conforming types should also stop the timer when the token is deinitialized, mirroring +/// `JSTimer`'s reference semantics. `JSScheduler` always cancels explicitly before releasing +/// a token, so deinit-cancellation is only a defensive backstop. +public protocol JSClockCancellable: AnyObject { + /// Stops the timer. Calling this on a fired or already-cancelled timer is a no-op. + func cancel() +} + +/// The time-and-timer seam used by ``JSScheduler``. +/// +/// `JSScheduler` performs **all** of its time observation and timer creation through this +/// protocol — the immediate, one-shot, and repeating Combine scheduling paths as well as the +/// async ``JSScheduler/sleep(for:)`` and ``JSScheduler/timer(interval:)`` bridges. Injecting +/// a deterministic implementation via ``JSScheduler/init(clock:)`` therefore makes every +/// scheduler behavior reproducible without real timers — including on platforms without a +/// JavaScript runtime (see the "Deterministic testing" section in the module overview). +/// +/// The default implementation, ``DefaultJSClockSource``, is backed by `JSDate.now()` and +/// `JSTimer` (`setTimeout`/`setInterval`) and is what ``JSScheduler/init()`` uses. +/// +/// Implementations inherit the scheduler's single-threaded contract: all calls are made from +/// the thread that owns the scheduler, and conforming types are not required to be `Sendable`. +public protocol JSClockSource { + /// The current time in milliseconds since the Unix epoch (the `Date.now()` convention). + var now: Double { get } + + /// Creates and starts a timer, mirroring `JSTimer.init(millisecondsDelay:isRepeating:callback:)`. + /// + /// - Parameters: + /// - millisecondsDelay: Delay before the first (or only) firing, in milliseconds. + /// Negative values must clamp to `0`, matching JavaScript timer semantics. + /// - isRepeating: When `true`, `callback` fires repeatedly every `millisecondsDelay` + /// milliseconds until the returned token is cancelled; when `false`, it fires once. + /// - callback: The closure to invoke when the timer fires. + /// - Returns: A token that keeps the timer alive and stops it when cancelled. + func makeTimer( + millisecondsDelay: Double, + isRepeating: Bool, + callback: @escaping () -> () + ) -> any JSClockCancellable +} + +/// The production ``JSClockSource``: real JavaScript time and timers. +/// +/// `now` reads `JSDate.now()`; ``makeTimer(millisecondsDelay:isRepeating:callback:)`` creates +/// a `JSTimer` (`setTimeout`/`setInterval`) — exactly the behavior `JSScheduler` had before +/// the seam existed. Requires a live JavaScript runtime (WebAssembly/browser/Node.js); +/// constructing the value itself is side-effect free. +public struct DefaultJSClockSource: JSClockSource { + /// Creates the default JavaScript-backed clock source. + public init() {} + + /// The current time as reported by `Date.now()`, in milliseconds since the Unix epoch. + public var now: Double { + JSDate.now() + } + + /// Starts a `JSTimer` and returns a token whose cancellation releases the timer + /// (dropping the last `JSTimer` reference calls `clearTimeout`/`clearInterval`). + public func makeTimer( + millisecondsDelay: Double, + isRepeating: Bool, + callback: @escaping () -> () + ) -> any JSClockCancellable { + JSTimerCancellable( + JSTimer(millisecondsDelay: millisecondsDelay, isRepeating: isRepeating, callback: callback) + ) + } +} + +/// Token wrapping a `JSTimer`. `JSTimer` has no explicit `invalidate()` — releasing the last +/// strong reference clears the underlying JS timer in `deinit` — so cancellation simply drops +/// the reference. Repeated cancellation is a natural no-op. +private final class JSTimerCancellable: JSClockCancellable { + private var timer: JSTimer? + + init(_ timer: JSTimer) { + self.timer = timer + } + + func cancel() { + timer = nil + } +} diff --git a/Sources/OpenCombineJS/JSScheduler+Async.swift b/Sources/OpenCombineJS/JSScheduler+Async.swift index 1adbc31..d8fa4a2 100644 --- a/Sources/OpenCombineJS/JSScheduler+Async.swift +++ b/Sources/OpenCombineJS/JSScheduler+Async.swift @@ -29,6 +29,8 @@ public extension JSScheduler { /// The delay is implemented with `setTimeout` — **not** `Task.sleep` — so it follows exactly /// the same JS macrotask semantics as the Combine scheduling APIs and shares the scheduler's /// internal timer bookkeeping (the timer entry is removed when it fires; nothing leaks). + /// Like every scheduling path, the delay is driven by the scheduler's ``JSClockSource``, so + /// an injected deterministic clock controls this suspension too. /// /// The method never resumes early: the continuation is resumed by the JS timer callback, which /// the JS runtime fires no earlier than the requested delay. Zero or negative intervals resume @@ -55,7 +57,8 @@ public extension JSScheduler { /// Returns an `AsyncStream` that yields once per `interval`, backed by the same repeating /// JS timer (`setInterval`) semantics as - /// ``JSScheduler/schedule(after:interval:tolerance:options:_:)``. + /// ``JSScheduler/schedule(after:interval:tolerance:options:_:)`` — and, like that path, + /// driven by the scheduler's ``JSClockSource``. /// /// The first tick arrives one full `interval` after the call — the same first-fire semantics /// as the Combine repeating schedule. The stream never finishes on its own; iteration ends diff --git a/Sources/OpenCombineJS/JSScheduler.swift b/Sources/OpenCombineJS/JSScheduler.swift index 1d02cd0..8f392b6 100644 --- a/Sources/OpenCombineJS/JSScheduler.swift +++ b/Sources/OpenCombineJS/JSScheduler.swift @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import JavaScriptKit - // Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11). #if canImport(Combine) import Combine @@ -32,17 +30,48 @@ import OpenCombine /// .sink { ... } /// ``` /// -/// ## Event-loop invariant +/// ## Concurrency model +/// +/// `JSScheduler` is intentionally **not** `Sendable` — that is the documented contract, not +/// an omission. Its mutable state (the pending-timer token table) is unsynchronized and is +/// safe only because, in its intended WebAssembly deployment, every scheduling call and every +/// timer callback runs on the single-threaded JavaScript event loop. The type also compiles +/// on Apple platforms (against native Combine), where Combine operators may legally invoke a +/// `Sendable` scheduler from arbitrary threads — which is exactly why this class must not be +/// marked `@unchecked Sendable`: the single-thread invariant cannot be guaranteed there. +/// +/// All time observation and timer creation flow through a single injectable seam +/// (``JSClockSource``), so the class body holds no other environment-dependent state and the +/// single-thread invariant is straightforward to audit. /// -/// `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: Instances must not be shared across concurrency domains or moved off the +/// thread that created them (on WebAssembly: the JS event-loop thread). /// -/// - Important: `JSScheduler` is intentionally **not** `Sendable`. Instances must not be -/// shared across concurrency domains or moved off the JS event-loop thread. +/// ## Deterministic testing +/// +/// Inject a manually advanced ``JSClockSource`` via ``init(clock:)`` to drive every scheduling +/// path — including ``sleep(for:)`` and ``timer(interval:)`` — without real timers or a +/// JavaScript runtime. See the "Deterministic testing" section in the module overview. public final class JSScheduler: Scheduler { - /// Creates a new `JSScheduler` backed by the current JavaScript runtime. - public init() {} + /// The time-and-timer seam. All scheduling paths read time and create timers exclusively + /// through this value; `JSDate`/`JSTimer` are never touched directly. + private let clock: any JSClockSource + + /// Creates a new `JSScheduler` backed by the current JavaScript runtime + /// (equivalent to `JSScheduler(clock: DefaultJSClockSource())`). + public init() { + clock = DefaultJSClockSource() + } + + /// Creates a `JSScheduler` driven by the given clock source. + /// + /// Use this initializer to inject a deterministic clock in tests; production code can keep + /// using ``init()``, which is backed by `JSDate.now()` and `JSTimer`. + /// + /// - Parameter clock: The source of current time and timers for all scheduling paths. + public init(clock: any JSClockSource) { + self.clock = clock + } private final class CancellableTimer: Cancellable { let cancellation: () -> () @@ -184,9 +213,10 @@ public final class JSScheduler: Scheduler { /// Opaque options type; `JSScheduler` has no configurable scheduling options. public struct SchedulerOptions {} - /// The current time as reported by `Date.now()`, expressed in milliseconds. + /// The current time as reported by the scheduler's clock source, expressed in milliseconds. + /// For the default clock this is `Date.now()`. public var now: SchedulerTimeType { - .init(millisecondsValue: JSDate.now()) + .init(millisecondsValue: clock.now) } /// The minimum tolerance accepted by the scheduler. @@ -206,16 +236,24 @@ public final class JSScheduler: Scheduler { 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]() + /// when a repeating schedule is cancelled; removal cancels the clock-source token, which (for + /// the default clock) 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: any JSClockCancellable]() private func nextToken() -> UInt64 { defer { nextTimerToken += 1 } return nextTimerToken } + /// Removes the timer registered under `token` (if any) and cancels it. Cancelling a fired + /// one-shot timer is a documented no-op on `JSClockCancellable`, so this is also used as + /// the post-fire cleanup path. + private func removeTimer(_ token: UInt64) { + scheduledTimers.removeValue(forKey: token)?.cancel() + } + /// Schedules `action` for immediate execution on the next event-loop turn. /// /// Wraps `setTimeout(action, 0)`. The action executes asynchronously — after the current @@ -226,16 +264,18 @@ public final class JSScheduler: Scheduler { /// - action: The closure to execute. public func schedule(options: SchedulerOptions?, _ action: @escaping () -> ()) { let token = nextToken() - scheduledTimers[token] = JSTimer(millisecondsDelay: 0) { [weak self] in + scheduledTimers[token] = clock.makeTimer(millisecondsDelay: 0, isRepeating: false) { + [weak self] in action() - self?.scheduledTimers[token] = nil + self?.removeTimer(token) } } /// 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. + /// The delay is computed as `date.millisecondsValue − now` (against the scheduler's clock + /// source) 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. @@ -249,11 +289,12 @@ public final class JSScheduler: Scheduler { _ action: @escaping () -> () ) { let token = nextToken() - scheduledTimers[token] = JSTimer( - millisecondsDelay: date.millisecondsValue - JSDate.now() + scheduledTimers[token] = clock.makeTimer( + millisecondsDelay: date.millisecondsValue - clock.now, + isRepeating: false ) { [weak self] in action() - self?.scheduledTimers[token] = nil + self?.removeTimer(token) } } @@ -284,15 +325,16 @@ public final class JSScheduler: Scheduler { // synchronization because both always run on the single-threaded JS event loop. var isCancelled = false - scheduledTimers[timeoutToken] = JSTimer( - millisecondsDelay: date.millisecondsValue - JSDate.now() + scheduledTimers[timeoutToken] = clock.makeTimer( + millisecondsDelay: date.millisecondsValue - clock.now, + isRepeating: false ) { [weak self] in guard let self = self else { return } - self.scheduledTimers[timeoutToken] = nil + self.removeTimer(timeoutToken) // 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( + self.scheduledTimers[intervalToken] = self.clock.makeTimer( millisecondsDelay: interval.magnitude, isRepeating: true ) { action() } @@ -303,8 +345,8 @@ public final class JSScheduler: Scheduler { // repeated cancellation is a no-op (removing absent keys has no effect). return CancellableTimer { isCancelled = true - self.scheduledTimers[timeoutToken] = nil - self.scheduledTimers[intervalToken] = nil + self.removeTimer(timeoutToken) + self.removeTimer(intervalToken) } } } diff --git a/Tests/OpenCombineJSTests/JSSchedulerVirtualClockTests.swift b/Tests/OpenCombineJSTests/JSSchedulerVirtualClockTests.swift new file mode 100644 index 0000000..1425714 --- /dev/null +++ b/Tests/OpenCombineJSTests/JSSchedulerVirtualClockTests.swift @@ -0,0 +1,315 @@ +// 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. + +// Deterministic JSScheduler tests driven by VirtualClock (issue #14). These are exact-value +// variants of the timing-sensitive wasm suites in JSSchedulerTests.swift, which remain as +// real-timer integration coverage. No JavaScript runtime is involved, so every test in this +// file runs on the host (`swift test`) as well as on wasm — only the async-bridge suite at +// the bottom is wasm-gated, because awaiting requires the JS event-loop executor. + +@testable import OpenCombineJS +import Testing + +// Dual Combine backend — see Sources/OpenCombineJS/JSPromise.swift (issue #11). +#if canImport(Combine) +import Combine +#else +import OpenCombine +#endif + +// MARK: - Clock injection and immediate scheduling + +struct JSSchedulerVirtualClockTests { + /// VC-01 — `now` is read through the injected clock source + @Test("now reflects the injected clock and follows advance(by:)") + func nowReflectsInjectedClock() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + #expect(scheduler.now.millisecondsValue == 0) + clock.advance(by: 1234) + #expect(scheduler.now.millisecondsValue == 1234) + } + + /// VC-02 — deterministic variant of SC-03/SC-04: asynchronous dispatch + cleanup (issue #4) + @Test("schedule(options:_:) defers the action until the clock advances, then cleans up") + func immediateScheduleDefersUntilAdvance() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var didRun = false + scheduler.schedule(options: nil) { didRun = true } + #expect(!didRun, "schedule(options:_:) must not execute the action synchronously") + #expect(scheduler.scheduledTimers.count == 1) + clock.advance(by: 0) // the next event-loop turn, virtualized + #expect(didRun) + #expect(scheduler.scheduledTimers.isEmpty, "fired one-shot timers must not leak (issue #4)") + #expect(clock.pendingTimerCount == 0) + } + + /// VC-03 — deterministic variant of SC-13: independent schedules, FIFO for equal due times + @Test("multiple schedule(options:_:) calls fire independently in submission order") + func immediateSchedulesFireInSubmissionOrder() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var order = [Int]() + for index in 1...3 { + scheduler.schedule(options: nil) { order.append(index) } + } + clock.advance(by: 0) + #expect(order == [1, 2, 3]) + #expect(scheduler.scheduledTimers.isEmpty) + } +} + +// MARK: - One-shot scheduling + +struct JSSchedulerVirtualClockOneShotTests { + /// VC-04 — deterministic variant of SC-05: exact due time, exactly once, cleanup (issue #4) + @Test("schedule(after:) fires exactly at the due time, exactly once, and cleans up") + func oneShotFiresExactlyAtDueTime() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var fireCount = 0 + scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(50)), + tolerance: .milliseconds(5), + options: nil + ) { + fireCount += 1 + } + clock.advance(by: 49) + #expect(fireCount == 0, "timer must not fire before its due time") + clock.advance(by: 1) + #expect(fireCount == 1) + clock.advance(by: 500) // an erroneously repeating timer would fire again here + #expect(fireCount == 1, "one-shot timer must fire exactly once") + #expect(scheduler.scheduledTimers.isEmpty, "fired one-shot timers must not leak (issue #4)") + #expect(clock.pendingTimerCount == 0) + } + + /// VC-05 — deterministic variant of SC-07: past dates clamp to zero delay + @Test("schedule(after:) with a past date fires on the next clock advance") + func oneShotPastDateFiresImmediately() { + let clock = VirtualClock() + clock.advance(by: 100) + let scheduler = JSScheduler(clock: clock) + var didFire = false + scheduler.schedule( + after: JSScheduler.SchedulerTimeType(millisecondsValue: 0), // 100 ms in the past + tolerance: .milliseconds(0), + options: nil + ) { + didFire = true + } + #expect(!didFire) + clock.advance(by: 0) + #expect(didFire, "negative delays must clamp to 0 (JS timer semantics)") + } + + /// VC-06 — due-time ordering is independent of submission order + @Test("one-shot timers fire in due-time order regardless of scheduling order") + func oneShotsFireInDueTimeOrder() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var order = [String]() + scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(30)), + tolerance: .milliseconds(0), + options: nil + ) { + order.append("later") + } + scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(10)), + tolerance: .milliseconds(0), + options: nil + ) { + order.append("sooner") + } + clock.advance(by: 30) + #expect(order == ["sooner", "later"]) + } +} + +// MARK: - Repeating scheduling + +struct JSSchedulerVirtualClockRepeatingTests { + /// VC-07 — deterministic variant of SC-08 (issue #5): exact first-fire time and cadence. + /// The wasm test asserts a [interval−20 ms, interval+200 ms] jitter band; the virtual clock + /// pins both the first fire (date + interval) and every subsequent tick to exact values. + @Test("repeating schedule first fires at date + interval, then exactly once per interval") + func repeatingScheduleHasExactCadence() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var fireTimes = [Double]() + let cancellable = scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(40)), + interval: .milliseconds(20), + tolerance: .milliseconds(10), + options: nil + ) { + fireTimes.append(clock.now) + } + clock.advance(by: 40) // reaches the start date: interval armed, no action fire yet + #expect(fireTimes.isEmpty, "first invocation fires one interval after the start date") + #expect( + scheduler.scheduledTimers.count == 1, + "timeout token must be replaced by the interval token" + ) + clock.advance(by: 19) + #expect(fireTimes.isEmpty) + clock.advance(by: 1) + #expect(fireTimes == [60]) + clock.advance(by: 60) // three full intervals + #expect(fireTimes == [60, 80, 100, 120], "exactly one fire per interval (issue #5)") + cancellable.cancel() + #expect(scheduler.scheduledTimers.isEmpty) + #expect(clock.pendingTimerCount == 0) + } + + /// VC-08 — deterministic variant of SC-11 (issue #3): cancel before the first fire + @Test("cancel() before the first fire clears both tokens and prevents any fire") + func cancelBeforeFirstFirePreventsEverything() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var fireCount = 0 + let cancellable = scheduler.schedule( + after: scheduler.now.advanced(by: .milliseconds(40)), + interval: .milliseconds(20), + tolerance: .milliseconds(5), + options: nil + ) { + fireCount += 1 + } + cancellable.cancel() + #expect(scheduler.scheduledTimers.isEmpty, "cancel() must clear the pending timeout (issue #3)") + #expect(clock.pendingTimerCount == 0, "cancellation must reach the clock source") + clock.advance(by: 500) + #expect(fireCount == 0, "no action may fire after cancel() (issue #3)") + } + + /// VC-09 — deterministic variant of SC-09 (issue #4): cancel between repeats + @Test("cancel() between repeats freezes the fire count and cleans up") + func cancelBetweenRepeatsStopsDelivery() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var fireCount = 0 + let cancellable = scheduler.schedule( + after: scheduler.now, + interval: .milliseconds(20), + tolerance: .milliseconds(5), + options: nil + ) { + fireCount += 1 + } + clock.advance(by: 40) // start (t=0) + two intervals + #expect(fireCount == 2) + cancellable.cancel() + #expect(scheduler.scheduledTimers.isEmpty, "cancel() must remove the interval token (issue #4)") + #expect(clock.pendingTimerCount == 0) + clock.advance(by: 500) + #expect(fireCount == 2, "interval must stop delivering after cancel()") + } + + /// VC-10 — deterministic variant of SC-12: double cancel is a no-op + @Test("calling cancel() twice is a harmless no-op") + func doubleCancelIsNoOp() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + 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() + clock.advance(by: 200) + #expect(fireCount == 0) + #expect(scheduler.scheduledTimers.isEmpty) + #expect(clock.pendingTimerCount == 0) + } + + /// VC-11 — deterministic variant of SC-10: the scheduler stays usable after cancellation + @Test("scheduler continues to function after cancel()") + func schedulerHealthyAfterCancel() { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + let cancellable = scheduler.schedule( + after: scheduler.now, + interval: .milliseconds(50), + tolerance: .milliseconds(10), + options: nil + ) {} + cancellable.cancel() + var didRun = false + scheduler.schedule(options: nil) { didRun = true } + clock.advance(by: 0) + #expect(didRun) + } +} + +// MARK: - Async bridge over the injected clock (wasm-only: awaiting needs the JS executor) + +#if os(WASI) +import JavaScriptKit + +struct JSSchedulerVirtualClockAsyncTests { + /// VC-A1 — `sleep(for:)` registers with the injected clock and resumes when it advances. + /// The real 0 ms `JSTimer` only drives the event loop; all *scheduling* time is virtual: + /// its callback cannot run before `sleep` suspends, because the JS event loop only regains + /// control at the suspension point. + @Test("sleep(for:) suspends until the injected clock advances past the deadline") + func sleepIsDrivenByInjectedClock() async { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + var didAdvance = false + let driver = JSTimer(millisecondsDelay: 0) { + #expect(clock.pendingTimerCount == 1, "sleep must register its one-shot with the clock") + clock.advance(by: 50) + didAdvance = true + } + await scheduler.sleep(for: .milliseconds(50)) + withExtendedLifetime(driver) {} + #expect(didAdvance, "the continuation must be resumed by the virtual timer") + #expect(clock.now == 50) + #expect(scheduler.scheduledTimers.isEmpty, "sleep must not leak its one-shot entry") + #expect(clock.pendingTimerCount == 0) + } + + /// VC-A2 — `timer(interval:)` ticks are produced by the injected clock; breaking out of the + /// loop cancels all the way down to the clock source. Each real event-loop turn advances + /// virtual time by exactly one interval, producing exactly one tick per turn. + @Test("timer(interval:) ticks follow the injected clock and break cancels the virtual timer") + func asyncTimerIsDrivenByInjectedClock() async { + let clock = VirtualClock() + let scheduler = JSScheduler(clock: clock) + let driver = JSTimer(millisecondsDelay: 1, isRepeating: true) { + clock.advance(by: 10) + } + var ticks = 0 + for await _ in scheduler.timer(interval: .milliseconds(10)) { + ticks += 1 + if ticks == 3 { break } + } + withExtendedLifetime(driver) {} + #expect(ticks == 3) + #expect(clock.now == 30, "three ticks must consume exactly three virtual intervals") + #expect(scheduler.scheduledTimers.isEmpty, "stream termination must release both tokens") + #expect(clock.pendingTimerCount == 0, "cancellation must reach the injected clock") + } +} +#endif diff --git a/Tests/OpenCombineJSTests/VirtualClock.swift b/Tests/OpenCombineJSTests/VirtualClock.swift new file mode 100644 index 0000000..0f51a8b --- /dev/null +++ b/Tests/OpenCombineJSTests/VirtualClock.swift @@ -0,0 +1,116 @@ +// 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. + +// Deterministic, manually advanced JSClockSource (06-test-data-strategy.md §3.2 Option A / +// §3.6, implemented by issue #14). Pure Swift — no JavaScript runtime required, so tests +// built on it run on the host (`swift test`) as well as on wasm. + +import OpenCombineJS + +/// A virtual clock for deterministic scheduler tests. +/// +/// Time starts at `0` ms and only moves when `advance(by:)` is called. Timers fire +/// synchronously inside `advance(by:)`, ordered by due time and — for equal due times — by +/// creation order, mirroring the JavaScript timer queue. Negative delays clamp to `0` +/// (JS `setTimeout` semantics); repeating intervals clamp to a minimum of 1 ms so a +/// zero-interval repeating timer cannot loop `advance(by:)` forever (browsers apply a +/// similar minimum clamp to nested `setInterval`). +final class VirtualClock: JSClockSource { + private struct PendingTimer { + let id: UInt64 + var dueTime: Double + /// `nil` for one-shot timers; the repetition period otherwise. + let interval: Double? + let callback: () -> () + } + + /// Current virtual time in milliseconds. Starts at `0`. + private(set) var now: Double = 0 + + /// Number of timers currently registered (pending one-shots + live repeating timers). + /// Lets tests assert that cancellation reached the clock, not just the scheduler's table. + var pendingTimerCount: Int { + pending.count + } + + private var pending = [UInt64: PendingTimer]() + private var nextID: UInt64 = 0 + + func makeTimer( + millisecondsDelay: Double, + isRepeating: Bool, + callback: @escaping () -> () + ) -> any JSClockCancellable { + let id = nextID + nextID += 1 + let delay = max(0, millisecondsDelay) // JS timers clamp negative delays to 0. + pending[id] = PendingTimer( + id: id, + dueTime: now + delay, + interval: isRepeating ? max(1, delay) : nil, + callback: callback + ) + return Token(clock: self, id: id) + } + + /// Advances virtual time by `milliseconds`, firing every timer that becomes due — in due-time + /// order, ties broken by creation order — including timers scheduled or cancelled by the fired + /// callbacks themselves (a callback-scheduled timer due inside the window fires in the same + /// `advance`). Repeating timers fire once per elapsed period. Afterwards `now` equals the old + /// `now` plus `milliseconds`, even if no timer was due. + func advance(by milliseconds: Double) { + precondition(milliseconds >= 0, "VirtualClock cannot move backwards") + let target = now + milliseconds + while let next = pending.values + .filter({ $0.dueTime <= target }) + .min(by: { ($0.dueTime, $0.id) < ($1.dueTime, $1.id) }) + { + now = max(now, next.dueTime) + // Reschedule/remove BEFORE invoking the callback so the callback observes the same + // registration state a JS timer callback would (and may cancel its own repetition). + if let interval = next.interval { + pending[next.id]?.dueTime = next.dueTime + interval + } else { + pending[next.id] = nil + } + next.callback() + } + now = target + } + + private func cancelTimer(id: UInt64) { + pending[id] = nil + } + + /// Cancellation token. Explicit `cancel()` and deinitialization both unregister the timer, + /// mirroring `JSTimer`'s release-to-clear reference semantics. + private final class Token: JSClockCancellable { + private weak var clock: VirtualClock? + private let id: UInt64 + + init(clock: VirtualClock, id: UInt64) { + self.clock = clock + self.id = id + } + + deinit { + cancel() + } + + func cancel() { + clock?.cancelTimer(id: id) + clock = nil + } + } +}