Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0005-ios-runner-interaction-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ios-runner-protocol-optimizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum CommandType: String, Codable {
case keyboardReturn
case alert
case pinch
case sequence
case rotateGesture
case transformGesture
case recordStart
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
Loading
Loading