Skip to content

Pin production-built Apple runner requests in a golden table that TS and Swift both verify #2881

Description

@thymikee

Purpose

The Swift runner decodes and handles request shapes that production TypeScript does not build, and nothing notices. The TypeScript RunnerCommand union and the Swift Command/CommandType models are two hand-kept vocabularies. Unit tests on each side build requests by hand. So a command, field or field combination can lose its last producer while the Swift branch and its hand-written test stay green for months.

This issue adds one golden table of production-built runner requests. It proves that production code in packages/platform-apple and src/ can build each request. It does not prove that the daemon sends it: the drives start at platform-apple entry points (for example createAppleInteractor), not at the daemon.

The #2803 audit found four instances at 01328de411. Each maps to the step that catches it:

Case What drifted Caught by
#2791, fixed by #2828 Dead wire command. back was in the TS union, the TS trait table and the Swift CommandType, with a Swift handler. No code produced it: resolveAppleBackRunnerCommand returns only backInApp/backSystem (packages/platform-apple/src/interactions.ts:65-67 at 6debef0634). Step 1. The TS completeness assertion and Swift check 3 both fail.
#2790, fixed by #2825 Dead field combination. drag with synthesized: true drove the continuous-drag profile (about 100 lines of ObjC and Swift). The only drag producer is macOS-only and never sets synthesized (interactions.ts:351). A hand-written test request covered the branch (RunnerTests+CommandExecution.swift:799 at 01328de411). Step 2 only. Step 1 passes: synthesized appears on tap entries, and Swift check 4 is per level, not per command. In step 2 the test request cannot be built, because the drag entry has no synthesized key.
#2800, fixed by #2845 Dormant route proven by a value the code never sends. The code sends textEntryMode: text === '\n' ? undefined : 'append' (interactions.ts:142). The unit test sent ordinary text with no mode (RunnerTests+CommandExecution.swift:396 at 01328de411). Not caught. Value-level reachability is a Non-goal.
#2791 Dead field use. The runner kept selector-keyed type after the TS side stopped sending selectorKey on type. Step 2, only when a Swift test runs that branch. Check 4 is per level, and tap/querySelector still carry selectorKey, so step 1 passes. A branch that no Swift test runs stays uncaught. See Non-goals.

Today (6debef0634) both vocabularies have 34 commands (runner-contract.ts:43-82, RunnerTests+Models.swift:5-40), and every command has a producer. This issue adds no fix. It makes the next drift of the first two kinds fail CI.

Design: add one golden table under contracts/fixtures/, in the same pattern as alert-command-traits.json (TS twin runner-command-traits.test.ts:87, Swift twin UnitTests/RunnerTests+LifecycleTests.swift:11). The TS tests pin every entry to the output of the real request sites. The Swift tests decode every entry, and require every Swift command and field to have at least one entry. Step 2 makes Swift unit tests derive their requests from the table.

Required behavior

Data shape: contracts/fixtures/runner-requests.json

[
  {
    "name": "ios-simulator.interactions-type.append",
    "producer": "packages/platform-apple/src/runner/__tests__/runner-contract.test.ts",
    "request": { "command": "type", "commandId": "<commandId>", "text": "hello", "textEntryMode": "append", "appBundleId": "com.example.app" }
  },
  {
    "name": "ios-device.recording-start.fps",
    "producer": "src/platform-runtime-screen-recording-apple-runner-transport.test.ts",
    "request": { "command": "recordStart", "outPath": "agent-device-recording-1700000000000.mp4", "fps": 30, "appBundleId": "com.example.app" }
  }
]
  • request is exactly what the capture seam receives, passed through JSON.parse(JSON.stringify(command)). Keys with undefined values therefore drop out. Every commandId and statusCommandId value becomes the literal "<commandId>". Nothing else is normalized.
    • At a seam after withRunnerCommandId (runner-contract.ts:288), the entry has commandId. This is true for the interactor provider seam, the injected runRunnerCommand, and the runner-internal senders.
    • The two recording tests mock runAppleRunnerCommand itself, so they capture the request before runner-client.ts:82 adds the id. Their entries have no commandId. On the wire the runner receives one. Other entries prove that commandId decodes.
    • A request value that depends on the clock is pinned with vi.setSystemTime, not normalized. Today there is one: recordStart on a physical device puts Date.now() into outPath (src/platform-runtime-screen-recording-apple-runner-transport.ts:62).
  • producer is the repo-relative path of the test file that owns the entry and asserts it. At 6debef0634 there are three owners (see the site list). A new owner is legal.
  • The file is sorted by name, and names are unique. Name format: <platform>.<site>.<variant>. Platform is ios-simulator, ios-device, macos or tvos. <site> names the call site (for example interactions-single-press-double-tap), so two sites that build the same request still have two entries.
  • The file has no trait fields (readOnly or similar). Traits stay in separate tables in each language, because they differ on purpose: TS lets querySelector and uptime be resent, but Swift does not give them its session-invalidating retry (RunnerTests+Models.swift:84-117). alert-command-traits.json stays as it is.
  • The file has no malformed requests. Malformed-input handling (INVALID_ARGS for an unknown SequenceStep.kind, which stays a String on purpose, RunnerTests+Models.swift:199-211) remains owned by its hand-written tests. See step 2.

