| name | uts-to-swift |
| description | Translate the UTS pseudocode test specs in a whole module directory into runnable Swift tests in the ably-cocoa UTS test target. Takes a UTS module directory (e.g. <cloned-ably-specification-repo-path>/uts/objects), validates its structure, resolves the target test directories, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Swift test per spec. Usage: /uts-to-swift <path-to-uts-module-directory> |
| license | proprietary |
| allowed-tools | Bash, Read, Edit, Write, WebFetch |
| metadata | {"team":"engineering","version":"2.0.0","tags":"testing, uts, swift, cocoa, ably-cocoa, test-translation","marketplace":false} |
Translate the UTS pseudocode test specs under the module directory $ARGUMENTS into runnable Swift
tests in the ably-cocoa UTS test target (Test/UTS).
$ARGUMENTS is a UTS module directory — a directory sitting directly under the spec repo's uts/,
e.g. <cloned-ably-specification-repo-path>/uts/objects. Its name (objects, realtime,
rest, …) is the source module. A module directory holds many spec files, organised into tiers
(unit/, integration/, and integration/proxy/).
The work happens in two phases:
- Phase 1 — Selection (Steps A–E below): a bundled resolver script validates the directory and works
out the target
Test/UTS directories, the spec files, and their class names; you then pick a tier, pick
which specs, and choose whether to also evaluate.
- Phase 2 — Per-spec translation (Steps 1–7): for each selected spec file, derive a Swift test.
Required reading — fetch first
Always fetch writing-derived-tests.md first (once per run) — don't rely on memory or the inlined summaries; the manual is updated over time.
Phase 1 — Selection
Path validation, the directory mapping, spec discovery, and class-name derivation are all mechanical, so a
bundled script does them — that keeps selection byte-for-byte deterministic instead of relying on the model
to re-eyeball regexes, join paths, and hand-convert snake_case → PascalCase each run.
If $ARGUMENTS is empty or blank, stop and show: Usage: /uts-to-swift <path-to-uts-module-directory>
— with the example /uts-to-swift <cloned-ably-specification-repo-path>/uts/objects
(the path to a module directory inside a local clone of
ably/specification).
Step A — Resolve the module
Run the resolver on the directory passed in (substitute the real path; the script path is relative to the
ably-cocoa repo root):
python3 .claude/skills/uts-to-swift/scripts/resolve_uts.py "<module-dir>"
It prints one JSON object. If ok is false, relay message to the user and stop — error codes:
NOT_A_UTS_MODULE_PATH (not a .../uts/<module> directory), DIR_NOT_FOUND, NO_TIER_DIRS (no unit/
or integration/). On success it gives sourceModule, mapped, testRoot, translationNotes, and a
tiers object with one entry per tier (unit / integration / proxy), each carrying present,
sourceDir, targetDir, and specs (a list of {file, className}). Everything downstream reads from
this output — treat it as the single source of truth and don't recompute paths or names by hand.
translationNotes is the path to a per-module ably-js → ably-cocoa type/interface map when the module
declares one (its notes field in uts-package-mapping.json, e.g. objects →
references/objects-mapping.md), else null. When it's non-null, it is required reading before
Phase 2 — see Step 1. (The objects notes file is currently a placeholder — if it still only
carries the "intentionally empty" stub, tell the user the module's mapping hasn't been authored yet and
treat the module as not-yet-translatable rather than guessing a mapping.)
Step B — Confirm or create the target mapping
The target dirs come from uts-package-mapping.json (alongside this skill); spec and ably-cocoa module
names don't always match (ably-java, for example, maps objects → liveobjects), which is why it's
explicit.
-
If mapped is true: show the resolved targetDir for each present tier and ask the user to confirm.
If they say the mapping is wrong, ask for the correct ably-cocoa module base name and re-run with --create
(below) to overwrite the entry, then re-resolve.
-
If mapped is false: there's no mapping for sourceModule yet. Ask for the target ably-cocoa module
base name (default to sourceModule; suggest a rename only when the SDK uses different terminology), then
create it deterministically and re-resolve:
python3 .claude/skills/uts-to-swift/scripts/resolve_uts.py "<module-dir>" --create <target>
This adds unit/<target>, integration/standard/<target>, and integration/proxy/<target> under
packages and re-prints the resolved output. (<target> must be a simple module base name — letters,
digits, underscore; the script returns BAD_TARGET_NAME otherwise, so just ask again.)
Step C — Choose the tier
Offer the tiers whose present is true. The chosen tier fixes the targetDir and specs (from Step A)
and the translation flow Phase 2 uses — don't re-detect any of it per spec:
| Tier | Translation flow |
|---|
| unit | mocked transport — Steps 3–4 below |
| integration (direct sandbox) | real sandbox, no faults — Direct-sandbox integration tests section |
| proxy | real sandbox + fault injection — Integration tests section (proxy subsections) |
Step D — Choose which specs to translate
The chosen tier's specs list (from Step A) is the candidate set — each entry already has its source file
and derived className. Present it and ask whether to translate all of them or a selected subset.
Then continue to Step E.
Step E — Translate only, or also evaluate?
writing-derived-tests.md splits the work into Translation (always) and Evaluation (only
meaningful once the SDK implementation for this module exists). Ask the user which they want, and carry the
answer into Phase 2:
- Translate only — generate each test and make it compile (Steps 1–5 and the Step 7 review). Don't
run the tests. Use this when the SDK feature isn't implemented yet, so there's nothing to run against.
- Translate and evaluate — all of the above plus running the tests and fixing until they pass
(Step 6): work the decision tree, and where the SDK genuinely diverges, gate/adapt the assertion and
record a deviation. Use this when the implementation exists.
If you can't tell whether the implementation exists, ask the user rather than guessing.
Phase 2 — Per-spec translation
Run this for each spec file selected in Step D. Step 6 only applies in "translate and evaluate" mode
(Step E) — in "translate only" mode, stop after compiling (Step 5) and reviewing (Step 7), and skip
Step 6 entirely.
When translating several specs, do Steps 1–4 (generate the file) for every spec first, then run Step 5
(compile) once for the whole module, then per file run Step 6 (only if evaluating) and the Step 7 review —
compiling once is faster than per-file and surfaces cross-file issues together. For a single spec, just go
through the steps in order.
Step 1 — Read the spec (and any module translation notes)
If Step A reported a non-null translationNotes, read that file first (once per run). UTS specs are
written in a language-agnostic pseudocode that mirrors the ably-js API; for modules whose ably-cocoa
types diverge, the notes map each spec symbol to its ably-cocoa equivalent. Skipping them yields tests
that read like ably-js and won't compile. (If the notes file is still the "intentionally empty"
placeholder, stop and tell the user — see Step A.)
Then read the current spec file (the one being translated from the Step D selection). Identify:
- All test cases — each has a structured ID like
realtime/unit/RSA4c2/callback-error-connecting-disconnected-0 and a description
- The protocol used (WebSocket for Realtime, HTTP for REST)
- Any timer usage (
enable_fake_timers, ADVANCE_TIME)
- Any protocol-variant dimension — a
PROTOCOL (json / msgpack) matrix the spec header says to run "once per variant". In swift this becomes a useBinaryProtocol parameterised test — @Test(arguments: [false, true]) (see the Direct-sandbox integration tests section), not a plain @Test.
Step 2 — Output path
Don't derive anything here — the resolver (Step A) already produced it. For the chosen tier use its
targetDir, and for the spec being translated use its className from that tier's specs list. Write the
test to <targetDir>/<className>.swift.
The spec's own <sub> grouping (e.g. connection/, channels/) is not reflected in the output — every
test sits directly in targetDir (the resolver flattens it). The chosen tier also fixes the translation
flow: unit → the rules in Steps 3–4 below; integration (direct sandbox) → the Direct-sandbox
integration tests section; proxy → the proxy subsections of the Integration tests section.
Each test file is a Swift Testing suite whose base class matches the tier:
@Suite(.serialized) final class <className>: UTSTestCase (unit), … : IntegrationTestCase
(direct sandbox), or … : ProxyTestCase (proxy). If a suitable suite already exists, add the new test
methods to it rather than creating a duplicate.
Step 3 — Read infrastructure files
Orientation — read Test/UTS/README.md first. It's the human-readable guide to this target: the
tier model (unit / direct-sandbox / proxy, §2), the run commands (§10), the integration & proxy
infrastructure (§11), and a per-file API reference for every infra helper (§13). Skim it for the why
and the what's available; the per-file list below is the what to open for exact signatures before
writing code.
Infrastructure is split by tier under Test/UTS/infra/:
infra/Utils.swift — shared async helpers used by the integration tiers (awaitState,
awaitChannelState, pollUntil, parseQueryParams).
infra/unit/ — unit-test base class and mocks (UTSTestCase.swift with the makeRealtime/makeRest
factories and the synchronous awaitConnectionState/awaitChannelState/poll waits,
MockWebSocket.swift, MockHTTPClient.swift, MockTimeProvider.swift, ProtocolMessage.swift,
Captured.swift, CapturingLog.swift, NoOpReachability.swift).
infra/integration/ + infra/integration/proxy/ — direct-sandbox + proxy helpers (SandboxApp.swift,
IntegrationTestCase.swift, ProxyManager.swift, ProxySession.swift, ProxyTestCase.swift) — see
the Integration tests section.
For a unit test, read ALL files under infra/unit/ plus infra/Utils.swift before generating any code
(you need exact method signatures). For an integration or proxy spec, follow the reading list in
the Integration tests section instead.
Step 4 — Generate the Swift test file
Apply the translation rules below, then write the file.
Accessing SDK internals (import Ably.Private)
The SDK is Objective-C, so Swift access levels (internal/package/private) don't apply to it —
visibility is controlled by headers + the module map:
import Ably → the public API (headers in Source/include/Ably/).
import Ably.Private → the internal API: the private headers listed in the explicit module Private
block of Source/include/module.modulemap (files under Source/PrivateHeaders/Ably/, e.g.
ARTClientOptions+TestConfiguration.h for testOptions, ART*+Private.h for class internals). This is
how the UTS infra reaches the injection seams, and how a test reaches internal fields the spec asserts
on. See Test/UTS/README.md §4–§5.
When a spec needs an internal class/method/field, work down this list:
- Check it's already exposed:
grep -r "<symbol>" Source/PrivateHeaders/Ably/ — if it's declared in a
listed private header, just import Ably.Private and use it.
- Declared only in a
.m file (class extension, ivar, private method)? It is invisible to Swift,
period. To expose it, declare it in a header under Source/PrivateHeaders/Ably/ and register that
header in both module maps (Source/include/module.modulemap for SPM and Source/Ably.modulemap
for Xcode) — the repo's CLAUDE.md convention. Only do this for small, test-motivated exposure; mirror
how existing +Private.h headers are written.
- Truly private state with no reasonable seam (or exposing it would distort the SDK)? Don't hack
around it — keep the spec's line as a comment, note why no assertion is emitted (see "Comments and
assertion fidelity"), and record it in
deviations.md under Mock Infrastructure Limitations.
Client construction
Set ClientOptions fields (key, autoConnect, recover, disconnectedRetryTimeout, etc.) in the
makeRealtime/makeRest configuration block. Both factories already seed the dummy key
appId.keyId:keySecret (matching ably-java's ClientOptionsBuilder), so only set options.key when the
spec pseudocode sets one — then use the spec's value.
| Pseudocode | Swift |
|---|
install_mock(mock_http) / install_mock(mock_ws) | installMock(mockHTTPClient) / installMock(wsProvider) — before makeRealtime/makeRest (the mock is injected at construction) |
Rest(options: ClientOptions(key: "...")) | let rest = makeRest { $0.key = "..." } |
Realtime(options: ClientOptions(key: "...", autoConnect: false)) | let client = makeRealtime { $0.key = "..."; $0.autoConnect = false } |
Realtime(options: ClientOptions(autoConnect: false)) | let client = makeRealtime { $0.autoConnect = false } — no key set; the seeded dummy key applies |
enable_fake_timers() | enableFakeTimers() — before makeRealtime/makeRest (the clock is injected at construction) |
Mock setup — WebSocket
Use the handler pattern: configure the simulated server in onConnectionAttempt. As in the spec,
opening the socket and delivering the CONNECTED message are two separate calls —
respond_with_success() then send_to_client(...).
Spec pseudocode:
mock_ws = MockWebSocket(
onConnectionAttempt: (conn) => {
conn.respond_with_success()
conn.send_to_client(ProtocolMessage(
action: CONNECTED,
connectionId: "connection-1",
connectionKey: "key-abc-123",
connectionDetails: ConnectionDetails(connectionKey: "key-abc-123")
))
}
)
install_mock(mock_ws)
client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false))
client.connect()
AWAIT_STATE client.connection.state == ConnectionState.connected
ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection
Swift (the UTS infra exposes activeConnection as the cocoa equivalent of the spec's
events.find(CONNECTION_SUCCESS).connection):
let wsProvider = MockWebSocketProvider(onConnectionAttempt: { connection in
connection.respondWithSuccess()
connection.sendToClient(.connected(connectionId: "connection-1", connectionKey: "key-abc-123"))
})
installMock(wsProvider)
let client = makeRealtime { options in
options.key = "appId.keyId:keySecret"
options.autoConnect = false
}
client.connect()
awaitConnectionState(client, .connected)
let ws = try #require(wsProvider.activeConnection)
When attempts need different behaviour (e.g. first succeeds, reconnects refused), branch on a counter
inside the handler:
Spec pseudocode:
connection_attempt_count = 0
mock_ws = MockWebSocket(
onConnectionAttempt: (conn) => {
connection_attempt_count++
IF connection_attempt_count == 1:
conn.respond_with_success()
conn.send_to_client(ProtocolMessage(
action: CONNECTED,
connectionId: "conn-s",
connectionKey: "key-s",
connectionDetails: ConnectionDetails(connectionKey: "key-s", connectionStateTtl: 2000)
))
ELSE:
conn.respond_with_refused()
}
)
Swift:
var attemptCount = 0
let wsProvider = MockWebSocketProvider(onConnectionAttempt: { connection in
attemptCount += 1
if attemptCount == 1 {
connection.respondWithSuccess()
connection.sendToClient(.connected(connectionId: "conn-s", connectionKey: "key-s", connectionStateTtl: 2))
} else {
connection.respondWithRefused()
}
})
Mock setup — HTTP
Spec pseudocode:
captured_requests = []
mock_http = MockHttpClient(
onConnectionAttempt: (conn) => conn.respond_with_success(),
onRequest: (req) => {
captured_requests.push(req)
req.respond_with(200, [1704067200000])
}
)
install_mock(mock_http)
client = Rest(options: ClientOptions(key: "app.key:secret"))
Swift:
let capturedRequests = Captured<PendingHTTPRequest>()
let mockHTTPClient = MockHTTPClient(
onConnectionAttempt: { connection in connection.respondWithSuccess() },
onRequest: { request in
capturedRequests.append(request)
request.respondWith(status: 200, body: [1704067200000])
}
)
installMock(mockHTTPClient)
let rest = makeRest { $0.key = "app.key:secret" }
Variable declarations
Take spec variable names (adapted to camelCase), for new ones, if needed don't use "noname" names (like
"fields"), make the name concrete.
Capturing connection attempts / requests
The target is built in the Swift 6 language mode. Since mock handler closures are @Sendable and run on
the SDK's queues, a plain var array captured into them is a compile error (a data race). Use the UTS
infra's thread-safe (lock-guarded) Captured<T> instead:
Spec pseudocode:
captured_connection_attempts = []
mock_ws = MockWebSocket(
onConnectionAttempt: (conn) => {
captured_connection_attempts.append(conn)
conn.respond_with_success()
conn.send_to_client(ProtocolMessage(
action: CONNECTED,
connectionId: "c",
connectionKey: "k",
connectionDetails: ConnectionDetails(connectionKey: "k")
))
}
)
# ... after AWAIT_STATE client.connection.state == ConnectionState.connected:
ASSERT captured_connection_attempts[0].url.query_params["recover"] == "..."
Swift:
let capturedConnectionAttempts = Captured<MockWebSocket>()
let wsProvider = MockWebSocketProvider(onConnectionAttempt: { connection in
capturedConnectionAttempts.append(connection)
connection.respondWithSuccess()
connection.sendToClient(.connected(connectionId: "c", connectionKey: "k"))
})
#expect(capturedConnectionAttempts[0].queryParams["recover"] == "...")
Captured<T> exposes append, all, count, first, and subscript(Int). Use
capturedConnectionAttempts.count to change outcome for different connection attempts.
Inspecting outgoing frames
The spec inspects client-sent frames through the mock's event timeline
(mock_ws.events.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server")); the cocoa UTS
infra exposes the decoded equivalent as ws.sentMessages. Capture after the channel/connection state
confirms the send happened:
Spec pseudocode:
ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection
channel.attach()
ws_connection.send_to_client(ProtocolMessage(action: ATTACHED, channel: "channel-one", channelSerial: "serial-1"))
AWAIT_STATE channel.state == ChannelState.attached
sent_frames = mock_ws.events.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server")
attach_frame = sent_frames.find(f => f.message.action == ATTACH AND f.message.channel == "channel-one")
ASSERT attach_frame.message.channelSerial == "serial-1"
Swift:
let ws = try #require(wsProvider.activeConnection)
channelOne.attach()
ws.sendToClient(.attached(channel: "channel-one", channelSerial: "serial-1"))
awaitChannelState(channelOne, .attached)
let attachFrames = ws.sentMessages.filter { $0.action == .attach && $0.channel == "channel-one" }
#expect(attachFrames.count == 1)
#expect(attachFrames.first?.channelSerial == "serial-1")
Mock method reference
connection is the object the relevant handler hands you — for WebSocket it's the MockWebSocket passed
to onConnectionAttempt (the spec's mock_ws/conn); for HTTP it's the PendingHTTPConnection passed to
the MockHTTPClient onConnectionAttempt. request is the PendingHTTPRequest passed to onRequest.
| Pseudocode | Swift |
|---|
conn.respond_with_success() | connection.respondWithSuccess() |
conn.respond_with_success(message) | connection.respondWithSuccess(message) — accept + deliver in one call (e.g. .connectedMessage) |
conn.respond_with_refused() | connection.respondWithRefused() |
mock_ws.send_to_client(msg) | connection.sendToClient(msg) |
mock_ws.send_to_client_and_close(msg) | connection.sendToClientAndClose(msg) |
mock_ws.simulate_disconnect() | connection.simulateDisconnect() |
req.respond_with(200, {...}) | request.respondWith(status: 200, body: [...]) |
req.respond_with_delay(delay, status, body) | request.respondWithDelay(delay, status: …, body: …) |
req.respond_with_timeout() | request.respondWithTimeout() |
conn.respond_with_refused/timeout/dns_error() (HTTP) | connection.respondWithRefused() / respondWithTimeout() / respondWithDNSError() |
Known WS-mock gap: the WS-level conn.respond_with_timeout() / conn.respond_with_dns_error() some
specs use have no MockWebSocket counterpart yet (only success/refused/disconnect exist; the
timeout/refused/DNS-error trio exists HTTP-side on PendingHTTPConnection). When a spec calls them, either
extend MockWebSocket (small — mirror respondWithRefused(), plumbing the matching transport error) or
record the case in deviations.md under Mock Infrastructure Limitations — never silently substitute
respondWithRefused().
Protocol messages and types
Server-to-client messages are described with the Sendable ProtocolMessage factories —
sendToClient/sendToClientAndClose take a ProtocolMessage and build the real ARTProtocolMessage at
delivery, so no non-Sendable value crosses the queue hop. Use these factories; add a new one (and the
matching makeProtocolMessage() case) if you need another action:
| Pseudocode | Swift |
|---|
ProtocolMessage(action: CONNECTED, connectionId, connectionKey, connectionDetails(...)) | .connected(connectionId:, connectionKey:, maxIdleInterval:, connectionStateTtl:) |
ProtocolMessage(action: ATTACHED, channel, channelSerial) | .attached(channel:, channelSerial:) |
ProtocolMessage(action: ERROR, error: ErrorInfo(code, statusCode, message)) | .error(code:, statusCode:, message:) |
ProtocolMessage(action: ACK, msgSerial, count) | .ack(msgSerial:, count:) |
ProtocolMessage(action: CLOSED) | .closed() |
CONNECTED_MESSAGE (ready-made default) | .connectedMessage |
connectionStateTtl: 2000 (wire ms) | seconds here: connectionStateTtl: 2 |
ConnectionState.connected / ChannelState.attached | .connected / .attached (ARTRealtimeConnectionState / ARTRealtimeChannelState) |
Awaiting state
AWAIT_STATE x.state == ConnectionState.X → awaitConnectionState(client, .x) (default 2s timeout, or
timeout:). Channels: awaitChannelState(channel, .attached). For other conditions (e.g. "a frame was
sent") use poll("description") { <bool> }. Don't use poll unless you can't find alternative or spec says
to do so.
Timer control
Spec pseudocode:
client = Realtime(...)
# ...
enable_fake_timers()
# ...
ADVANCE_TIME(1500) # fires due timers
Swift:
enableFakeTimers()
let client = makeRealtime { ... }
advanceTime(byMilliseconds: 1500)
For multi-retry scenarios use the spec's loop (don't compute exact jumps):
Spec pseudocode:
LOOP up to 10 times:
ADVANCE_TIME(1500)
IF client.connection.state == ConnectionState.suspended:
BREAK
AWAIT_STATE client.connection.state == ConnectionState.suspended
Swift:
for _ in 0..<10 {
advanceTime(byMilliseconds: 1500)
if client.connection.state == .suspended { break }
}
awaitConnectionState(client, .suspended)
Asserting on logs
To assert "an error is logged", inject a CapturingLog and check it:
let log = CapturingLog()
let client = makeRealtime { $0.key = "..."; $0.logHandler = log }
#expect(log.contains(level: .error, message: "recovery key"))
Comments and assertion fidelity
Carry over every comment from the spec pseudocode verbatim, as a // comment at the matching step —
these explain why each step exists and must not be dropped or paraphrased.
Translate every spec ASSERT/AWAIT into a Swift assertion at the same place. If an assertion genuinely
has no Swift equivalent (e.g. it checks a language-specific construct, or the SDK exposes no observable
hook for it), do not silently omit it — keep the spec's comment/line and add a // note explaining why
no assertion is emitted. Never delete the spec line.
#expect(recovered.connectionId == "connection-1")
A dropped or weakened assertion that is not annotated this way is a bug — Step 7 re-checks for it.
Assertions (Swift Testing)
Use Swift Testing macros (import Testing, not XCTest):
| Pseudocode | Swift |
|---|
ASSERT x == y | #expect(x == y) |
ASSERT x IS NOT null | let x = try #require(optional) (or #expect(x != nil)) |
ASSERT x IS null | #expect(x == nil) |
ASSERT x IS Auth (type check) | #expect(x is ARTAuth) — or let auth = try #require(x as? ARTAuth) when later lines use the value |
ASSERT x matches pattern "..." | #expect(x.range(of: "...", options: .regularExpression) != nil) |
ASSERT list CONTAINS_IN_ORDER [a, b, c] | #expect(list.filter { [a, b, c].contains($0) } == [a, b, c]) — or walk an index as the spec does |
ASSERT "k" IN map / NOT IN | #expect(map["k"] != nil) / #expect(map["k"] == nil) |
ASSERT list.length == N | #expect(list.count == N) |
ASSERT x == y (with message) | #expect(x == y, "message") |
AWAIT op FAILS WITH error | capture the error (see async below) and #expect(error.code == ...) / #expect(error.statusCode == ...) |
#expect(...) records a failure and continues; try #require(...) unwraps/asserts and stops the test on
failure (use it when later lines depend on the value, like XCTUnwrap).
REST calls will callback (e.g. rest.time { ... }) — make the test async throws and create a helper
method that bridges the completion handler with a continuation:
private func awaitTime(_ rest: ARTRest, sourceLocation: SourceLocation = #_sourceLocation) async -> Date {
await withCheckedContinuation { (continuation: CheckedContinuation<Date, Never>) in
rest.time { date, error in
if let error { Issue.record("time() failed: \(error)", sourceLocation: sourceLocation) }
guard let date else { Issue.record("time() failed: date should not be nil", sourceLocation: sourceLocation) }
continuation.resume(returning: date)
}
}
}
Put these async helpers in the test class extension at the bottom of the test file. Inside these helpers,
report non-assertion failures (timeouts, unexpected errors) with Issue.record("...", sourceLocation:).
Test naming and annotation
// UTS: <spec-id> comment immediately above each test, then the @Test attribute. Copy the spec's
full structured id verbatim (e.g. realtime/unit/RTN16g/recovery-key-structure-0; for the objects
module it would start objects/unit/…). Don't hand-build the prefix — use what the spec file declares.
(A hand-built prefix silently breaks the Step 7 audit's ID matching.)
- Method name:
test_<SPEC>_<description_with_underscores> — keep the spec point intact, join words with
underscores. Take the description from the spec test title. Keep camelCase for symbol names. Mark the
function throws (and async if it awaits).
@Test
func test_RTN16g_createRecoveryKey_returns_a_recovery_key() throws {
...
}
File template (unit tier)
This scaffold is for the unit tier — it wires the mocked transport (MockWebSocketProvider,
installMock, makeRealtime). For the integration (direct sandbox) and proxy tiers, start from
the file templates in the Integration tests section instead, not from this one.
import Testing
import Foundation
import Ably
import Ably.Private
@Suite(.serialized)
final class <className>: UTSTestCase {
@Test
func test_<SPEC>_<description>() throws {
let wsProvider = MockWebSocketProvider(onConnectionAttempt: { connection in
connection.respondWithSuccess()
connection.sendToClient(.connected(connectionId: "connection-1", connectionKey: "key-1"))
})
installMock(wsProvider)
let client = makeRealtime { options in
options.key = "appId.keyId:keySecret"
}
client.connect()
awaitConnectionState(client, .connected)
#expect(client.connection.state == .connected)
closeClient(client)
}
}
Helper methods return types should be ready for use in the test code without additional casting (if you
need "value as? String" in the test, move this conversion to the helper instead). Don't return optionals
from these methods - assert not nil within the method itself, unless test expects optional. Don't create
additional helper types for parsing dictionaries - just use a dictionary with the most suited type for the
test, accessing its fields by subscript. For dictionaries containing values of different types use
[String: Any]. Don't wrap dictionary constant initialization into helper method. Put helper methods for
the suite into an extension at the bottom of the file. Keep the spec's original tests order in the
generated test file. Keep test segmentation by adding comments like "// Setup", "// Test Steps",
"// Assertions".
Step 5 — Compile
swift build --build-tests
Fix any compilation errors and recompile until clean. Common issues:
- Missing
import Ably.Private when the test touches internals.
- Mock method names differing from what you read in Step 3's files — use the exact signatures.
- Swift 6
@Sendable capture errors — a plain var captured in a mock handler is a compile error; use
Captured<T> (see "Capturing connection attempts / requests").
Step 6 — Run tests (evaluate mode only)
Skip this whole step in "translate only" mode. In "translate and evaluate" mode, run the test and resolve
every failure via the decision tree below. Each test must end in exactly one of these states — never an
unexplained red:
- the spec-correct assertion passes; or
- a documented SDK deviation — env-gated or adapted, stays green (SDK ≠ spec; fix belongs in the SDK); or
- a documented UTS spec error — fails fast (the spec is wrong; fix belongs in the spec). This is the
one acceptable red.
Run the suite with the resolver's className as the filter:
swift test --filter "UTS.<className>"
swift test --filter "UTS.<className>/<test_method_name>"
(swift test --filter UTS still runs the whole UTS target — including the integration tiers. There is
no env-var gating: integration and proxy suites run directly from swift test or the Xcode test runner;
they just need network access, and the proxy tier needs macOS — see Test/UTS/README.md §10.)
Handle test failures using this decision tree (the Required reading doc you fetched up front has the full detail):
Test fails
|
+-- Does UTS spec match features spec? (fetch the features spec — see Required reading)
| NO → UTS SPEC ERROR — fix the spec at source, not the test. Record in deviations.md
| (UTS Spec Errors) + emit a fail-fast test (below) so it's fixed early.
| YES
| +-- Does test accurately translate the UTS spec?
| NO → fix the test (no deviation entry needed)
| YES → SDK deviation — adapt test, record in deviations file
Test patterns for a diagnosed failure
Two patterns are for an SDK deviation (both write the spec-correct assertions); the third,
spec-error fail-fast, is for a UTS spec error and is not a deviation.
Env-gated skip (preferred) — test contains spec-correct assertions but is disabled by default via the
Swift Testing .enabled(if:) trait, so it only runs when RUN_DEVIATIONS is set:
@Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_DEVIATIONS"] != nil))
func test_RSA4c2_callback_error_connecting_disconnected() throws {
}
Reproduce with RUN_DEVIATIONS=1 swift test --filter "UTS.<className>/<method>".
Adapted assertion — when you still want to assert on the SDK's actual behaviour to prevent regressions:
#expect(error.code == 40160)
Spec-error fail-fast (UTS spec error — NOT an SDK deviation) — when the decision tree's first branch
is NO (the UTS spec contradicts the features spec, or is internally inconsistent), the spec is the
fixable source of truth, so the test must fail loudly pointing at the deviation entry, rather than be
quietly adapted to green. This is the opposite of the SDK-deviation patterns above (which stay green /
assert actual behaviour) — failing fast is the forcing function that gets the spec fixed early.
@Test
func test_RTLC7c2_LOCAL_source_does_not_write_siteTimeserials() throws {
Issue.record("UTS spec error RTLC7c2 — fix the spec first; see deviations.md")
}
Never use the accommodate-both pattern (accept either spec or SDK behaviour). Every test must assert
either spec behaviour or the SDK's actual behaviour — never both at once.
Note: an infra-driving difference (e.g. using fake timers where the spec uses real ones, or a
queue-ordering workaround) is not an SDK deviation — explain it in a code comment, not deviations.md.
deviations.md is only for SDK non-compliance and mock-capability gaps.
Deviations file
Append to Test/UTS/deviations.md, using the manual's Recording deviations entry format and sections
(the writing-derived-tests.md you fetched up front — not a home-grown format). The ably-cocoa-specific
mapping: a UTS Spec Error (test fails fast — fix in the spec) goes under the manual's UTS Spec Errors
section (the file doesn't have that section yet — add the heading on first use, alongside the existing
three); an SDK deviation (env-gated/adapted — fix in the SDK) goes under Failing Tests / Adapted
Tests; a mock-capability gap goes under Mock Infrastructure Limitations.
Step 7 — Review generated output against the spec
The translation isn't done when it compiles — it's done when every line of the source spec is faithfully
represented in the Swift: each test case, each setup step, each operation, and each assertion. Missing a
single ASSERT produces a test that compiles, passes, and silently checks less than the spec demands. This
review runs in both modes (it's static — it doesn't need the tests to have run).
Deterministic faithfulness audit — run the script first
Eyeballing two files for "did I translate every line?" is exactly the kind of mechanical comparison the
model does inconsistently, so a bundled script extracts the ledger for you — same inputs, same report every
time. Run it for each translated spec — don't hand-build paths; use the resolver's output (Step A): the
source spec file is that spec's specs[].file (already an absolute path) and the generated Swift
file is the chosen tier's targetDir + className (Step 2):
python3 .claude/skills/uts-to-swift/scripts/audit_translation.py \
"<tier.specs[].file>" \
"<tier.targetDir>/<className>.swift"
It prints one JSON report and exits non-zero (2) when there are missing or orphan Test IDs. It does no
semantic judgement — it only extracts, deterministically, what you must then reconcile by hand:
idCoverage — the spec's **Test ID** set vs the Swift file's // UTS: tag set.
missingInSwift must be empty. Each entry is a spec test case with no @Test method — implement it.
orphanInSwift must be empty (or every entry explained) — a // UTS: tag that no longer matches
any spec Test ID means a stale or hand-edited tag.
duplicateInSwift must be empty — a tag carried by more than one @Test means the audit only
examined the last method with that tag; deduplicate the tags first.
perTest[] — for each spec test case, every non-comment code line inside the spec's pseudo blocks,
grouped by section (Setup / Test Steps / Assertions / …) in sections[] and each line tagged
assert (an ASSERT* outcome), await (an AWAIT* / EXPECT wait), or step (setup, mock/client
construction, an operation). specAsserts / specAwaits are flat convenience views of the first two;
specCodeLineTotal is the size of the spec test. The matching Swift method's calls come alongside,
counted separately so a surplus of waits can't hide a dropped assertion: assertions
(#expect/#require → swiftAssertionCount) and waits (awaitConnectionState/awaitChannelState/
awaitState/pollUntil/poll → swiftAwaitCount).
The audit is a review aid, not a gate — it never crashes, it degrades. It always prints one JSON object
(an error field instead of a report if a file is unreadable). If idCoverage.specCount is 0 for a file
you know is a real spec, the extractor couldn't find any **Test ID** markers (an unusual spec format) —
treat that as "couldn't verify deterministically" and fall back to a manual side-by-side read for that
file. The known case is a fixture-driven spec (e.g. rest/unit/encoding/msgpack_interop.md — one
parameterised test looping over external fixture data instead of enumerated test cases): there the audit
will also report your // UTS: tag as an orphan — expected, not a defect; explain it in the review and
verify that file by hand.
Coverage check — every test case is present
From the audit's idCoverage:
Line-by-line completeness — every spec line is translated
Walk the audit's perTest[].sections and reconcile each extracted spec line against the Swift method.
This is the guarantee that no setup step, operation, or assertion was dropped — the whole point of this step:
Setup fidelity — preconditions match the spec
For each test case, verify:
Deviation honesty (evaluate mode)
Deviations are discovered by running, so this check applies in evaluate mode. For any place where the
generated test diverges from the spec pseudocode (adapted assertion, env-gated skip, or omitted step):
If you find gaps during this review, fix them, then re-run the audit script until missingInSwift /
orphanInSwift / duplicateInSwift are empty and every perTest entry reconciles, and re-run Step 5
(compile) — and, in evaluate mode, Step 6 — before finishing.
Integration tests (direct sandbox + proxy)
Some specs are integration tests — they run against the real Ably sandbox instead of a mocked transport. Two tiers share one foundation and differ only in transport:
- Direct sandbox — the client connects straight to the sandbox. Happy-path interop (connect, publish, subscribe, presence); no faults.
- Proxy — the client is routed through the
ably/uts-proxy, a programmable HTTP/WebSocket proxy that forwards traffic transparently but can inject faults (dropped connections, modified/injected/delayed frames, error responses) via rules.
Shared foundation (both tiers, covered once): SandboxApp provisioning and teardown via the tier's
base test case, real (unmocked) clients, and the async awaitState / awaitChannelState / pollUntil
waits — never a fixed sleep, since real network is involved (use generous 10–30s timeouts; the helpers
default to 15s). Swift Testing has no setUp()/tearDown() and deinit can't await, so the base cases
provide scoped-resource methods instead — each with… method provisions its resource, runs your body,
and always tears it down, rethrowing the body's error afterwards. Never hand-roll teardown; wrap the
scenario in the scopes. Only the wiring differs per tier; that's what the two tier subsections below
cover.
Required reading for the proxy tier: before translating any proxy spec, read
uts/docs/proxy.md (once per run) — it defines the proxy session/control API, the rule and action
vocabulary, and the protocol action-number table the subsections below rely on. Fetch
the raw URL;
if that 404s (the docs/ layout may not be merged upstream yet), read it from the spec checkout the
module directory came from instead: <module-dir>/../docs/proxy.md.
Recognise a proxy spec by a reference to create_proxy_session(), proxy rules, trigger_action,
get_log, or a pointer to uts/docs/proxy.md. A spec with none of those is direct sandbox.
Which integration tier?
| Test type | When the spec uses it |
|---|
| Unit test (mock HTTP/WS — the rest of this skill) | Client-side logic, state machines, request formation, error parsing. Fast, deterministic. |
| Direct sandbox integration | Happy-path behaviour (connect, publish, subscribe, presence). No fault injection. |
| Proxy integration test | Fault behaviour against the real backend: connection failures, resume, heartbeat starvation, token renewal under network errors, channel error injection. |
Direct-sandbox integration tests
A direct-sandbox spec (no create_proxy_session, no rules — just happy-path interop against
nonprod:sandbox) uses the same SandboxApp provisioning as a proxy test but drops all proxy wiring:
no ProxyManager, no ProxySession, no connectThroughProxy. The client connects straight to the
sandbox host. Test/UTS/integration/standard/realtime/ChannelHistoryTests.swift (RTL10d — ably-java's
ChannelHistoryTest is its sibling) is the reference example — read it before translating a
direct-sandbox spec. For the proxy tier the reference is
Test/UTS/integration/proxy/realtime/AuthReauthTests.swift (RTN22/RTC8a). Don't add env-var gating to
generated tests — integration suites run ungated (they just need network; see Test/UTS/README.md §10).
Suites subclass IntegrationTestCase and wrap the scenario in the scoped-resource methods:
withSandboxApp { app in … } provisions a throwaway sandbox app and always deletes it;
withRealtimeClient(options) { client in … } builds a real ARTRealtime and always closes it (waiting
for CLOSED).
Client wiring — point both transports at the sandbox host; TLS stays on, so the plain sandbox key
works (RSA1). Explicit hosts auto-disable fallback hosts (REC2c2), so no fallbackHosts:
let options = ARTClientOptions(key: app.defaultKey)
options.realtimeHost = SandboxApp.sandboxHost
options.restHost = SandboxApp.sandboxHost
options.useBinaryProtocol = useBinaryProtocol
options.autoConnect = false
Class docstring — use a direct-sandbox variant (no proxy/fault-injection wording):
Protocol variants (json / msgpack) — when the spec header declares a PROTOCOL dimension and says
each test runs once per variant, translate it to a parameterised test over useBinaryProtocol (Swift
Testing has this built in — no extra imports), not a plain @Test. The // UTS: tag and method name stay
singular — the parameter expresses the variant:
@Test(arguments: [false, true])
func test_RTL10d_history_contains_messages_published_by_another_client(useBinaryProtocol: Bool) async throws {
}
A spec with no protocol dimension stays a plain @Test. Only the direct-sandbox tier is
parameterised — the proxy tier is always JSON (see "Connecting through the proxy").
Awaiting real server outcomes — integration specs assert on real backend state, so never sleep; await
or poll:
| Pseudocode | Swift |
|---|
AWAIT channel.attach() | channel.attach() then guard await awaitChannelState(channel, .attached, timeout: 10) else { return } |
AWAIT channel.publish(name, data) (await the ack) | bridge the callback overload (publish(_:data:callback:)) with a continuation, resuming on the callback and recording an Issue on error — same helper pattern as Step 4's awaitTime |
poll_until(() => AWAIT channel.history().items.length == N, …) | await pollUntil("history has N items") { await historyItems(of: channel).count == N } — with a continuation-bridged history helper (see ChannelHistoryTests and Test/UTS/README.md §11.4 for the concrete helpers) |
Use generous timeouts (10–30s) — real network is involved. Everything else is the shared foundation
described at the top of this section; a direct-sandbox test just skips the proxy-only subsections
(ProxySession, rule factories, the event log).
File template — direct sandbox (integration/standard/<module>/):
import Testing
import Foundation
import Ably
@Suite(.serialized)
final class <className>: IntegrationTestCase {
@Test(arguments: [false, true])
func test_<SPEC>_<description>(useBinaryProtocol: Bool) async throws {
try await withSandboxApp { app in
let options = ARTClientOptions(key: app.defaultKey)
options.realtimeHost = SandboxApp.sandboxHost
options.restHost = SandboxApp.sandboxHost
options.useBinaryProtocol = useBinaryProtocol
options.autoConnect = false
try await withRealtimeClient(options) { client in
client.connect()
guard await awaitState(client, .connected) else { return }
}
}
}
}
Infrastructure
The helpers live under Test/UTS/infra/integration/. Read the ones your tier uses before translating an
integration spec — they hold the exact method signatures. SandboxApp and IntegrationTestCase serve
both tiers; ProxyManager, ProxySession, and ProxyTestCase are proxy-only.
SandboxApp (infra/integration/SandboxApp.swift) — provisions/deletes a sandbox test app from the
shared test-app-setup.json in ably-common. SandboxApp.create() returns a SandboxApp with appId,
defaultKey, and keys (defaultKey is a full-capability appId.keyId:keySecret); app.delete()
tears it down. Also owns the single upstream sandbox host constant SandboxApp.sandboxHost
(sandbox.realtime.ably-nonprod.net, the resolved nonprod:sandbox endpoint) — the default target of
every ProxySession, and what direct-sandbox clients set realtimeHost / restHost from.
IntegrationTestCase (infra/integration/IntegrationTestCase.swift) — the direct-sandbox base
class: withSandboxApp, withRealtimeClient, and the runThenCleanUp(_:body:cleanup:) engine they're
built on (run body, always run cleanup, rethrow the body's error) — subclasses use it for their own
scopes.
ProxyManager (infra/integration/proxy/ProxyManager.swift) — actor that syncs (downloads,
checksum-verifies, caches) and starts the shared uts-proxy process; ensureProxy() is idempotent.
Spawning a local process makes the proxy tier macOS-only — wrap proxy test files in
#if os(macOS). (UTS_PROXY_LOCAL_PATH points it at a locally built binary instead.)
ProxySession (infra/integration/proxy/ProxySession.swift) — one programmable session wrapping
the proxy control API; also defines the connectThroughProxy extension, the rule-factory helpers, and
the typed ProxyEvent.
ProxyTestCase (infra/integration/proxy/ProxyTestCase.swift) — the proxy base class (extends
IntegrationTestCase): withProxySession(rules:) and proxyClientOptions(for:through:).
The SandboxApp and ProxySession methods are all async — call them with try await inside the
scoped bodies. Imports are simple: import Testing, import Foundation, import Ably (+
import Ably.Private only if the spec asserts on SDK internals) — the whole UTS target is one module, so
the infra helpers need no imports.
Proxy test class docstring
Give every proxy integration test class this doc comment:
Session lifecycle
create_proxy_session(endpoint: "nonprod:sandbox", rules: [...]) → the base class's
withProxySession(rules:) — it ensures the proxy is running, provisions the sandbox app, creates the
session, and always closes the session and deletes the app afterwards (the sandbox is already the
session's default target, so the endpoint argument drops). Never hand-roll the teardown:
try await withProxySession(rules: [wsConnectRule(action: ["type": "refuse_connection"], count: 2)]) { app, session in
let options = proxyClientOptions(for: app, through: session)
options.autoConnect = false
try await withRealtimeClient(options) { client in
client.connect()
guard await awaitState(client, .connected, timeout: 15) else { return }
}
}
| Pseudocode | Swift |
|---|
create_proxy_session(endpoint: "nonprod:sandbox", rules: [...]) | withProxySession(rules: [...]) { app, session in … } |
session.add_rules(rules, position: "prepend") | try await session.addRules(rules, position: "prepend") |
session.trigger_action({ type: "disconnect" }) | try await session.triggerAction(["type": "disconnect"]) |
session.get_log() | try await session.getLog() |
session.close() | handled by withProxySession — don't call it yourself |
session.proxy_port / session.proxy_host | session.proxyPort / session.proxyHost |
Connecting through the proxy
Call options.connectThroughProxy(session) on the client options. It is an ARTClientOptions extension
(in ProxySession.swift) that wires the SDK through the proxy:
| Proxy-def option | What connectThroughProxy sets |
|---|
endpoint: "localhost" | realtimeHost and restHost = session.proxyHost ("localhost") |
port: proxy_port | port = session.proxyPort |
tls: false | tls = false |
useBinaryProtocol: false | useBinaryProtocol = false — set explicitly (the proxy can only inspect text frames; the UTS proxy specs require JSON), so proxy tests are always JSON — never parameterise the protocol here |
It does not touch autoConnect, so set that yourself. Setting explicit hosts disables fallback hosts
automatically (REC2c2), so don't add fallbackHosts.
Auth in proxy tests
The proxy serves plain ws (tls = false) and basic (key) auth is TLS-only (RSA1), so a proxied client
can't just use the sandbox key. Where the pseudocode "generates a JWT from the key parts", the idiomatic
ably-cocoa equivalent is a locally-signed TokenRequest from the same sandbox key — no JWT library
required: a separate TLS "token signer" ARTRest calls auth.createTokenRequest(params, options:) inside
an authCallback, and the realtime client exchanges it for a token through the proxy.
The base class packages all of this: proxyClientOptions(for: app, through: session) returns
ARTClientOptions with the token-signer authCallback and connectThroughProxy already wired. Its body
(in ProxyTestCase.swift) is the pattern to inline when a spec needs to count or intercept the auth
callbacks itself:
let signerOptions = ARTClientOptions(key: app.defaultKey)
signerOptions.restHost = SandboxApp.sandboxHost
let tokenSigner = ARTRest(options: signerOptions)
let options = ARTClientOptions()
options.authCallback = { params, callback in
tokenSigner.auth.createTokenRequest(params, options: nil) { tokenRequest, error in
callback(tokenRequest, error)
}
}
options.connectThroughProxy(session)
(The auth types are top-level classes in cocoa — ARTTokenParams, ARTTokenRequest, ARTAuthDetails —
unlike ably-java, where TokenParams/TokenRequest are nested in Auth and AuthDetails in
ProtocolMessage.)
Rule factory helpers
Build rules with the factory helpers in ProxySession.swift rather than raw dictionary literals. Rules
are evaluated in order, first match wins, unmatched traffic passes through, and times auto-removes a
rule after N firings.
| Match condition | Swift |
|---|
{ "type": "ws_connect", "count": 2 } | wsConnectRule(action: …, count: 2) |
{ "type": "ws_connect", "queryContains": { "resume": "*" } } | wsConnectRule(action: …, queryContains: ["resume": "*"]) |
{ "type": "ws_frame_to_client", "action": "ATTACHED", "channel": "c" } | wsFrameToClientRule(action: …, messageAction: 11, channel: "c") |
{ "type": "ws_frame_to_server", "action": "ATTACH", "channel": "c" } | wsFrameToServerRule(action: …, messageAction: 10, channel: "c") |
{ "type": "http_request", "method": "POST", "pathContains": "/keys/" } | httpRequestRule(action: …, method: "POST", pathContains: "/keys/") |
messageAction is the protocol action number (e.g. 4 CONNECTED, 6 DISCONNECTED, 9 ERROR, 10
ATTACH, 11 ATTACHED) — see the action-number table in uts/docs/proxy.md (the doc you fetched at the
top of this section).
Actions are passed as ProxyRule dictionary literals, e.g.:
["type": "refuse_connection"]
["type": "disconnect"]
["type": "suppress"]
["type": "delay", "delayMs": 2000]
["type": "inject_to_client", "message": ["action": 6]]
["type": "http_respond", "status": 401, "body": ["...": "..."]]
Verifying the event log
getLog() returns a typed [ProxyEvent]. Access fields via dot notation ($0.type, $0.direction,
$0.queryParams, $0.status); numeric fields (status, closeCode) are already Int?. The raw protocol
message is exposed as ProxyEvent.message (a [String: Any]?) — introspect it with
event.message?["action"] as? Int.
let log = try await session.getLog()
let wsConnects = log.filter { $0.type == "ws_connect" }
#expect(wsConnects.count >= 2)
let queryParams = try #require(wsConnects.first?.queryParams)
#expect(queryParams["resume"] != nil)
Conventions
- Each test references the spec point and (where it exists) the corresponding unit test.
- Setup/teardown (proxy launch, sandbox app, session, client close) is the base class's job — wrap
scenarios in
withProxySession / withSandboxApp / withRealtimeClient; never hand-roll it.
- Guard every wait —
guard await awaitState(…) else { return }: a timeout already records an
Issue, so stop instead of driving a client in the wrong state (the scopes still tear down).
- Use
awaitState / awaitChannelState for state assertions; verify via SDK state and the proxy log
where useful.
- Use generous timeouts (10–30s) — real network is involved:
await awaitState(client, .connected, timeout: 15).
- Don't set
fallbackHosts; explicit hosts already disable fallbacks. Wrap proxy test files in
#if os(macOS).
Step 5 (compile) still applies; Step 6 (run) applies only in evaluate mode (Step E). Note that proxy tests
hit the live sandbox and download the proxy binary on first run, so they are slower and require network
access.
File template — proxy (integration/proxy/<module>/):
#if os(macOS)
import Testing
import Foundation
import Ably
@Suite(.serialized)
final class <className>: ProxyTestCase {
@Test
func test_<SPEC>_<description>() async throws {
try await withProxySession(rules: []) { app, session in
let options = proxyClientOptions(for: app, through: session)
options.autoConnect = false
try await withRealtimeClient(options) { client in
client.connect()
guard await awaitState(client, .connected) else { return }
try await session.triggerAction(["type": "inject_to_client", "message": ["action": 17]])
await pollUntil("client reacted to the injected frame") { true }
let log = try await session.getLog()
#expect(log.contains { $0.type == "ws_frame" && $0.direction == "client_to_server" })
}
}
}
}
#endif