diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ef771b1b35..00e66b8708 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,8 @@ "node_modules/**", "**/*.md", "scripts/maestro-conformance/corpus/**", - "fallow-baselines/**" + "fallow-baselines/**", + // One entry per line, so each pinned runner request changes as one reviewable diff line. + "contracts/fixtures/runner-requests.json" ] } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 2a4b84de27..d33303a79f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -2,7 +2,7 @@ import AgentDeviceSnapshotPresentation // MARK: - Wire Models -enum CommandType: String, Codable { +enum CommandType: String, Codable, CaseIterable { case tap case mouseClick case longPress diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index eab82171dc..1931498727 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -32,5 +32,115 @@ extension RunnerTests { func runnerCommandFixture(_ json: String) throws -> Command { try JSONDecoder().decode(Command.self, from: Data(json.utf8)) } + + func testProductionRunnerRequestsDecodeWithoutDroppingAKey() throws { + for (name, request) in try productionRunnerRequests() { + let command = try decodeProductionRunnerRequest(request, name) + XCTAssertEqual(command.command.rawValue, request["command"] as? String, name) + let reencoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(command)) + XCTAssertEqual(runnerRequestKeyPaths(reencoded), runnerRequestKeyPaths(request), name) + } + } + + func testEveryRunnerCommandTypeHasAProductionRequest() throws { + let produced = Set(try productionRunnerRequests().compactMap { $0.request["command"] as? String }) + let orphaned = CommandType.allCases.map(\.rawValue).filter { !produced.contains($0) } + XCTAssertEqual(orphaned, [], "CommandType cases with no production request") + } + + func testEveryRunnerRequestFieldHasAProductionRequest() throws { + let entries = try productionRunnerRequests() + let requests = entries.map(\.request) + let steps = requests.flatMap { $0["steps"] as? [[String: Any]] ?? [] } + let plans = requests.compactMap { $0["gesturePlan"] as? [String: Any] } + let pointers = plans.flatMap { $0["pointers"] as? [[String: Any]] ?? [] } + let samples = pointers.flatMap { $0["samples"] as? [[String: Any]] ?? [] } + let commands = try entries.map { try decodeProductionRunnerRequest($0.request, $0.name) } + let command = try XCTUnwrap(commands.first, "no production request") + let plan = try XCTUnwrap( + commands.compactMap(\.gesturePlan).first, + "no production request carries a gesturePlan" + ) + let step = try JSONDecoder().decode(SequenceStep.self, from: Data(#"{"kind":"tap"}"#.utf8)) + let sample = try XCTUnwrap(plan.pointers.first?.samples.first) + assertEveryStoredField(of: command, appearsIn: requests, "Command") + assertEveryStoredField(of: step, appearsIn: steps, "SequenceStep") + assertEveryStoredField(of: plan, appearsIn: plans, "RunnerGesturePlan") + assertEveryStoredField( + of: plan.viewport, + appearsIn: plans.compactMap { $0["viewport"] as? [String: Any] }, + "RunnerGestureViewport" + ) + assertEveryStoredField(of: plan.pointers[0], appearsIn: pointers, "RunnerGesturePointer") + assertEveryStoredField(of: sample, appearsIn: samples, "RunnerGestureSample") + assertEveryStoredField( + of: sample.point, + appearsIn: samples.compactMap { $0["point"] as? [String: Any] }, + "RunnerGesturePoint" + ) + } + + private func productionRunnerRequests() throws -> [(name: String, request: [String: Any])] { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("contracts/fixtures/runner-requests.json") + let entries = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [[String: Any]] + ) + return try entries.map { entry in + ( + name: try XCTUnwrap(entry["name"] as? String), + request: try XCTUnwrap(entry["request"] as? [String: Any], "\(entry["name"] ?? "?")") + ) + } + } + + private func decodeProductionRunnerRequest(_ request: [String: Any], _ name: String) throws + -> Command + { + do { + return try JSONDecoder().decode( + Command.self, + from: JSONSerialization.data(withJSONObject: request) + ) + } catch { + XCTFail("\(name) does not decode as Command: \(error)") + throw error + } + } + + private func runnerRequestKeyPaths(_ value: Any, _ prefix: String = "") -> Set { + if let object = value as? [String: Any] { + return object.reduce(into: Set()) { paths, field in + paths.insert(prefix + field.key) + paths.formUnion(runnerRequestKeyPaths(field.value, "\(prefix)\(field.key).")) + } + } + if let array = value as? [Any] { + return array.reduce(into: Set()) { paths, element in + paths.formUnion(runnerRequestKeyPaths(element, "\(prefix)[].")) + } + } + return [] + } + + private func assertEveryStoredField( + of value: Any, + appearsIn objects: [[String: Any]], + _ level: String + ) { + let fields = Set(Mirror(reflecting: value).children.compactMap(\.label)) + let produced = Set(objects.flatMap(\.keys)) + XCTAssertEqual( + fields.subtracting(produced).sorted(), + [], + "\(level) fields with no production request" + ) + } } #endif diff --git a/apple/runner/RUNNER_PROTOCOL.md b/apple/runner/RUNNER_PROTOCOL.md index aa80ffac1e..24a9d5d35b 100644 --- a/apple/runner/RUNNER_PROTOCOL.md +++ b/apple/runner/RUNNER_PROTOCOL.md @@ -14,6 +14,7 @@ The daemon probes `http://127.0.0.1:/command` for simulator and desktop fl ## Request Shape Every request includes a `command` field. Additional fields depend on the command family. +The request vocabulary is `contracts/fixtures/runner-requests.json`: the requests production builds. Examples: diff --git a/contracts/fixtures/runner-requests.json b/contracts/fixtures/runner-requests.json new file mode 100644 index 0000000000..9a5ce46a00 --- /dev/null +++ b/contracts/fixtures/runner-requests.json @@ -0,0 +1,70 @@ +[ + {"name": "ios-device.physical-device-control.activate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "activate", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-device.physical-device-control.terminate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "terminate", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-device.physical-device-screenshot.coredevice-file", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "screenshot", "appBundleId": "com.example.app", "fullscreen": true, "inlineScreenshot": false, "commandId": ""}}, + {"name": "ios-device.physical-device-screenshot.xctest-inline", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "screenshot", "appBundleId": "com.example.app", "inlineScreenshot": true, "commandId": ""}}, + {"name": "ios-device.recording-start-abort.stop", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStop", "appBundleId": "com.example.app"}}, + {"name": "ios-device.recording-start.fps", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStart", "outPath": "agent-device-recording-1700000000000.mp4", "fps": 30, "appBundleId": "com.example.app"}}, + {"name": "ios-device.recording-stop.no-app", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStop"}}, + {"name": "ios-device.recording-stop.session", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStop", "appBundleId": "com.example.app"}}, + {"name": "ios-simulator.alert.accept", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "alert", "action": "accept", "appBundleId": "com.example.app", "timeoutMs": 10000, "commandId": ""}}, + {"name": "ios-simulator.alert.dismiss", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "alert", "action": "dismiss", "appBundleId": "com.example.app", "timeoutMs": 10000, "commandId": ""}}, + {"name": "ios-simulator.alert.get", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "alert", "action": "get", "appBundleId": "com.example.app", "timeoutMs": 10000, "commandId": ""}}, + {"name": "ios-simulator.interactions-double-tap.single", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "sequence", "steps": [{"kind": "doubleTap", "x": 10, "y": 20}], "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-fill.non-hittable-fallback", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "type", "x": 10, "y": 20, "text": "hello", "textEntryMode": "replace", "allowNonHittableCoordinateFallback": true, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-fill.replace", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "type", "x": 10, "y": 20, "text": "hello", "delayMs": 10, "textEntryMode": "replace", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-focus.synthesized", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "x": 10, "y": 20, "synthesized": true, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-gesture-viewport.read", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "gestureViewport", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-gesture.single", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "gesture", "gesturePlan": {"topology": "single", "intent": "pan", "executionProfile": "timed-pan", "durationMs": 120, "viewport": {"x": 0, "y": 0, "width": 390, "height": 844}, "pointers": [{"pointerId": 0, "samples": [{"offsetMs": 0, "point": {"x": 100, "y": 400}}, {"offsetMs": 120, "point": {"x": 100, "y": 200}}]}]}, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-gesture.two", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "gesture", "gesturePlan": {"topology": "two", "intent": "pinch", "durationMs": 250, "viewport": {"x": 0, "y": 0, "width": 390, "height": 844}, "pointers": [{"pointerId": 0, "samples": [{"offsetMs": 0, "point": {"x": 150, "y": 422}}, {"offsetMs": 250, "point": {"x": 100, "y": 422}}]}, {"pointerId": 1, "samples": [{"offsetMs": 0, "point": {"x": 240, "y": 422}}, {"offsetMs": 250, "point": {"x": 290, "y": 422}}]}]}, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-long-press.duration", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "longPress", "x": 10, "y": 20, "durationMs": 600, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-press-series.double-tap", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "sequence", "steps": [{"kind": "doubleTap", "x": 10, "y": 20, "pauseMs": 50}, {"kind": "doubleTap", "x": 10, "y": 20}], "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-press-series.long-press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "sequence", "steps": [{"kind": "longPress", "x": 10, "y": 20, "durationMs": 600, "pauseMs": 50}, {"kind": "longPress", "x": 10, "y": 20, "durationMs": 600}], "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-press-series.tap", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "sequence", "steps": [{"kind": "tap", "x": 10, "y": 20, "synthesized": true, "pauseMs": 50}, {"kind": "tap", "x": 10, "y": 20, "synthesized": true}], "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-scroll.amount", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "scroll", "direction": "down", "amount": 0.65, "durationMs": 400, "scrollReleaseBehavior": "controlled", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-scroll.inertial", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "scroll", "direction": "left", "amount": 0.5, "durationMs": 400, "scrollReleaseBehavior": "inertial", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-scroll.pixels", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "scroll", "direction": "up", "pixels": 200, "durationMs": 300, "scrollReleaseBehavior": "controlled", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-single-press-double-tap.single", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "sequence", "steps": [{"kind": "doubleTap", "x": 10, "y": 20}], "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-single-press-long-press.hold", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "longPress", "x": 10, "y": 20, "durationMs": 600, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-single-press-tap.synthesized", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "x": 10, "y": 20, "synthesized": true, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-tap-element-selector.expected-point", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "selectorKey": "label", "selectorValue": "Go", "allowNonHittableCoordinateFallback": true, "x": 10, "y": 20, "synthesized": true, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-tap.synthesized", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "x": 10, "y": 20, "synthesized": true, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-type.append", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "type", "text": "hello", "delayMs": 10, "textEntryMode": "append", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactions-type.newline", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "type", "text": "\n", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-action-button.press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "actionButton", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-app-switcher.open", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "appSwitcher", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-back.in-app", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "backInApp", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-back.system", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "backSystem", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-find-text.text", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "findText", "text": "Ready", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-home.press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "home", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-keyboard-dismiss.dismiss", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "keyboardDismiss", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-keyboard-enter.return", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "keyboardReturn", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-read-text.point", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "readText", "x": 10, "y": 20, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-set-orientation.rotate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "rotate", "orientation": "landscape-left", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-snapshot.every-option", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "preferredBackend": "tree", "customActions": true, "depth": 3, "scope": "Go", "raw": true, "commandId": ""}}, + {"name": "ios-simulator.recording-clock-anchor.snapshot", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "depth": 1}}, + {"name": "ios-simulator.recording-start.default", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStart", "outPath": "capture.mp4", "appBundleId": "com.example.app"}}, + {"name": "ios-simulator.runner-adoption.uptime", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "uptime", "commandId": ""}}, + {"name": "ios-simulator.runner-client-prepare.uptime", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "uptime", "commandId": ""}}, + {"name": "ios-simulator.runner-client-target-reset.relaunch", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "targetReset", "commandId": ""}}, + {"name": "ios-simulator.runner-command-recovery.status", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "status", "statusCommandId": ""}}, + {"name": "ios-simulator.runner-disposal.shutdown", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "shutdown", "commandId": ""}}, + {"name": "ios-simulator.runner-lifecycle-prepare.uptime", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "uptime", "commandId": ""}}, + {"name": "ios-simulator.runner-selector-query.label", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "querySelector", "selectorKey": "label", "selectorValue": "Go", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.runner-session-readiness.uptime", "producer": "packages/platform-apple/src/runner/__tests__/runner-requests.test.ts", "request": {"command": "uptime", "commandId": ""}}, + {"name": "macos.desktop-scroll.amount", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "desktopScroll", "direction": "down", "amount": 0.5, "durationMs": 300, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.desktop-scroll.pixels", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "desktopScroll", "direction": "up", "pixels": 200, "durationMs": 300, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.interactions-drag.single", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "drag", "x": 100, "y": 400, "x2": 100, "y2": 200, "durationMs": 120, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.interactions-focus.coordinate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "x": 10, "y": 20, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.interactions-mouse-click.middle", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "mouseClick", "x": 10, "y": 20, "button": "middle", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.interactions-mouse-click.secondary", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "mouseClick", "x": 10, "y": 20, "button": "secondary", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.interactions-single-press-tap.coordinate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "x": 10, "y": 20, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.interactions-tap.coordinate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "tap", "x": 10, "y": 20, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "macos.recording-start.output-path", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStart", "outPath": "/tmp/capture.mp4", "appBundleId": "com.example.app"}}, + {"name": "macos.screenshot-runner.fullscreen", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "screenshot", "appBundleId": "com.example.app", "fullscreen": true, "inlineScreenshot": false, "commandId": ""}}, + {"name": "tvos.interactions-scroll.remote-press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "remotePress", "remoteButton": "down", "durationMs": 300, "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "tvos.interactions-swipe.single", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "swipe", "direction": "up", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "tvos.interactor-back.remote-press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "remotePress", "remoteButton": "menu", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "tvos.interactor-home.remote-press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "remotePress", "remoteButton": "home", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "tvos.interactor-tv-remote.hold", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "remotePress", "remoteButton": "select", "durationMs": 500, "appBundleId": "com.example.app", "commandId": ""}} +] diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json index b1a9cc3868..acc2bd7c19 100644 --- a/packages/platform-apple/package.json +++ b/packages/platform-apple/package.json @@ -64,6 +64,10 @@ "types": "./src/runner-operations-facade.ts", "default": "./src/runner-operations-facade.ts" }, + "./runner/requests-fixtures": { + "types": "./src/runner/runner-requests.fixtures.ts", + "default": "./src/runner/runner-requests.fixtures.ts" + }, "./runner/test-host": { "types": "./src/runner/test-host.ts", "default": "./src/runner/test-host.ts" diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index d0106f3392..80589e957c 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -1,4 +1,3 @@ -import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types'; import type { Interactor, RunnerContext, @@ -9,14 +8,14 @@ import { AppError } from '@agent-device/kernel/errors'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; -import type { - AppleRunnerCommandOptions, - AppleRunnerProvider, - RunnerCommand, -} from '../runner/index.ts'; +import type { AppleRunnerProvider } from '../runner/index.ts'; import { createAppleInteractor } from '../interactor.ts'; - -type RecordedRunnerCall = { command: RunnerCommand; options: AppleRunnerCommandOptions }; +import { + recordingRunnerProvider, + runnerResultFor, + singlePointerPanPlan, + type RecordedRunnerCall, +} from './recording-runner-provider.ts'; function presentedSnapshot(result: SnapshotRuntimeResult): SnapshotResult { if ('stage' in result) throw new Error('Apple runner snapshot must be presented'); @@ -446,57 +445,3 @@ test('snapshot forwards either forceable preferredBackend into the emitted runne assert.equal(snapshots[0]?.command.preferredBackend, 'tree'); assert.equal(snapshots[1]?.command.preferredBackend, undefined); }); - -function recordingRunnerProvider(calls: RecordedRunnerCall[]): AppleRunnerProvider { - return { - hasLiveSession: () => true, - runCommand: async (_device, command, options) => { - calls.push({ command, options }); - return runnerResultFor(command); - }, - }; -} - -function runnerResultFor(command: RunnerCommand): Record { - switch (command.command) { - case 'snapshot': - return { - nodes: [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Go', - hittable: true, - rect: { x: 10, y: 10, width: 80, height: 40 }, - }, - ], - }; - case 'gestureViewport': - return { x: 0, y: 0, x2: 390, y2: 844 }; - case 'rotate': - return { orientation: command.orientation }; - default: - return {}; - } -} - -function singlePointerPanPlan(): GesturePlan { - return { - topology: 'single', - intent: 'pan', - executionProfile: 'timed-pan', - durationMs: 120, - viewport: { x: 0, y: 0, width: 390, height: 844 }, - pointers: [ - { - pointerId: 0, - samples: [ - { offsetMs: 0, point: { x: 100, y: 400 } }, - { offsetMs: 120, point: { x: 100, y: 200 } }, - ], - }, - ], - }; -} diff --git a/packages/platform-apple/src/__tests__/recording-runner-provider.ts b/packages/platform-apple/src/__tests__/recording-runner-provider.ts new file mode 100644 index 0000000000..5c97b0745b --- /dev/null +++ b/packages/platform-apple/src/__tests__/recording-runner-provider.ts @@ -0,0 +1,72 @@ +import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types'; +import type { + AppleRunnerCommandOptions, + AppleRunnerProvider, + RunnerCommand, +} from '../runner/index.ts'; + +export type RecordedRunnerCall = Readonly<{ + command: RunnerCommand; + options: AppleRunnerCommandOptions; +}>; + +/** A runner transport that answers every command with a minimal valid result and records it. */ +export function recordingRunnerProvider( + calls: RecordedRunnerCall[], + results: Partial>> = {}, +): AppleRunnerProvider { + return { + hasLiveSession: () => true, + runCommand: async (_device, command, options) => { + calls.push({ command, options }); + return results[command.command] ?? runnerResultFor(command); + }, + }; +} + +export function runnerResultFor(sent: Pick) { + switch (sent.command) { + case 'snapshot': + return { + nodes: [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Go', + hittable: true, + rect: { x: 10, y: 10, width: 80, height: 40 }, + }, + ], + }; + case 'gestureViewport': + return { x: 0, y: 0, x2: 390, y2: 844 }; + case 'rotate': + return { orientation: sent.orientation }; + case 'scroll': + case 'desktopScroll': + return { referenceWidth: 390, referenceHeight: 844 }; + default: + return {}; + } +} + +export function singlePointerPanPlan(): GesturePlan { + return { + topology: 'single', + intent: 'pan', + executionProfile: 'timed-pan', + durationMs: 120, + viewport: { x: 0, y: 0, width: 390, height: 844 }, + pointers: [ + { + pointerId: 0, + samples: [ + { offsetMs: 0, point: { x: 100, y: 400 } }, + { offsetMs: 120, point: { x: 100, y: 200 } }, + ], + }, + ], + }; +} diff --git a/packages/platform-apple/src/__tests__/runner-requests.test.ts b/packages/platform-apple/src/__tests__/runner-requests.test.ts new file mode 100644 index 0000000000..f471daa08d --- /dev/null +++ b/packages/platform-apple/src/__tests__/runner-requests.test.ts @@ -0,0 +1,266 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types'; +import type { Interactor, PressPointOptions } from '@agent-device/contracts/interactor-types'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createAppleInteractor } from '../interactor.ts'; +import { resolveIosPhysicalDeviceControl } from '../core/physical-device-control.ts'; +import { runAppleRunnerCommand } from '../core/runner-client.ts'; +import { queryAppleRunnerSelector } from '../core/runner-selector-query.ts'; +import { captureScreenshotViaRunner } from '../core/screenshot.ts'; +import { withAppleRunnerProvider, type RunnerCommand } from '../runner/index.ts'; +import { + IOS_DEVICE, + IOS_SIMULATOR, + MACOS_DEVICE, + TVOS_SIMULATOR, +} from '../runner/__tests__/device-fixtures.ts'; +import { assertProducedRunnerRequests } from '../runner/runner-requests.fixtures.ts'; +import { + recordingRunnerProvider, + singlePointerPanPlan, + type RecordedRunnerCall, +} from './recording-runner-provider.ts'; +import { mkdtempForTest } from './tmp-dir.ts'; + +// Every Apple runner request site outside `runner/`, driven through its production entry point and +// pinned to contracts/fixtures/runner-requests.json. The drives never write a request themselves; +// runner/__tests__/runner-requests.test.ts reads this source to keep it that way. + +const APP = 'com.example.app'; +const XCTEST_DEVICE: DeviceInfo = { ...IOS_DEVICE, iosPhysicalDeviceBackend: 'xctest' }; + +type InteractorDrive = readonly [DeviceInfo, (interactor: Interactor) => Promise]; + +function press(overrides: Partial = {}) { + return (interactor: Interactor) => + interactor.pressPoint!( + { x: 10, y: 20 }, + { + button: 'primary', + count: 1, + intervalMs: 0, + holdMs: 0, + jitterPx: 0, + doubleTap: false, + ...overrides, + }, + ); +} + +const INTERACTOR_SITES: Record = { + 'ios-simulator.interactions-tap.synthesized': [IOS_SIMULATOR, (i) => i.tap(10, 20)], + 'macos.interactions-tap.coordinate': [MACOS_DEVICE, (i) => i.tap(10, 20)], + 'ios-simulator.interactions-focus.synthesized': [IOS_SIMULATOR, (i) => i.focus(10, 20)], + 'macos.interactions-focus.coordinate': [MACOS_DEVICE, (i) => i.focus(10, 20)], + 'ios-simulator.interactions-single-press-tap.synthesized': [IOS_SIMULATOR, press()], + 'macos.interactions-single-press-tap.coordinate': [MACOS_DEVICE, press()], + 'ios-simulator.interactions-tap-element-selector.expected-point': [ + IOS_SIMULATOR, + (i) => + i.tapElementSelector!({ + key: 'label', + value: 'Go', + allowNonHittableCoordinateFallback: true, + expectedPoint: { x: 10, y: 20 }, + }), + ], + 'ios-simulator.interactions-double-tap.single': [IOS_SIMULATOR, (i) => i.doubleTap!(10, 20)], + 'ios-simulator.interactions-long-press.duration': [ + IOS_SIMULATOR, + (i) => i.longPress(10, 20, 600), + ], + 'ios-simulator.interactions-type.append': [IOS_SIMULATOR, (i) => i.type('hello', 10)], + 'ios-simulator.interactions-type.newline': [IOS_SIMULATOR, (i) => i.type('\n')], + 'ios-simulator.interactions-fill.replace': [IOS_SIMULATOR, (i) => i.fill(10, 20, 'hello', 10)], + 'ios-simulator.interactions-fill.non-hittable-fallback': [ + IOS_SIMULATOR, + (i) => i.fill(10, 20, 'hello', undefined, { allowNonHittableCoordinateFallback: true }), + ], + 'ios-simulator.interactions-gesture-viewport.read': [IOS_SIMULATOR, (i) => i.gestureViewport!()], + 'ios-simulator.interactions-press-series.tap': [ + IOS_SIMULATOR, + press({ count: 2, intervalMs: 50 }), + ], + 'ios-simulator.interactions-press-series.double-tap': [ + IOS_SIMULATOR, + press({ count: 2, intervalMs: 50, doubleTap: true }), + ], + 'ios-simulator.interactions-press-series.long-press': [ + IOS_SIMULATOR, + press({ count: 2, intervalMs: 50, holdMs: 600 }), + ], + 'macos.interactions-mouse-click.secondary': [MACOS_DEVICE, press({ button: 'secondary' })], + 'macos.interactions-mouse-click.middle': [MACOS_DEVICE, press({ button: 'middle' })], + 'ios-simulator.interactions-single-press-double-tap.single': [ + IOS_SIMULATOR, + press({ doubleTap: true }), + ], + 'ios-simulator.interactions-single-press-long-press.hold': [ + IOS_SIMULATOR, + press({ holdMs: 600 }), + ], + 'macos.interactions-drag.single': [ + MACOS_DEVICE, + (i) => i.performGesture!(singlePointerPanPlan()), + ], + 'tvos.interactions-swipe.single': [ + TVOS_SIMULATOR, + (i) => i.performGesture!(singlePointerPanPlan()), + ], + 'ios-simulator.interactions-gesture.single': [ + IOS_SIMULATOR, + (i) => i.performGesture!(singlePointerPanPlan()), + ], + 'ios-simulator.interactions-gesture.two': [IOS_SIMULATOR, (i) => i.performGesture!(pinchPlan())], + 'tvos.interactions-scroll.remote-press': [ + TVOS_SIMULATOR, + (i) => i.scroll('down', { durationMs: 300 }), + ], + 'ios-simulator.interactions-scroll.amount': [IOS_SIMULATOR, (i) => i.scroll('down')], + 'ios-simulator.interactions-scroll.pixels': [ + IOS_SIMULATOR, + (i) => i.scroll('up', { pixels: 200, durationMs: 300 }), + ], + 'ios-simulator.interactions-scroll.inertial': [ + IOS_SIMULATOR, + (i) => i.scroll('left', { amount: 0.5, releaseBehavior: 'inertial' }), + ], + 'macos.desktop-scroll.amount': [ + MACOS_DEVICE, + (i) => i.scroll('down', { amount: 0.5, durationMs: 300 }), + ], + 'macos.desktop-scroll.pixels': [ + MACOS_DEVICE, + (i) => i.scroll('up', { pixels: 200, durationMs: 300 }), + ], + 'ios-simulator.interactor-find-text.text': [ + IOS_SIMULATOR, + (i) => i.findText!('Ready', { appBundleId: APP }), + ], + 'tvos.interactor-back.remote-press': [TVOS_SIMULATOR, (i) => i.back()], + 'ios-simulator.interactor-back.in-app': [IOS_SIMULATOR, (i) => i.back()], + 'ios-simulator.interactor-back.system': [IOS_SIMULATOR, (i) => i.back('system')], + 'tvos.interactor-home.remote-press': [TVOS_SIMULATOR, (i) => i.home!()], + 'ios-simulator.interactor-home.press': [IOS_SIMULATOR, (i) => i.home!()], + 'ios-simulator.interactor-set-orientation.rotate': [ + IOS_SIMULATOR, + (i) => i.setOrientation('landscape-left'), + ], + 'ios-simulator.interactor-app-switcher.open': [IOS_SIMULATOR, (i) => i.appSwitcher!()], + 'ios-simulator.interactor-action-button.press': [IOS_SIMULATOR, (i) => i.actionButton!()], + 'tvos.interactor-tv-remote.hold': [TVOS_SIMULATOR, (i) => i.tvRemote!('select', 500)], + 'ios-simulator.interactor-keyboard-dismiss.dismiss': [IOS_SIMULATOR, (i) => i.keyboardDismiss!()], + 'ios-simulator.interactor-keyboard-enter.return': [IOS_SIMULATOR, (i) => i.keyboardEnter!()], + 'ios-simulator.interactor-snapshot.every-option': [ + IOS_SIMULATOR, + (i) => + i.snapshot({ + appBundleId: APP, + interactiveOnly: true, + preferredBackend: 'tree', + customActions: true, + depth: 3, + scope: 'Go', + raw: true, + }), + ], + 'ios-simulator.interactor-read-text.point': [ + IOS_SIMULATOR, + (i) => i.readTextAtPoint!({ x: 10, y: 20 }, { appBundleId: APP }), + ], + 'ios-simulator.alert.get': [IOS_SIMULATOR, (i) => i.readAlert!({ appBundleId: APP })], + 'ios-simulator.alert.accept': [IOS_SIMULATOR, (i) => i.acceptAlert!({ appBundleId: APP })], + 'ios-simulator.alert.dismiss': [IOS_SIMULATOR, (i) => i.dismissAlert!({ appBundleId: APP })], +}; + +type ScopedDrive = readonly [DeviceInfo, (outPath: string) => Promise]; + +const SCOPED_SITES: Record = { + 'ios-simulator.runner-selector-query.label': [ + IOS_SIMULATOR, + () => queryAppleRunnerSelector(IOS_SIMULATOR, { key: 'label', value: 'Go' }, APP, {}), + ], + 'macos.screenshot-runner.fullscreen': [ + MACOS_DEVICE, + (outPath) => captureScreenshotViaRunner(MACOS_DEVICE, outPath, APP, true), + ], + 'ios-device.physical-device-screenshot.coredevice-file': [ + IOS_DEVICE, + (outPath) => captureScreenshotViaRunner(IOS_DEVICE, outPath, APP, true), + ], + 'ios-device.physical-device-screenshot.xctest-inline': [ + XCTEST_DEVICE, + (outPath) => captureScreenshotViaRunner(XCTEST_DEVICE, outPath, APP), + ], + 'ios-device.physical-device-control.activate': [ + XCTEST_DEVICE, + () => + resolveIosPhysicalDeviceControl(XCTEST_DEVICE).launchApp(XCTEST_DEVICE, APP, { + runRunnerCommand: runAppleRunnerCommand, + }), + ], + 'ios-device.physical-device-control.terminate': [ + XCTEST_DEVICE, + () => + resolveIosPhysicalDeviceControl(XCTEST_DEVICE).terminateApp(XCTEST_DEVICE, APP, { + runRunnerCommand: runAppleRunnerCommand, + }), + ], +}; + +test('every Apple request site builds exactly its golden runner request', async () => { + const captured: Array = []; + for (const [name, [device, drive]] of Object.entries(INTERACTOR_SITES)) { + const calls: RecordedRunnerCall[] = []; + await drive( + createAppleInteractor(device, { appBundleId: APP }, recordingRunnerProvider(calls)), + ); + captured.push([name, onlyRequest(name, calls)]); + } + const dir = await mkdtempForTest('agent-device-runner-requests-'); + const runnerScreenshot = path.join(dir, 'runner.png'); + fs.writeFileSync(runnerScreenshot, ''); + for (const [name, [device, drive]] of Object.entries(SCOPED_SITES)) { + const calls: RecordedRunnerCall[] = []; + const provider = recordingRunnerProvider(calls, { + screenshot: { message: runnerScreenshot, imageBase64: 'AA==' }, + }); + await withAppleRunnerProvider(provider, { deviceId: device.id }, () => + drive(path.join(dir, `${name}.png`)), + ); + captured.push([name, onlyRequest(name, calls)]); + } + assertProducedRunnerRequests(import.meta.filename, captured); +}); + +function onlyRequest(name: string, calls: RecordedRunnerCall[]): RunnerCommand { + if (calls.length !== 1) throw new Error(`${name} sent ${calls.length} runner requests`); + return calls[0]!.command; +} + +function pinchPlan(): GesturePlan { + return { + topology: 'two', + intent: 'pinch', + durationMs: 250, + viewport: { x: 0, y: 0, width: 390, height: 844 }, + pointers: [ + { + pointerId: 0, + samples: [ + { offsetMs: 0, point: { x: 150, y: 422 } }, + { offsetMs: 250, point: { x: 100, y: 422 } }, + ], + }, + { + pointerId: 1, + samples: [ + { offsetMs: 0, point: { x: 240, y: 422 } }, + { offsetMs: 250, point: { x: 290, y: 422 } }, + ], + }, + ], + }; +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts b/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts new file mode 100644 index 0000000000..bb6a13fda8 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-requests.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { tryAdoptRunnerSessionFromLease } from '../runner-adoption.ts'; +import { notifyIosRunnerAppRelaunched, prepareIosRunner } from '../runner-client.ts'; +import { RUNNER_COMMAND_TRAITS } from '../runner-command-traits.ts'; +import type { RunnerCommand } from '../runner-contract.ts'; +import { disposeRunnerSession } from '../runner-disposal.ts'; +import { buildRunnerLease, writeRunnerLease } from '../runner-lease.ts'; +import { executeRunnerCommand, prepareLocalIosRunner } from '../runner-lifecycle.ts'; +import { withAppleRunnerProvider } from '../runner-provider.ts'; +import { + assertProducedRunnerRequests, + readRunnerRequestFixture, + REPO_ROOT, +} from '../runner-requests.fixtures.ts'; +import { runApplePressSeries } from '../runner-sequence.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; +import { makeRunnerSession } from './runner-session-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const runnerState = vi.hoisted(() => ({ ensureRunnerSession: vi.fn(), derivedPath: '' })); + +vi.mock('../runner-session.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureRunnerSession: runnerState.ensureRunnerSession, +})); + +vi.mock('../runner-xctestrun.ts', async (importOriginal) => ({ + ...(await importOriginal()), + resolveExpectedRunnerCacheMetadata: () => ({}), + resolveRunnerDerivedPath: () => runnerState.derivedPath, +})); + +const APP = 'com.example.app'; + +let server: FakeRunnerServer | undefined; +let scratch: string; + +beforeEach(() => { + scratch = mkdtempForTestSync('agent-device-runner-contract-'); + runnerState.derivedPath = path.join(scratch, 'derived'); + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = path.join(scratch, 'leases'); +}); + +afterEach(async () => { + delete process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR; + await server?.close(); + server = undefined; +}); + +test('runner-internal request sites build exactly their runner-requests.json entries', async () => { + const sent: RunnerCommand[] = []; + await withAppleRunnerProvider( + async (_device, request) => { + sent.push(request); + return {}; + }, + { deviceId: IOS_SIMULATOR.id }, + async () => await prepareIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 1_000 }), + ); + + server = await startFakeRunnerServer({ + targetReset: [{ kind: 'hangUp' }], + status: [ + { + kind: 'ok', + data: { lifecycleState: 'completed', lifecycleResponseJson: '{"ok":true,"data":{}}' }, + }, + ], + }); + const session = makeRunnerSession({ + port: server.port, + xctestrunPath: path.join(scratch, 'runner.xctestrun'), + jsonPath: path.join(scratch, 'runner.json'), + }); + runnerState.ensureRunnerSession.mockResolvedValue(session); + appleRunnerTestHost.update({ + isProcessAlive: () => false, + isProcessGroupAlive: () => false, + runXcrun: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + }); + await prepareLocalIosRunner(IOS_SIMULATOR, { healthTimeoutMs: 5_000 }); + await runApplePressSeries( + IOS_SIMULATOR, + { x: 10, y: 20 }, + { button: 'primary', count: 2, intervalMs: 0, holdMs: 0, jitterPx: 0, doubleTap: false }, + APP, + async (request) => await executeRunnerCommand(IOS_SIMULATOR, request, {}), + ); + await notifyIosRunnerAppRelaunched(IOS_SIMULATOR); + await disposeRunnerSession(session); + + appleRunnerTestHost.update({ + isProcessAlive: () => true, + readProcessCommand: () => null, + readProcessStartTime: () => 'test-process-start', + }); + writeRunnerLease({ + ...buildRunnerLease({ + deviceId: IOS_SIMULATOR.id, + sessionId: `${IOS_SIMULATOR.id}:${server.port}:1`, + runnerPid: 424242, + port: server.port, + xctestrunPath: path.join(runnerState.derivedPath, 'Build', 'Products', 'r.xctestrun'), + jsonPath: path.join(runnerState.derivedPath, 'Build', 'Products', 'r.json'), + }), + ownerToken: 'owner-99999-deadbeef', + ownerPid: 99999, + ownerStartTime: 'not-a-real-start-time', + }); + assert.ok(await tryAdoptRunnerSessionFromLease(IOS_SIMULATOR, {}), 'the lease was not adopted'); + + const received = server.requests.map((request) => request.body); + assert.deepEqual( + received.map((request) => request.command), + ['uptime', 'uptime', 'sequence', 'targetReset', 'status', 'shutdown', 'uptime'], + ); + assertProducedRunnerRequests(import.meta.filename, [ + ['ios-simulator.runner-client-prepare.uptime', sent[0]], + ['ios-simulator.runner-lifecycle-prepare.uptime', received[0]], + ['ios-simulator.runner-session-readiness.uptime', received[1]], + ['ios-simulator.runner-client-target-reset.relaunch', received[3]], + ['ios-simulator.runner-command-recovery.status', received[4]], + ['ios-simulator.runner-disposal.shutdown', received[5]], + ['ios-simulator.runner-adoption.uptime', received[6]], + ]); +}); + +test('runner-requests.json has a production request for every runner command', () => { + const entries = readRunnerRequestFixture(); + const names = entries.map((entry) => entry.name); + assert.deepEqual(names, [...new Set(names)].sort()); + const produced = new Set(entries.map((entry) => entry.request.command)); + assert.deepEqual( + Object.keys(RUNNER_COMMAND_TRAITS).filter((name) => !produced.has(name)), + [], + 'runner commands with no production request', + ); + for (const producer of new Set(entries.map((entry) => entry.producer))) { + const source = fs.readFileSync(path.join(REPO_ROOT, producer), 'utf8'); + assert.ok( + producer.endsWith('runner-requests.test.ts') && + source.includes('assertProducedRunnerRequests(import.meta.filename'), + `${producer} must be a dedicated *runner-requests.test.ts drive that checks its own entries`, + ); + } +}); + +const REQUEST_LITERAL = /\bcommand\s*:(?!\s*RunnerCommand\b)/; +const DIRECT_SEND = new RegExp( + `\\b(?:${['runAppleRunnerCommand', 'executeRunnerCommandWithSession', 'waitForRunner', 'sendRunnerCommandOnce'].join('|')})\\s*\\(`, +); + +test('runner request drives send only requests production builds', () => { + const producers = readRunnerRequestFixture().map((entry) => path.join(REPO_ROOT, entry.producer)); + const sources = [ + ...new Set(producers), + path.join(import.meta.dirname, '../runner-requests.fixtures.ts'), + path.join(import.meta.dirname, '../../__tests__/recording-runner-provider.ts'), + ]; + const offending = sources.flatMap((file) => + fs + .readFileSync(file, 'utf8') + .split('\n') + .flatMap((line, index) => + REQUEST_LITERAL.test(line) || DIRECT_SEND.test(line) + ? [`${path.relative(REPO_ROOT, file)}:${index + 1}: ${line.trim()}`] + : [], + ), + ); + assert.deepEqual(offending, []); +}); diff --git a/packages/platform-apple/src/runner/runner-requests.fixtures.ts b/packages/platform-apple/src/runner/runner-requests.fixtures.ts new file mode 100644 index 0000000000..379f468048 --- /dev/null +++ b/packages/platform-apple/src/runner/runner-requests.fixtures.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export type RunnerRequestEntry = { + name: string; + producer: string; + request: Record; +}; + +export const REPO_ROOT = fileURLToPath(new URL('../../../../', import.meta.url)); + +const COMMAND_ID_KEYS = ['commandId', 'statusCommandId']; + +export function readRunnerRequestFixture(): RunnerRequestEntry[] { + return JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'contracts/fixtures/runner-requests.json'), 'utf8'), + ) as RunnerRequestEntry[]; +} + +/** The request as the wire carries it, with the random command ids pinned. */ +function wireRunnerRequest(sent: unknown): Record { + const request = JSON.parse(JSON.stringify(sent)) as Record; + for (const key of COMMAND_ID_KEYS) { + if (key in request) request[key] = ''; + } + return request; +} + +/** + * Pins the requests one producer test captured, by site name, to that producer's fixture entries. + * On a mismatch the diff shows the entries to paste into the fixture. + */ +export function assertProducedRunnerRequests( + producerFile: string, + captured: ReadonlyArray, +): void { + const producer = path.relative(REPO_ROOT, producerFile); + const produced = captured + .map(([name, sent]) => ({ name, producer, request: wireRunnerRequest(sent) })) + .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)); + assert.deepEqual( + produced, + readRunnerRequestFixture().filter((entry) => entry.producer === producer), + ); +} diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index d852129882..222b622719 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -541,6 +541,8 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/platform-apple/runner', '@agent-device/platform-apple/runner-owner', '@agent-device/platform-apple/runner/operations', + // Test-only entry: the runner-requests.json check package and root tests share. + '@agent-device/platform-apple/runner/requests-fixtures', '@agent-device/platform-apple/runner/test-host', '@agent-device/platform-apple/session-observation', '@agent-device/platform-apple/simctl', diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index 0ca48d2802..4daad31e92 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -38,6 +38,7 @@ function declarations(): PlatformPackageDeclaration[] { '@agent-device/platform-apple/runner', '@agent-device/platform-apple/runner/test-host', '@agent-device/platform-apple/runner/operations', + '@agent-device/platform-apple/runner/requests-fixtures', '@agent-device/platform-apple/runner-owner', '@agent-device/platform-apple/session-observation', '@agent-device/platform-apple/simctl', diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index 6b50f48f70..6d6b1a055d 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -86,6 +86,7 @@ const MECHANICS_FACET_SUBPATHS: Readonly ({ run: vi.fn(), snapshot: vi.fn() })); + +vi.mock('@agent-device/platform-apple/runner/operations', () => ({ + runAppleRunnerCommand: runner.run, + readRunnerSessionLiveness: runner.snapshot, +})); + +const device = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'device', + name: 'iPhone', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, +}; +const simulator = { ...device, kind: 'simulator' as const, id: 'sim' }; +const macosDevice = { + ...device, + appleOs: 'macos' as const, + id: 'host-macos-local', + name: 'Mac', + target: 'desktop' as const, +}; + +test('screen recording runner requests match their runner-requests.json entries', async () => { + runner.snapshot.mockReturnValue({ sessionId: 'runner-session-1', liveness: 'ready' }); + runner.run.mockResolvedValue({}); + const transport = resolveAppleRunnerScreenRecordingTransport(); + const request = { appBundleId: 'com.example.app', outputPath: '/tmp/capture.mp4' }; + + vi.setSystemTime(1_700_000_000_000); + try { + await transport.start({ ...request, device, fps: 30 }); + } finally { + vi.useRealTimers(); + } + await transport.start({ ...request, device: simulator }); + await transport.start({ ...request, device: macosDevice }); + await transport.stop({ + device, + runnerSessionId: 'runner-session-1', + appBundleId: 'com.example.app', + }); + await transport.stop({ device, runnerSessionId: 'runner-session-1' }); + const controller = new AbortController(); + const reason = new Error('cancel after runner acquisition'); + runner.snapshot.mockImplementationOnce(() => { + controller.abort(reason); + return { sessionId: 'runner-session-2', liveness: 'ready' }; + }); + await expect(transport.start({ ...request, device, signal: controller.signal })).rejects.toBe( + reason, + ); + await captureAppleClockAnchor(simulator, 'com.example.app'); + + const sent = runner.run.mock.calls.map((call) => call[1]); + assertProducedRunnerRequests(import.meta.filename, [ + ['ios-device.recording-start.fps', sent[0]], + ['ios-simulator.recording-start.default', sent[1]], + ['macos.recording-start.output-path', sent[2]], + ['ios-device.recording-stop.session', sent[3]], + ['ios-device.recording-stop.no-app', sent[4]], + ['ios-device.recording-start-abort.stop', sent[6]], + ['ios-simulator.recording-clock-anchor.snapshot', sent[7]], + ]); +});