Which calls are request sites

Rule: every production call that sends a RunnerCommand it builds (as a literal or through a builder call) has at least one entry. The senders are:

  • runAppleRunnerCommand (runner/runner-client.ts:75),
  • an injected runRunnerCommand or runCommand callback,
  • provider.runCommand,
  • the runner-internal senders executeRunnerCommandWithSession (runner/runner-session.ts:876), waitForRunner (runner/runner-startup-transport.ts:45) and sendRunnerCommandOnce (runner/runner-transport.ts:11).

Enumerate the calls by grepping the senders, not the command literals. A command-literal grep misses every call that goes through a builder:

git grep -nE '(runAppleRunnerCommand|runRunnerCommand|runCommand|executeRunnerCommandWithSession|waitForRunner|sendRunnerCommandOnce)\(' \
  -- packages/platform-apple/src src ':(exclude)*.test.ts' ':(exclude)**/__tests__/**'

At 6debef0634 it returns 74 hits. Classify each hit:

  • Origin site. The call builds its command in its arguments. It gets an entry.
  • Forwarded variable. The call sends a local that was built earlier in the same flow. Follow it to where it was built, and that line is the site: runner-client.ts:223 → :216, and runner-lifecycle.ts:464 → :61 (through runPrepareAttempt).
  • Pass-through. The call forwards a command it received as a parameter. It gets no entry: runner-client.ts:85,92, runner-lifecycle.ts:293,378, runner-session.ts:1035,1045, interactions.ts:211 (the lambda given to runApplePressSeries, which builds the command at runner-sequence.ts:160, an origin site).
  • Not a runner request. Definitions (runner-client.ts:75, runner-session.ts:876, runner-startup-transport.ts:45, runner-transport.ts:11, src/mcp/command-tools.ts:254) and host-process runCommand calls (core/tool-provider.ts, os/macos/host-provider.ts, src/commands/cli-runner.ts:26, src/mcp/command-tools.ts:260).

The implementer runs the grep again on the implementation base and adds any new origin site.

Step 1 (first PR): the table and its verifiers

TS producer test. New file packages/platform-apple/src/runner/__tests__/runner-contract.test.ts (it mirrors runner-contract.ts, which declares RunnerCommand).

  • Drive each site through its real entry point, capture the request at a seam, and assert deepEqual between the captured entries (sorted by name) and the file's entries whose producer is this test file. On a mismatch, the assertion diff shows the new entry to paste in.
  • No request literals in the drive code. Every entry must come from production code. A self-check in the same file reads its own source (import.meta.filename) and the shared helper's source. It fails on an object literal with a command: key, and on a direct call of runAppleRunnerCommand, executeRunnerCommandWithSession, waitForRunner or sendRunnerCommandOnce.
  • Capture seams, reused from existing tests. Pick per site the seam that sees the request unchanged:
    • Interactor sites: createAppleInteractor(device, ctx, recordingRunnerProvider(calls)), as packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts:111-124 does. Move recordingRunnerProvider (:450) into a shared __tests__ helper if both tests need it.
    • Runner-internal sites: real HTTP bodies from runner/__tests__/fake-runner-server.ts (requests[].body), as the runner-session-* and runner-adoption tests use it. Where no fake-server path reaches a site, use vi.mock of the sender module in an isolated describe with vi.doMock and a dynamic import, so the mock does not break the fake-server drives.
    • Injected callbacks: the runRunnerCommand option (core/physical-device-control.ts:198,213, core/physical-device-screenshot.ts:68) or the provider passed through options.
  • Sites to drive at 6debef0634 (call line, then builder line where it differs). Drive optional fields so that every field the site can emit appears in some entry.
    • packages/platform-apple/src/interactions.ts:
      • tap override (:92), focus (:132), single-press tap (:295): all through iosTapCommand (:384, literal :391). For each site, one entry where runnerSynthesizesTap is true (synthesized) and one where it is false.
      • tapElementSelector (:97, literal :100): with expectedPoint, allowNonHittableCoordinateFallback and synthesized.
      • interactor doubleTap (:116): a one-step sequence through buildRunnerSequenceCommand (runner/runner-sequence.ts:140). It does not go through runApplePressSeries.
      • interactor longPress (:125, :127).
      • type (:136, :139): append, and newline (no textEntryMode); with delayMs.
      • fill (:150, :153): with and without allowNonHittableCoordinateFallback.
      • gestureViewport (:179, :181).
      • pressPoint series (:206 → runApplePressSeries, runner-sequence.ts:160 → :140): count: 2 for each of tap, doubleTap and longPress steps. Include pauseMs and durationMs.
      • mouseClick (:250, :253): secondary and middle.
      • single-press doubleTap (:271): a one-step sequence through buildRunnerSequenceCommand, reached by pressPoint with count: 1 and doubleTap: true.
      • single-press longPress (:283, :286).
      • macOS drag (:348, :351): single-pointer plan on macOS.
      • tvOS swipe (:364, :366): single-pointer plan on tvOS. This site is tvOS-only (isTvOsDevice gate at :362).
      • gesture (:370, :372): a single-pointer and a two-pointer plan on iOS, each with two samples.
      • tvOS scroll (:412): remotePress through appleRemotePressCommand (os/tvos/remote.ts:17), with durationMs.
      • iOS scroll (:446, :449): with amount, with pixels, and with a release behavior.
    • packages/platform-apple/src/interactor.ts:
      • findText (:81, :83).
      • tvOS back (:91): remotePress menu. back (:98): both modes, backInApp and backSystem.
      • tvOS home (:110): remotePress home. home (:117, :119).
      • setOrientation (:124, rotate :128), appSwitcher (:142), actionButton (:149).
      • tvRemote (:156): remotePress with durationMs.
      • keyboardDismiss (:167), keyboardEnter (:181, keyboardReturn).
      • snapshot (:227, :230) with every option set, readText (:462, :464).
    • Other packages/platform-apple/src files:
      • alert.ts:52 (:54): get, accept, dismiss.
      • core/runner-selector-query.ts:13 (:16), core/screenshot.ts:247 (:250), core/physical-device-screenshot.ts:68 (:71).
      • core/physical-device-control.ts:198 (activate :200) and :213 (terminate :215).
      • os/macos/desktop-scroll.ts:25 (:28), reached by scroll on macOS.
      • runner/runner-client.ts:121 (targetReset) and :216 (uptime, sent at :223).
      • runner/runner-lifecycle.ts:61 (uptime, sent at :464).
      • runner/runner-adoption.ts:236 (uptime :239, through sendRunnerCommandOnce).
      • runner/runner-session.ts:1109 (readiness uptime :1112, through waitForRunner).
      • runner/runner-command-recovery.ts:140 (status :143, through executeRunnerCommandWithSession).
      • runner/runner-disposal.ts:122 (shutdown :126, through waitForRunner).
  • Completeness assertions:
    • Every key of RUNNER_COMMAND_TRAITS (runner-command-traits.ts:54-89, which satisfies Record<RunnerCommand['command'], …> and so enumerates the union) has at least one entry in the whole file.
    • Every entry's producer is an existing test file whose source contains runner-requests.json.
    • Names are unique and sorted.

Screen-recording producers. Two existing test files in src/ own entries. Each already mocks runAppleRunnerCommand from @agent-device/platform-apple/runner/operations, so their entries have no commandId.

  • src/platform-runtime-screen-recording-apple-runner-transport.test.ts (mock at :12-13). Sites: src/platform-runtime-screen-recording-apple-runner-transport.ts:46 (local stop, recordStop :48), :66 (recordStart :69) and :83 (abort cleanup, recordStop :85). Cover fps present and absent, and outPath for a simulator, a physical device and macOS. Pin the clock with vi.setSystemTime before the physical-device start.
  • src/platform-runtime-screen-recording-apple-runner-host.test.ts (mock at :10-11). Site: src/platform-runtime-screen-recording-apple-runner-host.ts:91 (snapshot with interactiveOnly: true, depth: 1, :93).
  • In each file, one new test drives the exported functions and asserts deepEqual between the mock's captured requests and the entries whose producer is that file. That test never calls the mock directly. This is a review rule, not a check: both files have other tests that assert with request literals, so a source self-check does not fit.

