Skip to content

[AIT-846] Time mocking and Universal Test Suite ground work#2210

Draft
maratal wants to merge 6 commits into
mainfrom
AIT-846-mock-for-time
Draft

[AIT-846] Time mocking and Universal Test Suite ground work#2210
maratal wants to merge 6 commits into
mainfrom
AIT-846-mock-for-time

Conversation

@maratal
Copy link
Copy Markdown
Collaborator

@maratal maratal commented Jun 4, 2026

Supersedes #2209 (rebased onto a clean base with the plugin-support dependency change dropped).

What's here

  1. Mock for time — squash of the ARTTimeProvider groundwork: a unified abstraction over the wall clock, the continuous clock, and scheduling, injected via ARTClientOptions.testOptions, with ARTSystemTimeProvider as the default. All internal wall-clock reads, scheduler calls, and the event-emitter/auth/log/encoder timestamps are routed through it so the Universal Test Suite can install a fake clock.

  2. Add Universal Test Suite (UTS) target — a standalone XCTest suite (Test/UTS) derived from the language-neutral specs in ably/specification (uts/). Mocked WebSocket (real ARTWebSocketTransport over a faked ARTWebSocket), mocked HTTP, a deterministic FakeTimeProvider, and UTSTestCase harness. Tests: ConnectionRecoveryTests (RTN16) and TimeTests (RSC16).

  3. Add /uts-to-swift translation skill — a Claude Code skill that translates a UTS pseudocode spec into a Swift test against this harness (the cocoa equivalent of ably-java's uts-to-kotlin).

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 4, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bb9adf65-0a2d-4471-8d46-9771b821f190

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch AIT-846-mock-for-time

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot temporarily deployed to staging/pull/2210/features June 4, 2026 21:09 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/jazzydoc June 4, 2026 21:11 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/markdown-api-reference June 4, 2026 21:11 Inactive
@maratal maratal force-pushed the AIT-846-mock-for-time branch from 04deb80 to 0713756 Compare June 4, 2026 21:40
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/features June 4, 2026 21:41 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/jazzydoc June 4, 2026 21:44 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/markdown-api-reference June 4, 2026 21:44 Inactive
maratal and others added 3 commits June 5, 2026 00:07
Squash of the ARTTimeProvider groundwork: a unified abstraction over the
wall clock, the continuous clock, and scheduling, injected via
ARTClientOptions.testOptions, with ARTSystemTimeProvider as the default.
All internal wall-clock reads, scheduler calls, and the
event-emitter/auth/log/encoder timestamps are routed through it so the
Universal Test Suite can install a fake clock.

The continuous-clock instant and the scheduler handle are expressed as
two small boundary protocols defined locally in ably-cocoa:
ARTContinuousClockInstantProtocol and ARTSchedulerHandle. Defining them
here, rather than depending on an as-yet-unreleased version of
ably-cocoa-plugin-support, keeps Package.swift at from: "2.0.0" and means
ARTTimeProvider.h, ARTContinuousClock.h, and ARTGCD.h no longer @import
_AblyPluginSupportPrivate, which also unblocks the non-SPM (CocoaPods /
Xcode-framework) builds.

ARTPluginAPI's three time forwarders are retained; they are inert until a
plugin-support release exposes the matching protocol methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Harness (Test/UTS/Harness):

- MockWebSocket: a real ARTWebSocketTransport over a faked ARTWebSocket, so
  URL/query-param building stays real; the provider drives the simulated
  server (respondWithSuccess/sendToClient/simulateDisconnect).
- MockHTTP: fake ARTHTTPExecutor to intercept/observe requests and respond.
- FakeTimeProvider: deterministic clock, opt-in via enableFakeTimers().
- UTSTestCase: installMock / makeRealtime / makeRest, AWAIT_STATE polling,
  CapturingLog, NoOpReachability.

Tests: ConnectionRecoveryTests (RTN16g/g1, g2, f, f1, j, k) and TimeTests
(RSC16, 0-4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cocoa equivalent of ably-java's .claude/skills/uts-to-kotlin/SKILL.md
(PR #1209). Invocable as `/uts-to-swift <spec-file>`, it walks the
translation from a UTS pseudocode spec to a Swift test in the UTS
target, mapped onto this repo's harness: installMock + makeRealtime/
makeRest, MockWebSocketProvider/MockWebSocket, MockHTTP, the
ARTProtocolMessage +UTS factories, awaitConnectionState/awaitChannelState/
poll, and enableFakeTimers/advanceTime.

Encodes the conventions established for this suite: the local-array
capture pattern (vs. a mock property), seconds-not-wire-milliseconds for
connectionStateTtl/maxIdleInterval, CapturingLog for log assertions, the
test_<SPEC>_<desc> + `// UTS:` naming, RUN_DEVIATIONS env-gated skips, and
the distinction between an SDK deviation (deviations.md) and a
harness-driving choice (code comment). Requires that spec comments are
copied verbatim and every ASSERT is either translated or annotated when
it has no Swift equivalent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maratal maratal force-pushed the AIT-846-mock-for-time branch from 0713756 to 1d0e282 Compare June 4, 2026 22:07
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/features June 4, 2026 22:08 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/jazzydoc June 4, 2026 22:11 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/markdown-api-reference June 4, 2026 22:11 Inactive
Convert the UTS target from XCTest to Swift Testing (`import Testing`):

- UTSTestCase becomes a plain base class (no longer XCTestCase); suites are
  `@Suite(.serialized) final class ...: UTSTestCase`. Swift Testing creates a
  fresh instance per `@Test`, so teardown moves from `tearDown()` to `deinit`.
- Assertions: XCTAssert* -> #expect, XCTUnwrap -> #require, XCTFail ->
  Issue.record (with SourceLocation), file/line params -> #_sourceLocation.
- TimeTests' async REST calls drop XCTestExpectation for `async` tests that
  bridge the completion handler via withCheckedContinuation.

Also updates the /uts-to-swift skill, README, and deviations.md to document
the Swift Testing conventions (#expect/#require, @Test/@suite, the
.enabled(if:) env-gated deviation skip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/features June 4, 2026 22:26 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/jazzydoc June 4, 2026 22:28 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/markdown-api-reference June 4, 2026 22:29 Inactive
@maratal
Copy link
Copy Markdown
Collaborator Author

maratal commented Jun 4, 2026

WebSocket mock — conformance against mock_websocket.md

Assessment of Test/UTS/Harness/MockWebSocket.swift against the UTS mock-WebSocket spec.

Architecture mapping

The spec describes one MockWebSocket (test handle) + a PendingConnection per attempt. We split this into:

Spec Ours
mock_ws (test handle) MockWebSocketProvider
mock_ws.active_connection / PendingConnection MockWebSocket (per-connection, conforms to ARTWebSocket)
install_mock testOptions.transportFactory = MockWebSocketTransportFactory

Clean mapping, and it exceeds the spec in one respect: we drive a real ARTWebSocketTransport, so URL/query-param construction (recover, resume, format) is exercised by production code, not faked.

What conforms ✅

  • Message injection: send_to_clientsendToClient, send_to_client_and_closesendToClientAndClose, simulate_disconnectsimulateDisconnect.
  • Connection outcomes: respond_with_successrespondWithSuccess(), respond_with_refusedrespondWithRefused().
  • Capturing client frames: spec's MESSAGE_FROM_CLIENTsentMessages (decoded, ordered).
  • Connection-closing semantics table: DISCONNECTED/connection-ERROR → sendToClientAndClose, channel-ERROR/CONNECTED/HEARTBEAT/ACK → sendToClient, transport failure → simulateDisconnect — matches the spec table exactly.
  • respondWithSuccess() ordering (spec §"respondWithSuccess() Ordering"): connection-open is delivered before the CONNECTED message because both go through the transport's serial delegateDispatchQueue in enqueue order.
  • Test isolation, fake timers, "no arbitrary real-time delays": fresh provider per @Test + deinit cleanup; FakeTimeProvider/advanceTime; awaitConnectionState is an AWAIT_STATE poll, not a fixed delay.
  • active_connectionprovider.activeConnection.

Intentional divergences (functionally equivalent) 🟡

  • respond_with_success(connected_message) is one call in the spec; we split it into respondWithSuccess() + sendToClient(.connected(...)). Ordering still holds via the serial queue.
  • No events list / await_* futures for the common case — we use the spec's other sanctioned patterns: onConnectionAttempt handler + local capture arrays, and sentMessages for client frames.
  • respond_with_error(msg, then_close) — no named method, but covered by sendToClient(.error(...)) (then_close=false) / sendToClientAndClose(.error(...)) (then_close=true).

Gaps / not yet implemented ❌ (none block the current 6 tests)

Material (behavioral):

  1. close() is a silent no-op (MockWebSocket.swift:104) — it only sets readyState. The spec (§"Mock close() Must Be Asynchronous" + "Client-Initiated Close") requires it to asynchronously fire onClose and record a CLIENT_CLOSE event. Current tests drive close via a server CLOSED (sendToClientAndClose), so they pass — but client-initiated-close scenarios (heartbeat-timeout, fatal-error, explicit close with no server reply) can't be tested, and await_client_close() / CLIENT_CLOSE don't exist.

Capability gaps:
2. No events unified timeline (MockEvent/MockEventType) → the spec's CONTAINS_IN_ORDER state/event-sequence verification isn't supported.
3. await_next_message_from_client / await_connection_attempt / await_client_close — the "await pattern" for advanced sequential coordination (inspect-then-decide). We only have the handler+counter pattern.
4. send_ping_frame() (RTN23b) — missing, and likely needs an ARTWebSocket delegate hook; SocketRocket may not surface pings, so this may be partly platform-N/A (to confirm).
5. WS-level respond_with_timeout() / respond_with_dns_error() — present on MockHTTP but not on MockWebSocket (we only have respondWithRefused). Needed for RTN17/RTN14 network-failure tests.
6. Raw-frame hooks onTextDataFrame / onBinaryDataFrame (and onMessageFromClient) — we decode straight to sentMessages, so wire-encoding assertions ("null fields omitted") aren't possible.
7. simulate_disconnect(error?) ignores the optional ErrorInfo; reset() absent (we use fresh instances); PendingConnection.protocol/timestamp not exposed.
8. Protocol templates: we have .connected/.attached/.error/.ack/.closed; missing .disconnected and .heartbeat factories.

Bottom line

For the RTN16 connection-recovery surface implemented here, the mock is faithful — closing semantics, success-ordering, isolation, and fake-timer rules all match. The gaps are all in areas no current test exercises. Highest-value items to close next, in order: (1) async close() + CLIENT_CLOSE/await_client_close (unlocks close/heartbeat tests), (5) WS timeout/DNS (network-failure tests), then (2) the events timeline (sequence assertions).

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR lays the groundwork for running Ably’s Universal Test Suite (UTS) against ably-cocoa by introducing an injectable ARTTimeProvider abstraction (covering wall clock, continuous clock, and scheduling) and by adding a new standalone UTS Swift Testing target with a mock transport/HTTP/time harness and initial derived tests.

Changes:

  • Introduce ARTTimeProvider + default ARTSystemTimeProvider, and route internal timestamps/timers/scheduling through it.
  • Add a new UTS Swift Testing target (Test/UTS) with a deterministic fake clock, mocked WebSocket/HTTP, and initial RTN16/RSC16 derived tests.
  • Update internal/private headers, module maps, and a few existing tests to align with the new time/scheduler APIs.

Reviewed changes

Copilot reviewed 48 out of 48 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
Test/UTS/Tests/TimeTests.swift Adds RSC16 time() derived tests using mocked HTTP.
Test/UTS/Tests/ConnectionRecoveryTests.swift Adds RTN16 recovery derived tests using mocked WebSocket and fake timers where needed.
Test/UTS/README.md Documents UTS target layout, harness primitives, and running instructions.
Test/UTS/Harness/UTSTestCase.swift Provides shared harness utilities: installMock/makeRealtime/makeRest, polling, fake-timer control, cleanup.
Test/UTS/Harness/NoOpReachability.swift Disables OS reachability monitoring during unit tests.
Test/UTS/Harness/MockWebSocket.swift Implements a fake ARTWebSocket + provider/factory to exercise real transport URL/query construction.
Test/UTS/Harness/MockHTTP.swift Implements a fake ARTHTTPExecutor to intercept requests and inject responses without network I/O.
Test/UTS/Harness/FakeTimeProvider.swift Implements deterministic ARTTimeProvider for controllable clocks and scheduling, plus timer leak safety net.
Test/UTS/Harness/CapturingLog.swift Adds an ARTLog subclass to capture log output for assertions.
Test/UTS/Harness/ARTProtocolMessage+UTS.swift Adds protocol-message factory helpers for UTS tests.
Test/UTS/deviations.md Adds a template for documenting spec deviations and env-gated deviation tests.
Test/AblyTests/Tests/UtilitiesTests.swift Updates ARTInternalEventEmitter init sites to pass a time provider.
Test/AblyTests/Tests/GCDTests.swift Updates scheduled-block handle construction to the new ARTScheduledBlockHandle API.
Test/AblyTests/Tests/ContinuousClockTests.swift Removes continuous clock tests that depended on the removed ARTContinuousClock class API.
Test/Ably.xctestplan Adds the UTS target to the Xcode test plan.
Source/PrivateHeaders/Ably/ARTTimeProvider.h Introduces the unified time abstraction protocol used internally.
Source/PrivateHeaders/Ably/ARTTestClientOptions.h Adds timeProvider to test options for injection.
Source/PrivateHeaders/Ably/ARTSystemTimeProvider.h Declares the default system-backed time provider.
Source/PrivateHeaders/Ably/ARTSchedulerHandle.h Introduces a cancellable scheduler handle protocol.
Source/PrivateHeaders/Ably/ARTRest+Private.h Exposes timeProvider and updates fallback expiration to protocol-based continuous instants.
Source/PrivateHeaders/Ably/ARTRealtime+Private.h Exposes timeProvider from realtime internals for plugins and internal use.
Source/PrivateHeaders/Ably/ARTLog+Private.h Adds injectable time provider for log timestamping.
Source/PrivateHeaders/Ably/ARTGCD.h Updates scheduled-block handle to conform to ARTSchedulerHandle; removes old helper functions.
Source/PrivateHeaders/Ably/ARTEventEmitter+Private.h Threads timeProvider through event emitter internals and updates initializers accordingly.
Source/PrivateHeaders/Ably/ARTContinuousClockInstantProtocol.h Introduces protocol for continuous-clock instants (type-erased across time providers).
Source/PrivateHeaders/Ably/ARTContinuousClock.h Refactors to expose only the ARTContinuousClockInstant concrete instant type conforming to the protocol.
Source/include/module.modulemap Exposes new private headers via the module map.
Source/ARTWebSocketTransport.m Injects timeProvider into the transport state emitter.
Source/ARTTestClientOptions.m Initializes and copies the default ARTSystemTimeProvider in test options.
Source/ARTSystemTimeProvider.m Implements the system-backed wall clock, continuous clock, and scheduler.
Source/ARTRest.m Replaces direct continuous-clock usage with injected timeProvider.
Source/ARTRealtimePresence.m Routes presence timestamps and event emitters through injected timeProvider.
Source/ARTRealtimeChannel.m Passes timeProvider into internal channel event emitters.
Source/ARTRealtimeAnnotations.m Passes timeProvider into annotations event emitter.
Source/ARTRealtime.m Replaces [NSDate date]/dispatch scheduling with timeProvider wall clock + scheduler handles.
Source/ARTPluginAPI.m Exposes time primitives/scheduling to plugins through the plugin API.
Source/ARTLog.m Uses injected time provider for log line timestamps (defaulting to system provider).
Source/ARTJsonLikeEncoder.m Uses injected wall clock for token-request timestamps instead of [NSDate date].
Source/ARTGCD.m Removes the old artDispatchScheduled helper implementation.
Source/ARTEventEmitter.m Replaces dispatch scheduling with timeProvider scheduling; threads provider through emitter initializers.
Source/ARTContinuousClock.m Refactors continuous instant comparison/addition to protocol-based API.
Source/ARTAuth.m Uses injected wall clock for auth’s notion of current time and for cancellation event emitter scheduling.
Source/Ably.modulemap Exposes new private headers via the framework module map.
Package.swift Adds the UTS SwiftPM test target.
CONTRIBUTING.md Documents the new “Time-related operations” convention using ARTTimeProvider.
Ably.xcodeproj/project.pbxproj Adds new headers/sources to Xcode project and header build phases.
.swiftpm/xcode/xcshareddata/xcschemes/ably-cocoa.xcscheme Adds UTS to the SwiftPM-generated Xcode scheme test action.
.claude/skills/uts-to-swift/SKILL.md Adds a Claude Code skill spec for translating UTS pseudocode specs into Swift tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Source/ARTContinuousClock.m
Comment thread CONTRIBUTING.md Outdated
Comment thread Package.swift
Comment thread Test/UTS/Harness/MockHTTP.swift Outdated
Comment thread Test/UTS/README.md Outdated
Addresses review feedback (PR #2209): the local captured-array pattern
(`var captured = []` appended from a mock handler) is a data race the
Swift 6 compiler rejects, and the suite should use the latest language
mode so such races are caught.

- Package.swift: build the UTS target with `-swift-version 6`.
- Import Ably / Ably.Private `@preconcurrency` in the harness, since the
  Objective-C SDK isn't Sendable-audited; this keeps strict checking for
  our own code while treating SDK interop as warnings.
- Make the mocks Sendable with their existing internal synchronisation:
  MockWebSocketProvider / MockWebSocket / MockHTTP / PendingHTTP* /
  FakeTimeProvider are `@unchecked Sendable`, and their handler closures
  are `@Sendable`.
- Add `Captured<T>`, a lock-guarded, Sendable collector for the spec's
  local `captured_*` arrays; tests use it instead of a mutable `var`
  array (which now fails to compile inside a `@Sendable` handler). The
  "first attempt vs later" branches switch on `Captured.count` rather
  than a separate mutable counter.
- Update the /uts-to-swift skill and README accordingly.

All 11 tests pass; the suite builds warning-clean under Swift 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/features June 4, 2026 23:10 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/jazzydoc June 4, 2026 23:12 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/2210/markdown-api-reference June 4, 2026 23:12 Inactive
- ARTContinuousClock.m: -isAfter: now guards the downcast of the
  id<ARTContinuousClockInstantProtocol> argument and raises a clear
  exception on a type mismatch, instead of force-casting and risking an
  invalid memory read.
- CONTRIBUTING.md: the "Time-related operations" bullets referenced the
  old APContinuousClockInstant / APSchedulerHandle names; point them at
  the actual ARTContinuousClockInstantProtocol / ARTSchedulerHandle.
- Package.swift: the UTS target comment said "XCTest suite"; it's a Swift
  Testing suite.
- MockHTTP: execute(_:completion:) now fails fast (assertion + error
  callback) when no onRequest handler is installed, rather than leaving
  the request (and any awaiting test) hanging with the callback uncalled.

The README layout drift Copilot also flagged was already corrected in the
preceding commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maratal
Copy link
Copy Markdown
Collaborator Author

maratal commented Jun 4, 2026

Could awaitConnectionState / awaitChannelState use await + withCheckedContinuation?

Short answer: feasible, but on balance I'd keep the poll. Analysis below.

ARTConnection / ARTRealtimeChannel embed the event emitter (on(_:) / once(_:) / off(_:) returning ARTEventListener), so we can resume a continuation from a state-change listener:

func awaitConnectionState(_ client: ARTRealtime, _ expected: ARTRealtimeConnectionState, ...) async {
    let connection = client.connection
    await withCheckedContinuation { cont in
        let listener = connection.on { change in if change.current == expected { finishOnce() } }
        // register FIRST, then read state — so no transition can slip through the gap
        if connection.state == expected { finishOnce() }   // finishOnce: off(listener) + resume, guarded
    }
}

Three wrinkles

  1. No built-in timeout. withCheckedContinuation never times out. Today poll records an issue and returns after 2 s; an event-driven version that misses its event would hang the whole suite (Swift Testing's .timeLimit trait floors at 1 minute). So you must race the listener against a Task.sleep timeout and resume-on-timeout — which is the bulk of the added code, and itself reintroduces a real-time timer.

  2. Already-in-state race. The listener only fires on future changes, while connect to CONNECTED is processed async on the SDK queues. You must register the listener before reading connection.state (the state getter is a barrier on the internal queue, so a read enqueued after the on(...) can't miss a transition the listener would catch). Solvable, but easy to get backwards.

  3. Fake-timer interaction (the important one). With enableFakeTimers(), transitions only happen when the test calls advanceTime(...) synchronously. The current tests already advanceTime before awaitConnectionState, so by the time we await, the state is already set, the fast-path check returns immediately, and the listener never suspends. So the await cannot be relied on to drive fake-timer transitions — it works only because of the synchronous pre-advance, meaning the async version wouldn't actually buy determinism here.

Plus: resume-exactly-once + off(listener) cleanup, and under the suite's Swift 6 strict-concurrency mode the listener closure needs a Sendable, lock-guarded box.

Recommendation

The poll isn't a fixed real-time delay (the spec's anti-pattern) — it's an AWAIT_STATE condition poll at 0.5 ms granularity, bounded by real async queue processing (microseconds); the whole 11-test suite runs in ~15 ms. The event-driven version is more idiomatic and frees the thread, but the timeout + once-resume + ordering logic roughly triples the helper's size and adds flakiness surface, for no measurable speed or determinism gain given how the fake-timer tests are structured.

Feasible and marginally nicer, but not worth the complexity right now. If preferred, a shared await(timeout:until:) continuation helper (listener + raced timeout + safe cleanup) could back both helpers — happy to do that if reviewers want it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants