diff --git a/docs/adr/0005-ios-runner-interaction-lifecycle.md b/docs/adr/0005-ios-runner-interaction-lifecycle.md index e22bcb9ca..25b5e695a 100644 --- a/docs/adr/0005-ios-runner-interaction-lifecycle.md +++ b/docs/adr/0005-ios-runner-interaction-lifecycle.md @@ -35,7 +35,7 @@ startup commands still skip that preflight because the first successful command proof for a newly launched runner. Readiness probe commands skip preflight to avoid recursion. The daemon may additionally skip the ready-session `uptime` preflight for an explicit allowlist of -mutating interactions (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`, `scroll`) when the same +mutating interactions (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`, `scroll`, `sequence`) when the same session produced a healthy mutating response — parsed ok and not carrying `runnerFatal` — for the same `appBundleId` within 5 seconds. This recency lives only on the `RunnerSession` object as `lastHealthyMutation`, so it dies with every invalidation/restart, and it is recorded only after the diff --git a/docs/ios-runner-protocol-optimizations.md b/docs/ios-runner-protocol-optimizations.md index 2f2ccf831..8bada8cae 100644 --- a/docs/ios-runner-protocol-optimizations.md +++ b/docs/ios-runner-protocol-optimizations.md @@ -57,7 +57,7 @@ Acceptance criteria (as shipped): (conservative) commands still preflight; readiness probes and read-only startup commands keep their existing skips. - Recency is derived only from healthy (parsed ok, non-`runnerFatal`) responses of an explicit - mutating allowlist (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`, `scroll`) for the same + mutating allowlist (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`, `scroll`, `sequence`) for the same `appBundleId`, within a 5s freshness window, and lives only on the session object so it dies with every invalidation/restart. Snapshots and read-only responses never refresh it. - A transport failure after a skipped preflight clears the recency record and marks the error with diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 57eb42349..9eb2f00ec 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -17,7 +17,7 @@ extension RunnerTests { min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120) } - private func coordinateDragHoldDuration() -> TimeInterval { + func coordinateDragHoldDuration() -> TimeInterval { 0.050 } @@ -84,7 +84,7 @@ extension RunnerTests { /// /// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap /// it in the scroll idle-timeout/quiescence-skip path and change its runtime behavior. - private func performGesture( + func performGesture( _ app: XCUIApplication, idleTimeout: Bool = true, _ action: () -> RunnerInteractionOutcome @@ -969,6 +969,8 @@ extension RunnerTests { return response } return gestureResponse(message: "pinched", timing: timing) + case .sequence: + return executeSequence(command: command, activeApp: activeApp) case .rotateGesture: guard let degrees = command.degrees, degrees.isFinite else { return Response(ok: false, error: ErrorPayload(message: "rotateGesture requires degrees")) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index d69ad666d..d96906007 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -146,8 +146,8 @@ final class RunnerCommandJournal { case .tap, .mouseClick, .tapSeries, .longPress, .interactionFrame, .drag, .dragSeries, .remotePress, .type, .swipe, .scroll, .findText, .querySelector, .readText, .back, .backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn, - .alert, .pinch, .rotateGesture, .transformGesture, .recordStart, .recordStop, .status, - .uptime, .shutdown: + .alert, .pinch, .sequence, .rotateGesture, .transformGesture, .recordStart, .recordStop, + .status, .uptime, .shutdown: return true } } @@ -294,6 +294,82 @@ extension RunnerTests { XCTAssertEqual(status.lifecycleErrorHint, hint) } + func testCommandJournalRetainsCompletedSequenceResults() throws { + let journal = RunnerCommandJournal() + let sequence = runnerJournalCommand("sequence", id: "sequence-completed") + let results = (0..<20).map { _ in + SequenceStepResult( + ok: true, + kind: "tap", + errorCode: nil, + errorMessage: nil, + gestureStartUptimeMs: 100, + gestureEndUptimeMs: 120 + ) + } + + journal.accept(command: sequence) + journal.finish( + command: sequence, + response: Response( + ok: true, + data: DataPayload( + message: "sequence", + completedSteps: 20, + failedStepIndex: nil, + sequenceResults: results + ) + ) + ) + + let status = journal.status(commandId: "sequence-completed") + XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.completed.rawValue) + XCTAssertEqual(status.lifecycleResponseOk, true) + let json = try XCTUnwrap(status.lifecycleResponseJson) + // Worst-case 20-step response must stay under the 16KB journal retention cap. + XCTAssertLessThan(json.utf8.count, 16 * 1024) + let decoded = try decodeRunnerJournalResponse(status.lifecycleResponseJson) + XCTAssertEqual(decoded.data?.completedSteps, 20) + XCTAssertEqual(decoded.data?.sequenceResults?.count, 20) + } + + func testCommandJournalRetainsFailedSequenceResults() throws { + let journal = RunnerCommandJournal() + let sequence = runnerJournalCommand("sequence", id: "sequence-failed") + let longError = String(repeating: "z", count: 200) + let results: [SequenceStepResult] = [ + SequenceStepResult(ok: true, kind: "tap", errorCode: nil, errorMessage: nil, + gestureStartUptimeMs: 100, gestureEndUptimeMs: 120), + SequenceStepResult(ok: true, kind: "tap", errorCode: nil, errorMessage: nil, + gestureStartUptimeMs: 130, gestureEndUptimeMs: 150), + SequenceStepResult(ok: false, kind: "drag", errorCode: "UNSUPPORTED_OPERATION", + errorMessage: longError, gestureStartUptimeMs: 160, gestureEndUptimeMs: 180), + ] + + journal.accept(command: sequence) + journal.finish( + command: sequence, + response: Response( + ok: true, + data: DataPayload( + message: "sequence", + completedSteps: 2, + failedStepIndex: 2, + sequenceResults: results + ) + ) + ) + + let status = journal.status(commandId: "sequence-failed") + XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.completed.rawValue) + let decoded = try decodeRunnerJournalResponse(status.lifecycleResponseJson) + XCTAssertEqual(decoded.data?.completedSteps, 2) + XCTAssertEqual(decoded.data?.failedStepIndex, 2) + XCTAssertEqual(decoded.data?.sequenceResults?.count, 3) + XCTAssertEqual(decoded.data?.sequenceResults?[2].ok, false) + XCTAssertEqual(decoded.data?.sequenceResults?[2].errorCode, "UNSUPPORTED_OPERATION") + } + private func runnerJournalCommand(_ command: String, id: String) -> Command { let json = #"{"command":"\#(command)","commandId":"\#(id)"}"# return try! JSONDecoder().decode(Command.self, from: Data(json.utf8)) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index feb26d4bb..a9a37bf2c 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -27,6 +27,7 @@ enum CommandType: String, Codable { case keyboardReturn case alert case pinch + case sequence case rotateGesture case transformGesture case recordStart @@ -75,7 +76,7 @@ extension CommandType { // .scroll is the fused frame-resolve + drag scroll; same classification as .drag. case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe, .scroll, .back, .backInApp, .backSystem, .rotate, .appSwitcher, - .keyboardDismiss, .keyboardReturn, .pinch, .rotateGesture, .transformGesture: + .keyboardDismiss, .keyboardReturn, .pinch, .sequence, .rotateGesture, .transformGesture: return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false) // Read-only reads: eligible for the session-invalidating retry. @@ -152,6 +153,34 @@ struct Command: Codable { let raw: Bool? let fullscreen: Bool? let synthesized: Bool? + let steps: [SequenceStep]? +} + +/// One allowlisted coordinate gesture step inside a fused `sequence` command. +/// `kind` is decoded as a raw String (not an enum) so the runner can return a clear +/// INVALID_ARGS for an unknown kind instead of a generic decode failure. +struct SequenceStep: Codable { + let kind: String + let x: Double? + let y: Double? + let x2: Double? + let y2: Double? + let durationMs: Double? + let pauseMs: Double? + /// For `tap` steps on iOS non-tv: use the synthesized HID tap fast path (synthesizedTapAt) + /// instead of the drag-based XCUICoordinate tapAt, matching the individual `tap` command. + let synthesized: Bool? +} + +/// Per-step result for a `sequence` response. `ok:false` carries the failing step's +/// errorCode/errorMessage; execution stops at the first failed step. +struct SequenceStepResult: Codable { + let ok: Bool + let kind: String + let errorCode: String? + let errorMessage: String? + let gestureStartUptimeMs: Double? + let gestureEndUptimeMs: Double? } struct Response: Codable { @@ -199,6 +228,9 @@ struct DataPayload: Codable { let gestureFallbackHint: String? let runnerFatal: Bool? let runnerFatalReason: String? + let completedSteps: Int? + let failedStepIndex: Int? + let sequenceResults: [SequenceStepResult]? init( message: String? = nil, @@ -232,7 +264,10 @@ struct DataPayload: Codable { gestureFallbackMessage: String? = nil, gestureFallbackHint: String? = nil, runnerFatal: Bool? = nil, - runnerFatalReason: String? = nil + runnerFatalReason: String? = nil, + completedSteps: Int? = nil, + failedStepIndex: Int? = nil, + sequenceResults: [SequenceStepResult]? = nil ) { self.message = message self.text = text @@ -266,6 +301,9 @@ struct DataPayload: Codable { self.gestureFallbackHint = gestureFallbackHint self.runnerFatal = runnerFatal self.runnerFatalReason = runnerFatalReason + self.completedSteps = completedSteps + self.failedStepIndex = failedStepIndex + self.sequenceResults = sequenceResults } } diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift new file mode 100644 index 000000000..ea54dbbe4 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift @@ -0,0 +1,389 @@ +import XCTest + +extension RunnerTests { + // MARK: - Sequence command + + /// Hard cap mirrored from the daemon (MAX_RUNNER_SEQUENCE_STEPS). Keeps the worst-case + /// retained journal response well under the 16KB cap and bounds the lost-response window. + var maxSequenceSteps: Int { 20 } + + /// Allowlisted step kinds. Validated on both sides so an unsupported kind is rejected with a + /// clear INVALID_ARGS naming the step index, executing nothing. + private var sequenceableStepKinds: Set { ["tap", "longPress", "drag"] } + + /// Per-step outcome carried by `assembleSequenceExecution`. The timing is captured by the + /// executor closure (via performGesture) so ordering/stop-on-failure stay device-free testable. + struct SequenceStepOutcome { + let outcome: RunnerInteractionOutcome + let gestureStartUptimeMs: Double + let gestureEndUptimeMs: Double + } + + func executeSequence(command: Command, activeApp: XCUIApplication) -> Response { + guard let steps = command.steps, !steps.isEmpty else { + return sequenceInvalidArgs("sequence requires at least one step") + } + guard steps.count <= maxSequenceSteps else { + return sequenceInvalidArgs( + "sequence accepts at most \(maxSequenceSteps) steps, received \(steps.count)" + ) + } + for (index, step) in steps.enumerated() { + if let error = validateSequenceStep(step, index: index) { + return error + } + } + + // First-step touch frame mirrors tapSeries so recording-gestures works unchanged. + let firstStep = steps[0] + let firstFrame = (firstStep.x != nil && firstStep.y != nil) + ? resolvedTouchVisualizationFrame(app: activeApp, x: firstStep.x!, y: firstStep.y!) + : nil + + let execution = assembleSequenceExecution(steps: steps) { _, step in + performSequenceStep(step, activeApp: activeApp) + } + return sequenceResponse(execution: execution, touchFrame: firstFrame) + } + + /// Pure, device-free assembler: runs each step in order via `perform`, stops at the first + /// `.unsupported` outcome, and assembles the DataPayload (completedSteps, optional + /// failedStepIndex, per-step results, top-level gesture timing spanning first..last executed). + /// Steps after the failed index are never invoked and produce no result entries, so + /// results.count == completedSteps + (failedStepIndex != nil ? 1 : 0). + func assembleSequenceExecution( + steps: [SequenceStep], + perform: (Int, SequenceStep) -> SequenceStepOutcome + ) -> SequenceExecutionResult { + var results: [SequenceStepResult] = [] + var completedSteps = 0 + var failedStepIndex: Int? + var gestureStartUptimeMs: Double? + var gestureEndUptimeMs: Double? + + for (index, step) in steps.enumerated() { + let stepOutcome = perform(index, step) + if gestureStartUptimeMs == nil { + gestureStartUptimeMs = stepOutcome.gestureStartUptimeMs + } + gestureEndUptimeMs = stepOutcome.gestureEndUptimeMs + + switch stepOutcome.outcome { + case .performed: + results.append( + SequenceStepResult( + ok: true, + kind: step.kind, + errorCode: nil, + errorMessage: nil, + gestureStartUptimeMs: stepOutcome.gestureStartUptimeMs, + gestureEndUptimeMs: stepOutcome.gestureEndUptimeMs + ) + ) + completedSteps += 1 + case .unsupported(let message, _): + results.append( + SequenceStepResult( + ok: false, + kind: step.kind, + errorCode: "UNSUPPORTED_OPERATION", + errorMessage: message, + gestureStartUptimeMs: stepOutcome.gestureStartUptimeMs, + gestureEndUptimeMs: stepOutcome.gestureEndUptimeMs + ) + ) + failedStepIndex = index + return SequenceExecutionResult( + results: results, + completedSteps: completedSteps, + failedStepIndex: failedStepIndex, + gestureStartUptimeMs: gestureStartUptimeMs, + gestureEndUptimeMs: gestureEndUptimeMs + ) + } + } + + return SequenceExecutionResult( + results: results, + completedSteps: completedSteps, + failedStepIndex: nil, + gestureStartUptimeMs: gestureStartUptimeMs, + gestureEndUptimeMs: gestureEndUptimeMs + ) + } + + struct SequenceExecutionResult { + let results: [SequenceStepResult] + let completedSteps: Int + let failedStepIndex: Int? + let gestureStartUptimeMs: Double? + let gestureEndUptimeMs: Double? + } + + // MARK: - Step validation / execution + + private func validateSequenceStep(_ step: SequenceStep, index: Int) -> Response? { + guard sequenceableStepKinds.contains(step.kind) else { + return sequenceInvalidArgs( + "sequence step \(index) has unsupported kind \"\(step.kind)\"; allowed: tap, longPress, drag" + ) + } + guard let x = step.x, let y = step.y, x.isFinite, y.isFinite else { + return sequenceInvalidArgs("sequence step \(index) (\(step.kind)) requires finite x and y") + } + if step.kind == "drag" { + guard let x2 = step.x2, let y2 = step.y2, x2.isFinite, y2.isFinite else { + return sequenceInvalidArgs("sequence step \(index) (drag) requires finite x2 and y2") + } + } + return nil + } + + private func performSequenceStep( + _ step: SequenceStep, + activeApp: XCUIApplication + ) -> SequenceStepOutcome { + let x = step.x ?? 0 + let y = step.y ?? 0 + // Synthesized HID tap fast path mirrors the individual `tap` command (idleTimeout:false, with + // a tapAt fallback when synthesis is unsupported), so fusing a jittered tap series does not + // change the touch mechanism for these inputs. + if step.kind == "tap", step.synthesized == true { + let (timing, outcome) = performGesture(activeApp, idleTimeout: false) { + synthesizedTapAt(app: activeApp, x: x, y: y) + } + if case .performed = outcome { + if let pauseMs = step.pauseMs, pauseMs > 0 { + sleepFor(min(max(pauseMs, 0), 10000) / 1000.0) + } + return SequenceStepOutcome( + outcome: outcome, + gestureStartUptimeMs: timing.gestureStartUptimeMs, + gestureEndUptimeMs: timing.gestureEndUptimeMs + ) + } + // Synthesis unsupported (e.g. macOS) — fall through to the drag-based tapAt below. + } + let (timing, outcome) = performGesture(activeApp) { + switch step.kind { + case "longPress": + let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0 + return longPressAt(app: activeApp, x: x, y: y, duration: duration) + case "drag": + // Route through keyboardAvoidingDragPoints for parity with the individual `.drag` command + // (RunnerTests+CommandExecution.swift). durationMs is intentionally ignored on this + // coordinate-drag path, matching that command's non-synthesized branch. + let dragPoints = keyboardAvoidingDragPoints( + app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y) + return dragAt( + app: activeApp, + x: dragPoints.x, + y: dragPoints.y, + x2: dragPoints.x2, + y2: dragPoints.y2, + holdDuration: coordinateDragHoldDuration() + ) + default: + return tapAt(app: activeApp, x: x, y: y) + } + } + // Sleep AFTER the step — pauseMs is the inter-step gap — but only when the step performed. + // assembleSequenceExecution stops at the first unsupported outcome, so pausing after a failed + // step would burn up to 10s of watchdog budget with no following step to separate from. + if case .performed = outcome, let pauseMs = step.pauseMs, pauseMs > 0 { + sleepFor(min(max(pauseMs, 0), 10000) / 1000.0) + } + return SequenceStepOutcome( + outcome: outcome, + gestureStartUptimeMs: timing.gestureStartUptimeMs, + gestureEndUptimeMs: timing.gestureEndUptimeMs + ) + } + + private func sequenceResponse( + execution: SequenceExecutionResult, + touchFrame: TouchVisualizationFrame? + ) -> Response { + return Response( + ok: true, + data: DataPayload( + message: "sequence", + gestureStartUptimeMs: execution.gestureStartUptimeMs, + gestureEndUptimeMs: execution.gestureEndUptimeMs, + x: touchFrame?.x, + y: touchFrame?.y, + referenceWidth: touchFrame?.referenceWidth, + referenceHeight: touchFrame?.referenceHeight, + completedSteps: execution.completedSteps, + failedStepIndex: execution.failedStepIndex, + sequenceResults: execution.results + ) + ) + } + + private func sequenceInvalidArgs(_ message: String) -> Response { + Response(ok: false, error: ErrorPayload(code: "INVALID_ARGS", message: message)) + } +} + +// MARK: - In-bundle unit tests (device-free) + +extension RunnerTests { + func testSequenceDecodesStepsFromWire() throws { + let json = """ + {"command":"sequence","commandId":"seq-1","steps":[ + {"kind":"tap","x":100,"y":200}, + {"kind":"longPress","x":102,"y":200,"durationMs":300}, + {"kind":"drag","x":10,"y":600,"x2":10,"y2":200,"durationMs":250,"pauseMs":50} + ]} + """ + let command = try JSONDecoder().decode(Command.self, from: Data(json.utf8)) + XCTAssertEqual(command.command, .sequence) + XCTAssertEqual(command.steps?.count, 3) + XCTAssertEqual(command.steps?[0].kind, "tap") + XCTAssertEqual(command.steps?[2].x2, 10) + XCTAssertEqual(command.steps?[2].pauseMs, 50) + } + + func testSequenceRejectsUnknownKind() throws { + let response = executeSequenceForTest(steps: [ + sequenceStep(kind: "tap", x: 1, y: 2), + sequenceStep(kind: "pinch", x: 3, y: 4), + ]) + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.error?.code, "INVALID_ARGS") + XCTAssertTrue(response.error?.message.contains("step 1") ?? false) + XCTAssertTrue(response.error?.message.contains("pinch") ?? false) + } + + func testSequenceRejectsEmpty() { + let response = executeSequenceForTest(steps: []) + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.error?.code, "INVALID_ARGS") + } + + func testSequenceRejectsTooManySteps() { + let steps = (0..<21).map { _ in sequenceStep(kind: "tap", x: 1, y: 2) } + let response = executeSequenceForTest(steps: steps) + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.error?.code, "INVALID_ARGS") + XCTAssertTrue(response.error?.message.contains("at most 20") ?? false) + } + + func testSequenceRejectsDragMissingSecondPoint() { + let response = executeSequenceForTest(steps: [ + sequenceStep(kind: "tap", x: 1, y: 2), + sequenceStep(kind: "drag", x: 3, y: 4), + ]) + XCTAssertEqual(response.ok, false) + XCTAssertEqual(response.error?.code, "INVALID_ARGS") + XCTAssertTrue(response.error?.message.contains("step 1") ?? false) + } + + func testAssembleSequencePreservesOrderOnSuccess() { + let steps = [ + sequenceStep(kind: "tap", x: 1, y: 1), + sequenceStep(kind: "longPress", x: 2, y: 2), + sequenceStep(kind: "tap", x: 3, y: 3), + ] + var calls: [Int] = [] + let execution = assembleSequenceExecution(steps: steps) { index, _ in + calls.append(index) + return SequenceStepOutcome( + outcome: .performed, + gestureStartUptimeMs: Double(index * 10), + gestureEndUptimeMs: Double(index * 10 + 5) + ) + } + XCTAssertEqual(calls, [0, 1, 2]) + XCTAssertEqual(execution.completedSteps, 3) + XCTAssertNil(execution.failedStepIndex) + XCTAssertEqual(execution.results.map { $0.kind }, ["tap", "longPress", "tap"]) + XCTAssertEqual(execution.gestureStartUptimeMs, 0) + XCTAssertEqual(execution.gestureEndUptimeMs, 25) + } + + func testAssembleSequenceStopsAtFirstFailure() { + let steps = [ + sequenceStep(kind: "tap", x: 1, y: 1), + sequenceStep(kind: "drag", x: 2, y: 2), + sequenceStep(kind: "tap", x: 3, y: 3), + ] + var calls: [Int] = [] + let execution = assembleSequenceExecution(steps: steps) { index, _ in + calls.append(index) + if index == 1 { + return SequenceStepOutcome( + outcome: .unsupported(message: "drag unsupported", hint: nil), + gestureStartUptimeMs: 10, + gestureEndUptimeMs: 15 + ) + } + return SequenceStepOutcome(outcome: .performed, gestureStartUptimeMs: 0, gestureEndUptimeMs: 5) + } + // Step 2 is never invoked. + XCTAssertEqual(calls, [0, 1]) + XCTAssertEqual(execution.completedSteps, 1) + XCTAssertEqual(execution.failedStepIndex, 1) + // results.count == completedSteps + 1 (the failed step). + XCTAssertEqual(execution.results.count, 2) + XCTAssertEqual(execution.results[1].ok, false) + XCTAssertEqual(execution.results[1].errorCode, "UNSUPPORTED_OPERATION") + XCTAssertEqual(execution.results[1].errorMessage, "drag unsupported") + } + + func testSequenceWorstCaseResponseStaysUnderJournalCap() throws { + let longMessage = String(repeating: "e", count: 200) + let results = (0..<20).map { index in + SequenceStepResult( + ok: index < 19, + kind: "drag", + errorCode: index < 19 ? nil : "UNSUPPORTED_OPERATION", + errorMessage: index < 19 ? nil : longMessage, + gestureStartUptimeMs: 123456.789, + gestureEndUptimeMs: 123466.789 + ) + } + let response = Response( + ok: true, + data: DataPayload( + message: "sequence", + completedSteps: 19, + failedStepIndex: 19, + sequenceResults: results + ) + ) + let encoded = try JSONEncoder().encode(response) + XCTAssertLessThan(encoded.count, 16 * 1024) + } + + private func sequenceStep( + kind: String, + x: Double?, + y: Double? = nil, + x2: Double? = nil, + y2: Double? = nil + ) -> SequenceStep { + SequenceStep( + kind: kind, x: x, y: y, x2: x2, y2: y2, durationMs: nil, pauseMs: nil, synthesized: nil) + } + + /// Validation runs before any executor call, so the INVALID_ARGS paths are exercised without + /// reaching the device executor (which is never invoked when validation rejects). + private func executeSequenceForTest(steps: [SequenceStep]) -> Response { + let command = makeSequenceCommand(steps: steps) + return executeSequence(command: command, activeApp: app) + } + + /// Build a sequence Command via JSON so the test does not depend on the memberwise init's + /// parameter order. + private func makeSequenceCommand(steps: [SequenceStep]) -> Command { + struct SequenceCommandFixture: Encodable { + let command = "sequence" + let commandId = "seq-test" + let steps: [SequenceStep] + } + let data = try! JSONEncoder().encode(SequenceCommandFixture(steps: steps)) + return try! JSONDecoder().decode(Command.self, from: data) + } +} diff --git a/src/core/__tests__/dispatch-interactions.test.ts b/src/core/__tests__/dispatch-interactions.test.ts index 1fdab0489..02b34fb2e 100644 --- a/src/core/__tests__/dispatch-interactions.test.ts +++ b/src/core/__tests__/dispatch-interactions.test.ts @@ -12,12 +12,15 @@ vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => { import { handlePanCommand, + handlePressCommand, handleRotateGestureCommand, handleSwipeCommand, handleSwipePresetCommand, handleTransformGestureCommand, } from '../dispatch-interactions.ts'; import type { Interactor } from '../interactor-types.ts'; +import type { RunnerCommand } from '../../platforms/ios/runner-contract.ts'; +import { AppError } from '../../utils/errors.ts'; import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => { @@ -247,6 +250,246 @@ test('handleRotateGestureCommand routes Android through the interactor', async ( }); }); +test('handlePressCommand fuses an iOS jitter series into one sequence runner request', async () => { + mockRunIosRunnerCommand.mockResolvedValueOnce({ + completedSteps: 3, + sequenceResults: [ + { ok: true, kind: 'tap' }, + { ok: true, kind: 'tap' }, + { ok: true, kind: 'tap' }, + ], + gestureStartUptimeMs: 100, + gestureEndUptimeMs: 260, + }); + const interactor = makeUnusedInteractor(); + + const result = await handlePressCommand(IOS_SIMULATOR, interactor, ['100', '200'], { + count: 3, + jitterPx: 2, + intervalMs: 40, + appBundleId: 'com.example.App', + }); + + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 1); + const sent = mockRunIosRunnerCommand.mock.calls[0]?.[1] as RunnerCommand; + assert.equal(sent.command, 'sequence'); + assert.equal(sent.appBundleId, 'com.example.App'); + assert.deepEqual(sent.steps, [ + { kind: 'tap', x: 100, y: 200, synthesized: true, pauseMs: 40 }, + { kind: 'tap', x: 102, y: 200, synthesized: true, pauseMs: 40 }, + { kind: 'tap', x: 100, y: 202, synthesized: true }, + ]); + assert.equal(result.timingMode, 'runner-sequence'); + assert.equal(result.message, 'Tapped (100, 200)'); +}); + +test('handlePressCommand fuses an iOS hold series into longPress sequence steps', async () => { + mockRunIosRunnerCommand.mockResolvedValueOnce({ + completedSteps: 3, + sequenceResults: [ + { ok: true, kind: 'longPress' }, + { ok: true, kind: 'longPress' }, + { ok: true, kind: 'longPress' }, + ], + }); + const interactor = makeUnusedInteractor(); + + await handlePressCommand(IOS_SIMULATOR, interactor, ['100', '200'], { + count: 3, + holdMs: 300, + }); + + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 1); + const sent = mockRunIosRunnerCommand.mock.calls[0]?.[1] as RunnerCommand; + assert.equal(sent.command, 'sequence'); + assert.deepEqual(sent.steps, [ + { kind: 'longPress', x: 100, y: 200, durationMs: 300 }, + { kind: 'longPress', x: 100, y: 200, durationMs: 300 }, + { kind: 'longPress', x: 100, y: 200, durationMs: 300 }, + ]); +}); + +test('handlePressCommand maps a failed sequence step to an AppError', async () => { + mockRunIosRunnerCommand.mockResolvedValueOnce({ + completedSteps: 1, + failedStepIndex: 1, + sequenceResults: [ + { ok: true, kind: 'tap' }, + { ok: false, kind: 'tap', errorCode: 'UNSUPPORTED_OPERATION', errorMessage: 'tap blocked' }, + ], + }); + + await assert.rejects( + () => + handlePressCommand(IOS_SIMULATOR, makeUnusedInteractor(), ['100', '200'], { + count: 2, + jitterPx: 2, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.equal(error.details?.failedStepIndex, 1); + return true; + }, + ); +}); + +test('handlePressCommand rebases a chunk-2 failure to global step/completed indices', async () => { + // 25 jittered taps -> 2 chunks of 20/5. Chunk 2 fails at its LOCAL step index 2 (global 22), + // having completed 2 of its steps locally (global 22). No chunk 3 must be sent. + mockRunIosRunnerCommand + .mockResolvedValueOnce({ + completedSteps: 20, + sequenceResults: Array.from({ length: 20 }, () => ({ ok: true, kind: 'tap' })), + }) + .mockResolvedValueOnce({ + completedSteps: 2, + failedStepIndex: 2, + sequenceResults: [ + { ok: true, kind: 'tap' }, + { ok: true, kind: 'tap' }, + { + ok: false, + kind: 'tap', + errorCode: 'UNSUPPORTED_OPERATION', + errorMessage: 'tap blocked', + }, + ], + }); + + await assert.rejects( + () => + handlePressCommand(IOS_SIMULATOR, makeUnusedInteractor(), ['100', '200'], { + count: 25, + jitterPx: 2, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.equal(error.details?.failedStepIndex, 22); + assert.equal(error.details?.completedSteps, 22); + assert.equal(error.details?.chunkStepIndex, 2); + return true; + }, + ); + + // Both chunk requests were sent; the failure stopped chunk 3 from ever being issued. + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 2); + const chunk1 = mockRunIosRunnerCommand.mock.calls[0]?.[1] as RunnerCommand; + const chunk2 = mockRunIosRunnerCommand.mock.calls[1]?.[1] as RunnerCommand; + assert.equal(chunk1.command, 'sequence'); + assert.equal(chunk1.steps?.length, 20); + assert.equal(chunk2.command, 'sequence'); + assert.equal(chunk2.steps?.length, 5); +}); + +test('handlePressCommand aggregates completedSteps and gestureEnd across sequence chunks', async () => { + // 45 jittered taps -> 3 chunks of 20/20/5. The aggregated result must report all 45 steps + // and the LAST chunk's gestureEndUptimeMs, not just the first chunk's. + mockRunIosRunnerCommand + .mockResolvedValueOnce({ + completedSteps: 20, + sequenceResults: Array.from({ length: 20 }, () => ({ ok: true, kind: 'tap' })), + gestureStartUptimeMs: 100, + gestureEndUptimeMs: 300, + x: 0.5, + y: 0.5, + }) + .mockResolvedValueOnce({ + completedSteps: 20, + sequenceResults: Array.from({ length: 20 }, () => ({ ok: true, kind: 'tap' })), + gestureStartUptimeMs: 400, + gestureEndUptimeMs: 600, + }) + .mockResolvedValueOnce({ + completedSteps: 5, + sequenceResults: Array.from({ length: 5 }, () => ({ ok: true, kind: 'tap' })), + gestureStartUptimeMs: 700, + gestureEndUptimeMs: 900, + }); + + const result = await handlePressCommand(IOS_SIMULATOR, makeUnusedInteractor(), ['100', '200'], { + count: 45, + jitterPx: 2, + }); + + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 3); + assert.equal(result.completedSteps, 45); + assert.equal((result.sequenceResults as unknown[]).length, 45); + // First chunk frame/start preserved, last chunk end. + assert.equal(result.gestureStartUptimeMs, 100); + assert.equal(result.gestureEndUptimeMs, 900); + assert.equal(result.x, 0.5); +}); + +test('handlePressCommand sub-chunks a hold series by estimated duration under the runner watchdog', async () => { + // count=20 hold-ms=2000 is ~40s of holds in one chunk -> over the 30s main-thread watchdog. + // The duration budget must split it into multiple sub-chunks even though step count <= 20. + mockRunIosRunnerCommand.mockResolvedValue({ + completedSteps: 0, + sequenceResults: [], + }); + + await handlePressCommand(IOS_SIMULATOR, makeUnusedInteractor(), ['100', '200'], { + count: 20, + holdMs: 2000, + }); + + assert.ok( + mockRunIosRunnerCommand.mock.calls.length > 1, + `expected multiple chunks, got ${mockRunIosRunnerCommand.mock.calls.length}`, + ); + // Every chunk's estimated holds + pauses + overhead must stay under the budget. + for (const call of mockRunIosRunnerCommand.mock.calls) { + const sent = call[1] as RunnerCommand; + const steps = sent.steps ?? []; + const estimatedMs = steps.reduce( + (sum, step) => sum + (step.durationMs ?? 0) + (step.pauseMs ?? 0) + 250, + 0, + ); + assert.ok(estimatedMs <= 20_000, `chunk estimated ${estimatedMs}ms exceeds budget`); + } +}); + +test('handlePressCommand count=1 keeps the direct (non-sequence) path on iOS', async () => { + const taps: unknown[][] = []; + const interactor = { + ...makeUnusedInteractor(), + tap: async (...args: unknown[]) => { + taps.push(args); + return undefined; + }, + }; + + await handlePressCommand(IOS_SIMULATOR, interactor, ['100', '200'], { count: 1, jitterPx: 2 }); + + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); + assert.deepEqual(taps, [[100, 200]]); +}); + +test('handlePressCommand on Android keeps the direct path even with hold', async () => { + const longPresses: unknown[][] = []; + const interactor = { + ...makeUnusedInteractor(), + // Returns a non-nullish result: every iteration must still perform its + // press even once the kept-first result is set (regression for a `??=` + // short-circuit that skipped presses 2..N). + longPress: async (...args: unknown[]) => { + longPresses.push(args); + return { pressed: true }; + }, + }; + + const result = await handlePressCommand(ANDROID_EMULATOR, interactor, ['100', '200'], { + count: 3, + holdMs: 200, + }); + + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); + assert.equal(longPresses.length, 3); + assert.equal(result.pressed, true); +}); + test('handleTransformGestureCommand routes iOS simulator through the interactor', async () => { const calls: unknown[][] = []; const interactor = { diff --git a/src/core/__tests__/dispatch-series.test.ts b/src/core/__tests__/dispatch-series.test.ts index 96adabf44..870c8d9c9 100644 --- a/src/core/__tests__/dispatch-series.test.ts +++ b/src/core/__tests__/dispatch-series.test.ts @@ -4,11 +4,20 @@ import { requireIntInRange, shouldUseIosTapSeries, shouldUseIosDragSeries, + shouldUseIosPressSequence, + chunkRunnerSequenceSteps, + chunkRunnerSequenceStepsByBudget, } from '../dispatch-series.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; const iosDevice: DeviceInfo = { platform: 'ios', id: 'test', name: 'iPhone', kind: 'simulator' }; +const androidDevice: DeviceInfo = { + platform: 'android', + id: 'emu', + name: 'Pixel', + kind: 'emulator', +}; // --- requireIntInRange --- test('requireIntInRange throws for value below minimum', () => { @@ -65,6 +74,76 @@ test('shouldUseIosDragSeries returns false when count is 1', () => { assert.equal(shouldUseIosDragSeries(iosDevice, 1), false); }); +// --- shouldUseIosPressSequence --- + +test('shouldUseIosPressSequence returns true for iOS with count > 1 and hold', () => { + assert.equal(shouldUseIosPressSequence(iosDevice, 3, 200, 0), true); +}); + +test('shouldUseIosPressSequence returns true for iOS with count > 1 and jitter', () => { + assert.equal(shouldUseIosPressSequence(iosDevice, 3, 0, 2), true); +}); + +test('shouldUseIosPressSequence returns false without hold or jitter', () => { + assert.equal(shouldUseIosPressSequence(iosDevice, 3, 0, 0), false); +}); + +test('shouldUseIosPressSequence returns false for count <= 1', () => { + assert.equal(shouldUseIosPressSequence(iosDevice, 1, 200, 2), false); +}); + +test('shouldUseIosPressSequence returns false on non-Apple platforms', () => { + assert.equal(shouldUseIosPressSequence(androidDevice, 3, 200, 2), false); +}); + +// --- chunkRunnerSequenceSteps --- + +test('chunkRunnerSequenceSteps splits 45 steps into 20/20/5 preserving order', () => { + const steps = Array.from({ length: 45 }, (_, index) => index); + const chunks = chunkRunnerSequenceSteps(steps, 20); + assert.deepEqual( + chunks.map((chunk) => chunk.length), + [20, 20, 5], + ); + assert.deepEqual(chunks.flat(), steps); +}); + +test('chunkRunnerSequenceSteps keeps a short list in one chunk', () => { + const steps = [1, 2, 3]; + assert.deepEqual(chunkRunnerSequenceSteps(steps, 20), [steps]); +}); + +// --- chunkRunnerSequenceStepsByBudget --- + +test('chunkRunnerSequenceStepsByBudget caps by step count when steps are cheap', () => { + const steps = Array.from({ length: 45 }, () => ({})); + const chunks = chunkRunnerSequenceStepsByBudget(steps, 20, 20_000); + assert.deepEqual( + chunks.map((chunk) => chunk.length), + [20, 20, 5], + ); +}); + +test('chunkRunnerSequenceStepsByBudget splits below the step cap when holds exceed the budget', () => { + // 20 holds of 2000ms each (+250ms overhead) ~= 45s -> must split despite count <= 20. + const steps = Array.from({ length: 20 }, () => ({ durationMs: 2000 })); + const chunks = chunkRunnerSequenceStepsByBudget(steps, 20, 20_000); + assert.ok(chunks.length > 1); + for (const chunk of chunks) { + const estimatedMs = chunk.reduce((sum, step) => sum + (step.durationMs ?? 0) + 250, 0); + assert.ok(estimatedMs <= 20_000, `chunk estimated ${estimatedMs}ms exceeds budget`); + } + assert.equal(chunks.flat().length, 20); +}); + +test('chunkRunnerSequenceStepsByBudget keeps an oversized single step in its own chunk', () => { + const steps = [{ durationMs: 10_000, pauseMs: 10_000 }, { durationMs: 100 }]; + const chunks = chunkRunnerSequenceStepsByBudget(steps, 20, 20_000); + assert.equal(chunks.length, 2); + assert.equal(chunks[0]?.length, 1); + assert.equal(chunks[1]?.length, 1); +}); + // --- computeDeterministicJitter --- // --- runRepeatedSeries --- diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index bfd07e6c3..76acfa4b9 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -31,9 +31,17 @@ import { requireIntInRange, shouldUseIosTapSeries, shouldUseIosDragSeries, + shouldUseIosPressSequence, + chunkRunnerSequenceStepsByBudget, computeDeterministicJitter, runRepeatedSeries, } from './dispatch-series.ts'; +import { + MAX_RUNNER_SEQUENCE_STEPS, + buildRunnerSequenceCommand, + parseRunnerSequenceResult, +} from '../platforms/ios/runner-sequence.ts'; +import type { RunnerSequenceStep } from '../platforms/ios/runner-contract.ts'; import type { DispatchContext } from './dispatch-context.ts'; import type { Interactor, RunnerCallOptions } from './interactor-types.ts'; @@ -155,6 +163,10 @@ export async function handlePressCommand( return await runIosTapSeries(device, x, y, series, context); } + if (shouldUseIosPressSequence(device, series.count, series.holdMs, series.jitterPx)) { + return await runIosPressSequence(device, x, y, series, context); + } + return await runDirectPressSeries(interactor, x, y, series); } @@ -351,6 +363,113 @@ async function runIosTapSeries( }; } +// Fuses an iOS hold/jitter press series into `sequence` runner requests, replacing the N-request +// runDirectPressSeries fallback. Chunks are bounded by BOTH a step-count cap and an estimated +// wall-clock budget so no single request risks the runner's 30s main-thread watchdog. Stops at the +// first chunk reporting a failed step (mapped to an AppError with the global step index). Returns a +// tapSeries-shaped result aggregated across chunks (first chunk's frame/x/y/gestureStart, last +// chunk's gestureEnd, summed completedSteps, concatenated sequenceResults) so recording-gestures +// and response shaping see the whole series, not just the first chunk. +async function runIosPressSequence( + device: DeviceInfo, + x: number, + y: number, + series: PressSeriesOptions, + context: DispatchContext | undefined, +): Promise> { + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); + const steps = buildPressSequenceSteps(device, x, y, series); + const chunks = chunkRunnerSequenceStepsByBudget(steps, MAX_RUNNER_SEQUENCE_STEPS); + + let firstChunkRunnerResult: Record | undefined; + let lastChunkRunnerResult: Record | undefined; + let completedSteps = 0; + const sequenceResults: unknown[] = []; + let stepOffset = 0; + for (const chunk of chunks) { + const runnerResult = await runIosRunnerCommand( + device, + buildRunnerSequenceCommand(chunk, context?.appBundleId), + runnerOptionsFromContext(context), + ); + firstChunkRunnerResult ??= runnerResult; + lastChunkRunnerResult = runnerResult; + let parsed; + try { + parsed = parseRunnerSequenceResult(runnerResult); + } catch (error) { + throw remapSequenceErrorStepIndex(error, stepOffset); + } + completedSteps += parsed.completedSteps; + sequenceResults.push(...parsed.results); + stepOffset += chunk.length; + } + + return { + x, + y, + count: series.count, + intervalMs: series.intervalMs, + holdMs: series.holdMs, + jitterPx: series.jitterPx, + doubleTap: series.doubleTap, + timingMode: 'runner-sequence', + ...(firstChunkRunnerResult ?? {}), + completedSteps, + sequenceResults, + ...(lastChunkRunnerResult?.gestureEndUptimeMs !== undefined + ? { gestureEndUptimeMs: lastChunkRunnerResult.gestureEndUptimeMs } + : {}), + ...successText(formatPressMessage({ x, y })), + }; +} + +function buildPressSequenceSteps( + device: DeviceInfo, + x: number, + y: number, + series: PressSeriesOptions, +): RunnerSequenceStep[] { + const kind = series.holdMs > 0 ? 'longPress' : 'tap'; + // Mirror the individual `tap` command: on iOS non-tv, tap steps use synthesized HID taps + // (synthesizedTapAt) rather than the drag-based XCUICoordinate tapAt, matching iosTapCommand. + const synthesized = kind === 'tap' && device.platform === 'ios' && device.target !== 'tv'; + return Array.from({ length: series.count }, (_, index) => { + const [dx, dy] = computeDeterministicJitter(index, series.jitterPx); + const isLast = index === series.count - 1; + return { + kind, + x: x + dx, + y: y + dy, + ...(synthesized ? { synthesized: true } : {}), + ...(series.holdMs > 0 ? { durationMs: series.holdMs } : {}), + ...(!isLast && series.intervalMs > 0 ? { pauseMs: series.intervalMs } : {}), + }; + }); +} + +// Sequence step errors carry a chunk-local failedStepIndex and completedSteps; rebase both onto the +// global series so the error names the true step and completed count across chunk boundaries. +function remapSequenceErrorStepIndex(error: unknown, stepOffset: number): unknown { + if (stepOffset === 0 || !(error instanceof AppError) || !error.details) return error; + const localIndex = error.details.failedStepIndex; + if (typeof localIndex !== 'number') return error; + const localCompletedSteps = error.details.completedSteps; + return new AppError( + error.code, + error.message, + { + ...error.details, + failedStepIndex: localIndex + stepOffset, + chunkStepIndex: localIndex, + ...(typeof localCompletedSteps === 'number' + ? { completedSteps: stepOffset + localCompletedSteps } + : {}), + }, + error.cause, + ); +} + async function runDirectPressSeries( interactor: Interactor, x: number, @@ -362,15 +481,19 @@ async function runDirectPressSeries( const [dx, dy] = computeDeterministicJitter(index, series.jitterPx); const targetX = x + dx; const targetY = y + dy; + // `??=` must not guard the awaited call itself: that would short-circuit + // every press after the first. Only the first result is kept. if (series.doubleTap) { - interactionResult ??= (await interactor.doubleTap(targetX, targetY)) ?? undefined; + const result = await interactor.doubleTap(targetX, targetY); + interactionResult ??= result ?? undefined; return; } if (series.holdMs > 0) { - interactionResult ??= - (await interactor.longPress(targetX, targetY, series.holdMs)) ?? undefined; + const result = await interactor.longPress(targetX, targetY, series.holdMs); + interactionResult ??= result ?? undefined; } else { - interactionResult ??= (await interactor.tap(targetX, targetY)) ?? undefined; + const result = await interactor.tap(targetX, targetY); + interactionResult ??= result ?? undefined; } }); diff --git a/src/core/dispatch-series.ts b/src/core/dispatch-series.ts index f43d431b4..899618f94 100644 --- a/src/core/dispatch-series.ts +++ b/src/core/dispatch-series.ts @@ -27,6 +27,78 @@ export function shouldUseIosDragSeries(device: DeviceInfo, count: number): boole return isApplePlatform(device.platform) && count > 1; } +/** + * Whether a press series should fuse into one or more `sequence` runner requests. + * Fires only when the hold/jitter variant disqualifies it from the plain `tapSeries` + * fast path but it still loops more than once on an Apple platform — the hot loop + * that otherwise issues N separate runner requests. + */ +export function shouldUseIosPressSequence( + device: DeviceInfo, + count: number, + holdMs: number, + jitterPx: number, +): boolean { + return isApplePlatform(device.platform) && count > 1 && (holdMs > 0 || jitterPx > 0); +} + +export function chunkRunnerSequenceSteps(steps: T[], chunkSize: number): T[][] { + if (chunkSize <= 0) return [steps]; + const chunks: T[][] = []; + for (let index = 0; index < steps.length; index += chunkSize) { + chunks.push(steps.slice(index, index + chunkSize)); + } + return chunks; +} + +/** + * Wall-clock budget (ms) for one fused `sequence` runner request. The runner executes a whole + * chunk inside a single DispatchQueue.main block guarded by a 30s main-thread watchdog + * (mainThreadExecutionTimeout); if a chunk's holds + pauses exceed that, the runner reports a + * timeout while the remaining steps keep mutating the UI. We sub-chunk well under 30s so the + * estimated holds + pauses + per-step overhead of any single chunk stays safely inside it. + */ +const RUNNER_SEQUENCE_CHUNK_BUDGET_MS = 20_000; + +/** + * Rough fixed per-step cost (ms) the runner spends on each gesture beyond its hold/pause + * (synthesis, frame resolution, XCTest dispatch). Kept conservative so the budget errs toward + * smaller chunks rather than risking the watchdog. + */ +const RUNNER_SEQUENCE_STEP_OVERHEAD_MS = 250; + +/** + * Chunks sequence steps by BOTH a hard step-count cap and an estimated wall-clock budget, so a + * single fused request never risks the runner's 30s main-thread watchdog. Each step's estimated + * cost is its hold (durationMs) + inter-step pause (pauseMs) + fixed overhead. A step whose own + * estimated cost already exceeds the budget still gets its own single-step chunk (the daemon-side + * caps keep one step under 30s: max 10s hold + 10s pause + overhead). + */ +export function chunkRunnerSequenceStepsByBudget< + T extends { durationMs?: number; pauseMs?: number }, +>(steps: T[], maxSteps: number, budgetMs: number = RUNNER_SEQUENCE_CHUNK_BUDGET_MS): T[][] { + if (steps.length === 0) return []; + const stepCap = maxSteps > 0 ? maxSteps : steps.length; + const chunks: T[][] = []; + let current: T[] = []; + let currentCostMs = 0; + for (const step of steps) { + const stepCostMs = + (step.durationMs ?? 0) + (step.pauseMs ?? 0) + RUNNER_SEQUENCE_STEP_OVERHEAD_MS; + const wouldExceedBudget = current.length > 0 && currentCostMs + stepCostMs > budgetMs; + const wouldExceedCount = current.length >= stepCap; + if (wouldExceedBudget || wouldExceedCount) { + chunks.push(current); + current = []; + currentCostMs = 0; + } + current.push(step); + currentCostMs += stepCostMs; + } + if (current.length > 0) chunks.push(current); + return chunks; +} + export function computeDeterministicJitter(index: number, jitterPx: number): [number, number] { if (jitterPx <= 0) return [0, 0]; const [dx, dy] = DETERMINISTIC_JITTER_PATTERN[index % DETERMINISTIC_JITTER_PATTERN.length]!; diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 224f44c70..10c9c3727 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -148,6 +148,14 @@ const runnerProtocolCommandFixtures: Record { + const session = makeRunnerSession({ port: 8100, ready: true }); + const sequenceData = { + message: 'sequence', + completedSteps: 3, + sequenceResults: [ + { ok: true, kind: 'tap' }, + { ok: true, kind: 'tap' }, + { ok: true, kind: 'tap' }, + ], + }; + + mockEnsureRunnerSession.mockResolvedValueOnce(session); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockResolvedValueOnce({ + lifecycleState: 'completed', + lifecycleResponseJson: JSON.stringify({ ok: true, data: sequenceData }), + }); + + const result = await runIosRunnerCommand(IOS_SIMULATOR, { + command: 'sequence', + steps: [ + { kind: 'tap', x: 1, y: 2 }, + { kind: 'tap', x: 3, y: 4 }, + { kind: 'tap', x: 5, y: 6 }, + ], + }); + + assert.deepEqual(result, sequenceData); + assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0); + // status probe only — the mutating sequence is never replayed. + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status'); + assertDiagnosticDecision({ + decision: 'skipped', + reason: 'completed_with_retained_response', + lifecycleState: 'completed', + }); +}); + +test('sequence surfaces a lifecycle failure without replaying', async () => { + const session = makeRunnerSession({ port: 8100, ready: true }); + + mockEnsureRunnerSession.mockResolvedValueOnce(session); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockResolvedValueOnce({ + lifecycleState: 'failed', + lifecycleErrorCode: 'UNSUPPORTED_OPERATION', + lifecycleErrorMessage: 'sequence step 1 (drag) failed', + }); + + await assert.rejects( + () => + runIosRunnerCommand(IOS_SIMULATOR, { + command: 'sequence', + steps: [ + { kind: 'tap', x: 1, y: 2 }, + { kind: 'drag', x: 3, y: 4, x2: 5, y2: 6 }, + ], + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.equal(error.message, 'sequence step 1 (drag) failed'); + return true; + }, + ); + + assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assertDiagnosticDecision({ + decision: 'skipped', + reason: 'runner_reported_failure', + lifecycleState: 'failed', + }); +}); + +test('sequence in-flight after lost response reports no-replay guidance', async () => { + const session = makeRunnerSession({ port: 8100, ready: true }); + + mockEnsureRunnerSession.mockResolvedValueOnce(session); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockResolvedValueOnce({ lifecycleState: 'started' }); + + await assert.rejects( + () => + runIosRunnerCommand(IOS_SIMULATOR, { + command: 'sequence', + steps: [{ kind: 'tap', x: 1, y: 2 }], + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.match(error.message, /"sequence" is still started/); + assert.match(String(error.details?.hint), /snapshot -i/); + return true; + }, + ); + + assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assertDiagnosticDecision({ + decision: 'skipped', + reason: 'command_still_in_flight', + lifecycleState: 'started', + }); +}); + +test('sequence invalidates the session when the status probe fails', async () => { + const session = makeRunnerSession({ port: 8100, ready: true }); + + mockEnsureRunnerSession.mockResolvedValueOnce(session); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'status probe failed')); + + await assert.rejects(() => + runIosRunnerCommand(IOS_SIMULATOR, { + command: 'sequence', + steps: [{ kind: 'tap', x: 1, y: 2 }], + }), + ); + + assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ + [session, 'transport_error_after_command_send'], + ]); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assertDiagnosticDecision({ + decision: 'retained', + reason: 'status_probe_failed', + }); +}); + function makeBadCacheRecoveryFixtures() { const restoredArtifact = makeRunnerArtifact({ xctestrunPath: '/tmp/restored.xctestrun', diff --git a/src/platforms/ios/__tests__/runner-sequence.test.ts b/src/platforms/ios/__tests__/runner-sequence.test.ts new file mode 100644 index 000000000..913ddfdfb --- /dev/null +++ b/src/platforms/ios/__tests__/runner-sequence.test.ts @@ -0,0 +1,182 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { AppError } from '../../../utils/errors.ts'; +import { + MAX_RUNNER_SEQUENCE_STEPS, + SEQUENCEABLE_RUNNER_STEP_KINDS, + buildRunnerSequenceCommand, + parseRunnerSequenceResult, + validateRunnerSequenceSteps, +} from '../runner-sequence.ts'; +import type { RunnerSequenceStep } from '../runner-contract.ts'; + +function tap(x: number, y: number): RunnerSequenceStep { + return { kind: 'tap', x, y }; +} + +test('SEQUENCEABLE_RUNNER_STEP_KINDS is the documented allowlist', () => { + assert.deepEqual([...SEQUENCEABLE_RUNNER_STEP_KINDS], ['tap', 'longPress', 'drag']); +}); + +test('validateRunnerSequenceSteps rejects an unsupported kind naming the step index', () => { + assert.throws( + () => + validateRunnerSequenceSteps([ + tap(1, 2), + { kind: 'pinch' as RunnerSequenceStep['kind'], x: 3, y: 4 }, + ]), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(error.message, /step 1/); + assert.match(error.message, /pinch/); + assert.equal(error.details?.stepIndex, 1); + return true; + }, + ); +}); + +test('validateRunnerSequenceSteps rejects swipe (another non-allowlisted kind)', () => { + assert.throws( + () => + validateRunnerSequenceSteps([{ kind: 'swipe' as RunnerSequenceStep['kind'], x: 1, y: 2 }]), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); +}); + +test('validateRunnerSequenceSteps rejects empty step list', () => { + assert.throws( + () => validateRunnerSequenceSteps([]), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); +}); + +test('validateRunnerSequenceSteps enforces the step-count cap', () => { + const steps = Array.from({ length: MAX_RUNNER_SEQUENCE_STEPS + 1 }, () => tap(1, 2)); + assert.throws( + () => validateRunnerSequenceSteps(steps), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.maxSteps, MAX_RUNNER_SEQUENCE_STEPS); + return true; + }, + ); +}); + +test('validateRunnerSequenceSteps accepts exactly the cap', () => { + const steps = Array.from({ length: MAX_RUNNER_SEQUENCE_STEPS }, () => tap(1, 2)); + validateRunnerSequenceSteps(steps); +}); + +test('validateRunnerSequenceSteps requires finite x/y on every step', () => { + assert.throws( + () => validateRunnerSequenceSteps([{ kind: 'tap', x: Number.NaN, y: 2 }]), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); +}); + +test('validateRunnerSequenceSteps requires x2/y2 for drag steps', () => { + assert.throws( + () => validateRunnerSequenceSteps([tap(1, 2), { kind: 'drag', x: 3, y: 4 }]), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(error.message, /step 1/); + assert.match(error.message, /drag/); + return true; + }, + ); +}); + +test('validateRunnerSequenceSteps accepts a low durationMs (runner clamps the floor)', () => { + // `press --hold-ms 5` is legal CLI input (holdMs min 0); the runner clamps durationMs up to 16, + // so the validator must not reject a below-floor duration here. + validateRunnerSequenceSteps([{ kind: 'longPress', x: 1, y: 2, durationMs: 5 }]); + validateRunnerSequenceSteps([{ kind: 'longPress', x: 1, y: 2, durationMs: 0 }]); +}); + +test('validateRunnerSequenceSteps rejects out-of-range durationMs and pauseMs', () => { + assert.throws( + () => validateRunnerSequenceSteps([{ kind: 'longPress', x: 1, y: 2, durationMs: -1 }]), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + assert.throws( + () => validateRunnerSequenceSteps([{ kind: 'longPress', x: 1, y: 2, durationMs: 20_000 }]), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + assert.throws( + () => validateRunnerSequenceSteps([{ kind: 'tap', x: 1, y: 2, pauseMs: 20_000 }]), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); +}); + +test('buildRunnerSequenceCommand returns a validated sequence command', () => { + const command = buildRunnerSequenceCommand([tap(10, 20)], 'com.example.app'); + assert.equal(command.command, 'sequence'); + assert.equal(command.appBundleId, 'com.example.app'); + assert.deepEqual(command.steps, [tap(10, 20)]); +}); + +test('parseRunnerSequenceResult returns ordered results when no step failed', () => { + const parsed = parseRunnerSequenceResult({ + completedSteps: 2, + sequenceResults: [ + { ok: true, kind: 'tap', gestureStartUptimeMs: 1, gestureEndUptimeMs: 2 }, + { ok: true, kind: 'longPress', gestureStartUptimeMs: 3, gestureEndUptimeMs: 4 }, + ], + }); + assert.equal(parsed.completedSteps, 2); + assert.equal(parsed.failedStepIndex, undefined); + assert.equal(parsed.results.length, 2); + assert.equal(parsed.results[0]?.kind, 'tap'); + assert.equal(parsed.results[1]?.kind, 'longPress'); +}); + +test('parseRunnerSequenceResult maps failedStepIndex to a deterministic AppError', () => { + assert.throws( + () => + parseRunnerSequenceResult({ + completedSteps: 2, + failedStepIndex: 2, + sequenceResults: [ + { ok: true, kind: 'tap' }, + { ok: true, kind: 'tap' }, + { + ok: false, + kind: 'drag', + errorCode: 'UNSUPPORTED_OPERATION', + errorMessage: 'drag unsupported here', + }, + ], + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'UNSUPPORTED_OPERATION'); + assert.equal(error.message, 'drag unsupported here'); + assert.equal(error.details?.failedStepIndex, 2); + assert.equal(error.details?.failedStepKind, 'drag'); + assert.equal(error.details?.completedSteps, 2); + assert.ok(Array.isArray(error.details?.sequenceResults)); + return true; + }, + ); +}); + +test('parseRunnerSequenceResult infers a failure from sequenceResults when failedStepIndex is absent', () => { + assert.throws( + () => + parseRunnerSequenceResult({ + sequenceResults: [ + { ok: true, kind: 'tap' }, + { ok: false, kind: 'tap', errorCode: 'COMMAND_FAILED', errorMessage: 'boom' }, + ], + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.failedStepIndex, 1); + return true; + }, + ); +}); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 5a919fbfc..3676e56ef 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -876,6 +876,10 @@ const ALLOWLISTED_MUTATIONS: { name: string; command: Record }[ }, { name: 'swipe', command: { command: 'swipe', x: 1, y: 2, x2: 3, y2: 4 } }, { name: 'scroll', command: { command: 'scroll', direction: 'down' } }, + { + name: 'sequence', + command: { command: 'sequence', steps: [{ kind: 'tap', x: 120, y: 240 }] }, + }, ]; for (const { name, command } of ALLOWLISTED_MUTATIONS) { diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index 6cffaedee..5164bf590 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -46,6 +46,7 @@ export type RunnerCommand = { | 'keyboardReturn' | 'alert' | 'pinch' + | 'sequence' | 'recordStart' | 'recordStop' | 'status' @@ -92,12 +93,33 @@ export type RunnerCommand = { raw?: boolean; fullscreen?: boolean; synthesized?: boolean; + steps?: RunnerSequenceStep[]; /** * @deprecated Use textEntryMode: 'replace'. Kept for compatibility with older local runner clients. */ clearFirst?: boolean; }; +/** + * One allowlisted coordinate gesture step inside a fused `sequence` runner command. + * The kind set is intentionally narrow (tap/longPress/drag) and validated on both the + * daemon and runner sides — see runner-sequence.ts (the single interpretation point). + */ +export type RunnerSequenceStep = { + kind: 'tap' | 'longPress' | 'drag'; + x: number; + y: number; + x2?: number; + y2?: number; + durationMs?: number; + pauseMs?: number; + /** + * For `tap` steps on iOS non-tv: use the synthesized HID tap (synthesizedTapAt) fast path + * instead of the drag-based XCUICoordinate tapAt, matching the individual `tap` command. + */ + synthesized?: boolean; +}; + export function isRetryableRunnerError(err: unknown): boolean { if (!(err instanceof AppError)) return false; if (err.code !== 'COMMAND_FAILED') return false; diff --git a/src/platforms/ios/runner-sequence.ts b/src/platforms/ios/runner-sequence.ts new file mode 100644 index 000000000..fb1d5fc10 --- /dev/null +++ b/src/platforms/ios/runner-sequence.ts @@ -0,0 +1,198 @@ +import { AppError, toAppErrorCode } from '../../utils/errors.ts'; +import type { RunnerCommand, RunnerSequenceStep } from './runner-contract.ts'; + +export const SEQUENCEABLE_RUNNER_STEP_KINDS = ['tap', 'longPress', 'drag'] as const; +export type SequenceableRunnerStepKind = (typeof SEQUENCEABLE_RUNNER_STEP_KINDS)[number]; + +/** + * Hard cap on steps per `sequence` request. Two constraints set this bound: + * - The retained journal response stays well under the 16KB cap: 20 steps x ~150B + * per-step result (~3-4KB worst case) leaves ample headroom for lost-response recovery. + * - It bounds the UI-uncertainty window when a transport response is lost mid-sequence — + * at most 20 ordered mutating steps can have run unobserved. + * Longer daemon-side series chunk into ceil(N/20) requests (still stop-on-failure across chunks). + */ +export const MAX_RUNNER_SEQUENCE_STEPS = 20; + +// The runner clamps durationMs to 16..10000 (RunnerTests+SequenceExecution.swift), so the +// validator only guards the upper bound and finiteness; the floor is the runner's job. This keeps +// legal CLI input like `press --hold-ms 5` (holdMs min 0) acceptable instead of rejecting it here. +const MIN_DURATION_MS = 0; +const MAX_DURATION_MS = 10_000; +const MIN_PAUSE_MS = 0; +const MAX_PAUSE_MS = 10_000; + +export type RunnerSequenceStepResult = { + ok: boolean; + kind: string; + errorCode?: string; + errorMessage?: string; + gestureStartUptimeMs?: number; + gestureEndUptimeMs?: number; +}; + +export type ParsedRunnerSequenceResult = { + results: RunnerSequenceStepResult[]; + completedSteps: number; + failedStepIndex?: number; +}; + +function isSequenceableKind(kind: unknown): kind is SequenceableRunnerStepKind { + return ( + typeof kind === 'string' && (SEQUENCEABLE_RUNNER_STEP_KINDS as readonly string[]).includes(kind) + ); +} + +function invalidStep(index: number, kind: unknown, message: string): AppError { + return new AppError('INVALID_ARGS', message, { + stepIndex: index, + kind: typeof kind === 'string' ? kind : undefined, + }); +} + +/** + * Validates an ordered list of sequence steps before sending, throwing AppError('INVALID_ARGS') + * naming the offending step index and kind. The runner rejects the same kinds, missing coords, and + * over-length lists with nothing executed; durations differ — the runner clamps durationMs into + * 16..10000 rather than rejecting, so this validator only rejects non-finite/negative/too-high + * durations and leaves the floor to the runner's clamp. + */ +export function validateRunnerSequenceSteps(steps: RunnerSequenceStep[]): void { + if (!Array.isArray(steps) || steps.length === 0) { + throw new AppError('INVALID_ARGS', 'sequence requires at least one step', { + stepCount: Array.isArray(steps) ? steps.length : 0, + }); + } + if (steps.length > MAX_RUNNER_SEQUENCE_STEPS) { + throw new AppError( + 'INVALID_ARGS', + `sequence accepts at most ${MAX_RUNNER_SEQUENCE_STEPS} steps, received ${steps.length}`, + { stepCount: steps.length, maxSteps: MAX_RUNNER_SEQUENCE_STEPS }, + ); + } + steps.forEach((step, index) => validateRunnerSequenceStep(step, index)); +} + +function validateRunnerSequenceStep(step: RunnerSequenceStep, index: number): void { + if (!isSequenceableKind(step.kind)) { + throw invalidStep( + index, + step.kind, + `sequence step ${index} has unsupported kind "${String(step.kind)}"; allowed: ${SEQUENCEABLE_RUNNER_STEP_KINDS.join(', ')}`, + ); + } + if (!Number.isFinite(step.x) || !Number.isFinite(step.y)) { + throw invalidStep( + index, + step.kind, + `sequence step ${index} (${step.kind}) requires finite x and y`, + ); + } + if (step.kind === 'drag' && (!Number.isFinite(step.x2) || !Number.isFinite(step.y2))) { + throw invalidStep(index, step.kind, `sequence step ${index} (drag) requires finite x2 and y2`); + } + if (step.durationMs !== undefined) { + assertInRange( + step.durationMs, + MIN_DURATION_MS, + MAX_DURATION_MS, + index, + step.kind, + 'durationMs', + ); + } + if (step.pauseMs !== undefined) { + assertInRange(step.pauseMs, MIN_PAUSE_MS, MAX_PAUSE_MS, index, step.kind, 'pauseMs'); + } +} + +function assertInRange( + value: number, + min: number, + max: number, + index: number, + kind: string, + field: string, +): void { + if (!Number.isFinite(value) || value < min || value > max) { + throw invalidStep( + index, + kind, + `sequence step ${index} (${kind}) ${field} must be between ${min} and ${max}`, + ); + } +} + +export function buildRunnerSequenceCommand( + steps: RunnerSequenceStep[], + appBundleId?: string, +): RunnerCommand { + validateRunnerSequenceSteps(steps); + return { command: 'sequence', steps, appBundleId }; +} + +/** + * Single interpretation point for a `sequence` runner response. The runner returns + * ok:true even when a step failed (step failure is data, so the tracked unit completed + * and its results are retained for lost-response recovery). This maps a present + * failedStepIndex into a deterministic AppError keyed off the failing step's errorCode, + * naming the step index and kind, with completedSteps + per-step results in details. + * + * Any future caller issuing `sequence` MUST route the response through here, or step + * failures will be silently ignored. + */ +export function parseRunnerSequenceResult( + data: Record, +): ParsedRunnerSequenceResult { + const results = readSequenceResults(data.sequenceResults); + const completedSteps = + typeof data.completedSteps === 'number' && Number.isFinite(data.completedSteps) + ? data.completedSteps + : results.filter((result) => result.ok).length; + const failedStepIndex = + typeof data.failedStepIndex === 'number' && Number.isFinite(data.failedStepIndex) + ? data.failedStepIndex + : results.findIndex((result) => !result.ok) >= 0 + ? results.findIndex((result) => !result.ok) + : undefined; + + if (failedStepIndex !== undefined) { + throw buildSequenceStepError(results, completedSteps, failedStepIndex); + } + + return { results, completedSteps, failedStepIndex }; +} + +function buildSequenceStepError( + results: RunnerSequenceStepResult[], + completedSteps: number, + failedStepIndex: number, +): AppError { + const failed = results[failedStepIndex]; + const kind = failed?.kind ?? 'step'; + const message = failed?.errorMessage ?? `sequence step ${failedStepIndex} (${kind}) failed`; + return new AppError(toAppErrorCode(failed?.errorCode), message, { + failedStepIndex, + failedStepKind: kind, + completedSteps, + sequenceResults: results, + hint: 'Run snapshot -i to inspect the current UI, then continue from the observed state.', + }); +} + +function readSequenceResults(value: unknown): RunnerSequenceStepResult[] { + if (!Array.isArray(value)) return []; + return value.map((entry) => { + const record = (entry ?? {}) as Record; + return { + ok: record.ok === true, + kind: typeof record.kind === 'string' ? record.kind : 'unknown', + errorCode: typeof record.errorCode === 'string' ? record.errorCode : undefined, + errorMessage: typeof record.errorMessage === 'string' ? record.errorMessage : undefined, + gestureStartUptimeMs: + typeof record.gestureStartUptimeMs === 'number' ? record.gestureStartUptimeMs : undefined, + gestureEndUptimeMs: + typeof record.gestureEndUptimeMs === 'number' ? record.gestureEndUptimeMs : undefined, + }; + }); +} diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index ab3b774c0..442286e88 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -65,6 +65,7 @@ const PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS = new Set