iOS SDK contract suite¶
The contract suite drives SDK capabilities inside a real host app on a simulator and follows each result through CtrlProxy, the daemon’s iOS client and the MCP surface. Layer-local tests prove each side encodes and decodes its own fixtures; this suite proves the layers agree with each other on the shipped artifacts. Each SDK capability is covered by one slice, described in the sections below.
Parts¶
| Part | Location | Role |
|---|---|---|
| Fixture host | ios/Playground/Sources/SdkContract/ |
Runs one scenario per launch, against loopback listeners only, and writes a manifest |
| Harness | scripts/ios/sdk-contract/run.ts |
Launches Playground per scenario, reads the manifest and MCP resources, judges cases |
| Wrapper | scripts/ios/sdk-contract-integration.sh |
Builds Playground (Debug) for the booted simulator and runs the harness |
| CI | XCTestRunner Simulator Tests (both systems) |
Run iOS SDK contract suite, after the navigation graph step |
Playground does nothing unless launched with PLAYGROUND_SDK_CONTRACT=<scenario> and
PLAYGROUND_SDK_CONTRACT_RUN_ID=<id> (passed as SIMCTL_CHILD_*). The run id is limited to
ASCII letters, digits and hyphens because it becomes a file name and a URL path segment. The
harness runs the network, capabilities, faults, observe, storage-default and storage
scenarios in that order, each in a fresh Playground process, because host registration cannot be
undone inside a process.
Manifest¶
Playground attempts to write Documents/sdk-contract/<runId>.json, even when the scenario fails.
A missing manifest means the host never reached the write or the write failed; a write failure
is logged only as manifest_write_failed in the app log. It records:
status:completewhen the scenario ran to the end,failedwithfailureotherwise;- one expected record per case, with the values the scenario gave the SDK adapter (absent fields are not asserted);
events: this run’s envelopes exactly as the SDK encoded them. Playground observes the batch notificationSdkEventBroadcasterposts just before delivery to CtrlProxy, so the bytes are captured whether or not delivery succeeds;undecodableBatches: batches the SDK posted that its publicSdkEventBatchtype cannot decode;faults: for the fault scenario, what the client saw per case (URL, connection and request id, elapsed time, and the error as<domain>:<code>orposix:<code>);capabilities: for the capability scenario, the transport descriptors before and after registration;observation: for the observation scenario, how many captures the contract bridge served and every action its executor ran;storage: for the storage scenarios, the storage registration the host applied.
scripts/ios/sdk-contract/manifest.ts is the TypeScript schema; a newer schemaVersion is
rejected rather than read.
Boundary attribution¶
For each case the harness compares five observations and reports the first layer that disagrees. Later layers are not judged, because their input is already wrong.
| Observation | Disagreement reported as |
|---|---|
Manifest missing, unparsable or failed |
fixture-host |
| CtrlProxy did not deliver the fault rules to the SDK | ctrlproxy-command |
| Capability state or client-side fault outcome is wrong | sdk-adapter |
| SDK payload missing, differs from the adapter input, or leaks | sdk-serialization |
| Production replay of the SDK bytes drops or changes the row | ios-client |
| Replayed row correct, replayed request resource wrong | mcp-surface |
| The host capability parser misreads an SDK descriptor | ios-client |
| Live resource missing, CtrlProxy still queues the event | ios-client |
| Live resource missing, CtrlProxy queue empty | sdk-endpoint |
| Live resource differs from the replay of the same bytes | ctrlproxy-command |
The replay (referencePipeline.ts) runs the SDK’s captured batch through the same decoder,
ingestor, repository and automobile:network/request/{id} handler the daemon uses, against a
private in-memory database. Because the live path differs from the replay only by CtrlProxy and
the daemon’s polling, a live/replay difference isolates the transport. The CtrlProxy queue probe
(GET /sdk-events) drains the queue, so it runs only after the live wait has expired.
Records are joined across the payload, the replay and the live resource by SDK request id.
URLProtocol records carry none, so they are joined by method and URL instead; the run’s unique
/contract/<runId>/<case> path keeps that key unique.
Network slice¶
The scenario registers each transport with the host registration API (no swizzling) and exchanges
one message per transport with Playground’s loopback listeners: a GET on a registered default
URLSession, which the SDK’s URLProtocol records, a URLSessionWebSocketTask text echo (one sent and
one received frame) and an NWConnection raw HTTP exchange. URLs carry /contract/<runId>/, which
is how the manifest and the traffic query pick out this run. The URLSession request carries
Authorization: Bearer contract-secret; the header must arrive as <redacted> and the secret
must not appear in any SDK payload.
The harness enables capture with the network tool before launch, because the daemon drops
network rows while capture is off, and reads automobile:network/traffic filtered by device,
host and launch time until every expected record appears.
Capability slice¶
Playground reads AutoMobileNetwork.transportCapabilities() with capture on, registers a default
URLSession, a WebSocket and an NWConnection, and reads it again. capabilitySlice.ts holds the
expected matrix: before registration every family is DISABLED with reason HOST_OPT_IN and the
other family is UNSUPPORTED with NO_ADAPTER; afterwards the three registered families are
SUPPORTED and ENFORCED with their fault actions, and the unregistered background family is still
HOST_OPT_IN. Each descriptor is also read back through the host’s shared capability parser, which
must keep its id, state and reason. These descriptors are not served over CtrlProxy, so the slice
has no live boundary.
Fault slice¶
faultSlice.ts owns the rules and the expected outcomes. After launch, the harness sends one
one-shot delay rule (400 ms) and one fail rule per transport with CtrlProxy’s
set_network_fault_rules command over CtrlProxy’s WebSocket, retrying while CtrlProxy reports no
SDK server; the daemon has no MCP tool that sends iOS fault rules. Once CtrlProxy confirms
delivery, the harness writes <runId>.rules-ready beside the manifest, which Playground waits for
before running any case. Each case runs through the transport the registration API returns:
| Case | Client must see | SDK record |
|---|---|---|
| URLSession delay | success after the delay (a synthetic 200) | error fault:<id>:latency |
| URLSession fail | NSURLErrorDomain:-1001 (rule errorType: timeout) |
error fault:<id>:error |
| URLSession expired | the loopback server’s 200; the one-shot rule is spent | no error |
| WebSocket delay/fail | success after the delay / NSURLErrorDomain:-1005 |
SEND record with fault_* metadata |
| NWConnection delay/fail | ready after the delay / posix:54 (reset) |
the host’s request with fault_* metadata |
The client outcome is judged first (sdk-adapter), then each record crosses the same boundaries as
the network slice.
Observation slice¶
Playground registers a contract AutoMobileObservationBridge that declares tap, insertText
and clearText and publishes synthetic nodes: contract-save (counts taps into
contract-tap-count), contract-email (always focused, so sendKeys needs no XCTest focus tap),
contract-arm-stale and contract-hold (placed over the status bar). The harness runs these steps
with the MCP tools, in order, then writes <runId>.actions-done; Playground waits up to 180 s for
it before writing the manifest with the host’s action log.
| Step | Tool result must show | Host log must show |
|---|---|---|
observe |
backend: sdk and the bridge nodes |
(not judged) |
tap |
backend: sdk |
one tap on contract-save |
send-keys (replace) |
the command’s backend: sdk |
insertText of the typed text |
unsupported-long-press |
backend: xctest, fallbackReason: unsupported |
no longPress |
arm-stale |
backend: sdk |
tap on contract-arm-stale |
stale-tap |
a failure with the bridge’s stale_observation reason |
no second contract-save tap |
Once the trap is armed, the host replaces every capture with a new one as soon as the bridge has
committed it: it probes with an action naming an element no snapshot has, which the bridge rejects
before the executor (unknown_element once the capture is current, stale_observation before),
then recaptures. The observation an action was resolved against is therefore stale when the action
arrives. The host log is the ground truth: a tap it
ran that the tool misreported is mcp-surface, an SDK tap the tool reported that the host never ran
is ctrlproxy-command, an unanswered bridge is sdk-endpoint, an undeclared action reaching the
bridge is sdk-adapter, and a node that was not routed, or a stale target that did not fail
closed, is ios-client.
Storage slice¶
storage-default applies the default storage configuration (nothing registered, Keychain deletion
off). storage registers an uncoordinated and a host-coordinated app-group suite, an app-private
and an app-group Core Data store, and Keychain deletion for one exact item, and writes a secret into
the first suite. With the app running, the harness reads
automobile:devices/{udid}/storage/capabilities?appId=<Playground> and compares the Keychain
capability and the key_value and databases store states with the registration
(storageSlice.ts). After storage, it terminates Playground and reads the resource again: the
Keychain capability must be unknown with BRIDGE_UNAVAILABLE and no store state may remain. A
reachable SDK reporting the wrong state is sdk-adapter; BRIDGE_UNAVAILABLE while the app runs is
sdk-endpoint; SDK_TOO_OLD is sdk-serialization; the secret anywhere in the resource is
mcp-surface.
Failure modes¶
- No CtrlProxy reachable from the app: the SDK keeps events pending and the cases report
sdk-endpoint. - The session’s CtrlProxy client is not draining: events stay queued and report
ios-client. - The scenario times out after 30 s, a case fails or a listener fails:
fixture-hostwith the case name, the error domain and code, and the loopback server’s event log (accepted, request path, response sent, early close). - CtrlProxy never confirms the fault rules:
ctrlproxy-command, and the harness skips the fault cases. Playground stops waiting for<runId>.rules-readyafter 60 s. - The harness never writes
<runId>.actions-done: Playground fails the observation scenario after 180 s, reported asfixture-host.