Swift verifier. Add to UnitTests/RunnerTests+ModelsTests.swift (it mirrors RunnerTests+Models.swift). Load the fixture file by #filePath, as RunnerTests+LifecycleTests.swift:13-20 does.

  1. Every request decodes as Command, and command.command.rawValue == request["command"].
  2. Round trip: JSONEncoder re-encodes the decoded value, and its key set equals the fixture's key set, recursively through steps[] and gesturePlan. Compare key sets only, not number formatting. This catches a TS key that Swift silently ignores.
  3. CommandType.allCases minus the fixture commands is empty. The one production change: enum CommandType: String, Codable, CaseIterable.
  4. Every stored property of Command, SequenceStep and RunnerGesturePlan (with its nested viewport/pointer/sample/point) appears as a key in at least one fixture object at that level. Enumerate the properties with Mirror on a decoded minimal value, as testErrorPayloadEncodesEveryFieldButTheRunnerInternalRetryableFailure does. This check is per level, not per command: it proves a field has some producer, not that each command that reads it has one.

Failure messages name the entry, or the orphaned case or field.

If a check fails on current main because a Swift field or command truly has no producer, delete it in the same PR when the deletion is 30 lines or fewer. Otherwise stop and file it. Do not add an allowlist or exemption.

Step 2 (second PR): Swift unit tests derive their requests from the table

  • Replace runnerCommandFixture(_:) (UnitTests/RunnerTests+ModelsTests.swift:32) with two helpers:
    • productionRunnerRequest(_ name: String, replacing: [String: Any] = [:]) throws -> Command. It looks up the named entry and applies replacing only to keys already present. The JSON type must stay the same. Bool values, and string values of command, textEntryMode, action, selectorKey, direction, button, remoteButton, orientation, preferredBackend, scrollReleaseBehavior and step kind, cannot be replaced. Any other key or value fails the test.
    • malformedRunnerRequest(_ json: String) throws -> Command. It is for tests that assert INVALID_ARGS or a decode failure only.
  • Migrate every request built in UnitTests/ to these helpers. Count by the decode, not by the literal prefix: some literals start with #"{"appBundleId" (TextInputProbeTests:78,157). At 6debef0634:
    • 48 runnerCommandFixture( calls in 9 files: AlertDispatchTests, AlertObservationTests, CommandDispatchTests, CommandExecutionTests, CoordinateTextEntryTests, LifecycleTests, ScrollDragExecutionTests, TextInputProbeTests, TextTypingTests.
    • 14 inline Command.self decodes in 9 files: AXSnapshotFallbackTests:152,162,167, CommandDispatchTests:444, CommandJournalTests:311, RecordingTests:15, ScrollGestureTests:80,101, SelectorMatchPolicyTests:105, SequenceExecutionTests:15,183, SnapshotCapturePlanTests:332,356, TransportTests:8. Some are local helpers, some are inline decodes.
    • 17 files in all (CommandDispatchTests has both kinds).
    • SequenceStep( construction in SequenceExecutionTests:157: keep it for INVALID_ARGS tests only.
  • If a migrated test needs a shape that no entry has, the Swift branch it exercises has no producer. Delete the branch and its test, or add the missing producer drive in TS. Never add a hand-written entry.
  • Guard: a Swift test in RunnerTests+ModelsTests.swift lists the sibling UnitTests/*.swift files by #filePath. It fails when any file other than RunnerTests+ModelsTests.swift contains Command.self or runnerCommandFixture(. The production decoder at RunnerTests+Transport.swift:110 is outside UnitTests/. The guard is in Swift because check:affected selects only the swift-runner-* and xctest-selection lanes for a change under apple/runner/ (scripts/check-affected/model.ts:461-480): a TS guard would not run on a Swift-only change. The macOS host lane runs the whole macOS-compiled bundle on every PR (scripts/check-xctest-selection.ts), so this guard does.

Completion conditions

Step 1:

  • runner-contract.test.ts, the two recording tests and the four Swift tests pass on the macOS host lane (macos.yml) and on the unit lane. pnpm check:affected --run is green. contracts/fixtures/ already selects the unit lane and both Swift runner lanes (scripts/check-affected/model.ts:548-566).
  • The PR body lists the classified sender-grep hits (origin, forwarded, pass-through, not a request) at its base, and names the entry for each origin site.
  • Red on the old code, shown in the PR body with the failing assertion quoted: locally revert the back hunks of chore(ios-runner): remove unset env knobs, the dead back wire command, and unreachable paths #2828 (TS union member, trait row, Swift case). The TS completeness assertion fails ("back has no production request"), and so does Swift check 3.
  • Red on drift, shown in the PR body: (a) add an unused let legacy: Bool? to Swift Command, and check 4 fails; (b) add an extra key to one TS producer, and Swift check 2 fails after the fixture update; (c) add { command: 'home' } to the drive code, and the self-check fails.
  • pnpm check:xctest-selection, pnpm check:packaged-runner-swift, typecheck, lint, pnpm format (repository-wide) and pnpm check:production-exports are all green, with no new production TS exports.

Step 2:

  • No Command.self decode or runnerCommandFixture( call remains in UnitTests/ outside the helper file, and the Swift guard enforces this. Demonstrate by re-adding one literal: the guard fails on the host lane.
  • Red on the old code, shown in the PR body: the chore(ios-runner): delete the unreachable synthesized .continuous drag profile #2790 test request (drag plus synthesized: true) cannot be expressed. productionRunnerRequest("macos.interactions-drag.single", replacing: ["synthesized": true]) fails because the key is absent.
  • The host-lane executed test count still matches check:xctest-selection.

Non-goals

  • Daemon reachability. The drives start at platform-apple and src/ recording entry points, not at the daemon. A builder whose last daemon caller is removed still produces its entry, and every check stays green. Proving that the daemon sends a request needs daemon-level drives, which are out of scope.
  • Per-command field reading. Swift check 4 is per level. A field that one command carries (selectorKey on tap) satisfies it for every command. So a Swift handler that reads a field its command never receives (the selectorKey on type case) is caught only when a Swift test runs that branch: step 2 then makes the test request inexpressible. A branch with no test stays uncaught. A per-command field table would need hand-declared rules per command.
  • Value-level branch reachability (the refactor(ios-runner): restrict the tap-witness synthesized type route to newline #2800 kind). Step 2 still allows replacing text, so a test can send ordinary text through the type.newline entry. The shape then shows in the test as the newline entry with non-newline text, which a reviewer can see, but no check fails. Pinning value classes such as "newline vs text" would need hand-declared rules per field.
  • A source scan that proves every site is driven. The site list above is the classified sender grep at 6debef0634. The completeness assertion covers new commands, not new sites for an existing command.
  • Code generation of TS or Swift types, and a shared schema. The cross-review estimated a generator at 1–3 weeks and net growth in maintained lines.
  • A shared trait table. Traits differ on purpose, and alert-command-traits.json stays.
  • Response-shape fixtures. TS reads responses as unknown (runner-contract.ts:228).
  • RUNNER_PROTOCOL.md rewrites (docs(ios-runner): RUNNER_PROTOCOL.md points at the wrong file and omits recovery and busy codes #2797 owns the doc). Step 1 adds one line there that names contracts/fixtures/runner-requests.json as the request vocabulary.

Cost and size

  • Step 1: 2–3 days. Net production lines: 0 TS, +0 Swift (one CaseIterable conformance on an existing line). Gross diff about 800–950 lines: fixture about 250–320 lines (49 origin sites at 6debef0634, 70–80 compact one-line entries, gesture plans kept to two samples), TS producer tests and self-check about 380–450, recording tests about 70, Swift verifier about 90.
  • Step 2: 1–2 days. Net production lines: 0, or negative if it finds and deletes a dead branch. Gross diff about 400–600 lines, mostly the migration of 62 decodes across 17 files, plus the Swift guard (about 30 lines).
  • Each step fits in one PR under the 1,000-line gross budget. They do not fit together. If step 1 goes over, split the runner-internal sites (runner/*.ts) into their own PR.
  • Constraints checked: the change is tests plus one fixture plus one conformance, so there are no new modules, package subpaths or production exports. The eager-closure budgets (ADR 0019, ADR 0027), the R9 type-cycle ratchet and the fallow production-export and baseline checks are unaffected. The packaged runner strips UnitTests/, and CaseIterable compiles in the packaged source (check:packaged-runner-swift).

Dependencies / related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions