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`, `sequence`) when the same
mutating interactions (`tap`, `longPress`, `drag`, `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`, `sequence`) for the same
mutating allowlist (`tap`, `longPress`, `drag`, `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 @@ -55,27 +55,6 @@ extension RunnerTests {
}
}

private func performDragSeries(
count: Int,
pauseMs: Double,
pattern: String,
points: DragPoints,
_ drag: (_ x: Double, _ y: Double, _ x2: Double, _ y2: Double) -> RunnerInteractionOutcome
) -> RunnerInteractionOutcome {
var outcome = RunnerInteractionOutcome.performed
runSeries(count: count, pauseMs: pauseMs) { idx in
guard case .performed = outcome else {
return
}
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
let startX = reverse ? points.x2 : points.x
let startY = reverse ? points.y2 : points.y
let endX = reverse ? points.x : points.x2
let endY = reverse ? points.y : points.y2
outcome = drag(startX, startY, endX, endY)
}
return outcome
}

/// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
/// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
Expand Down Expand Up @@ -580,42 +559,6 @@ extension RunnerTests {
} catch {
return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
}
case .tapSeries:
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "tapSeries requires x and y"))
}
let count = max(Int(command.count ?? 1), 1)
let intervalMs = max(command.intervalMs ?? 0, 0)
let doubleTap = command.doubleTap ?? false
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
if doubleTap {
let (timing, outcome) = performGesture(activeApp) {
var outcome = RunnerInteractionOutcome.performed
runSeries(count: count, pauseMs: intervalMs) { _ in
if case .performed = outcome {
outcome = doubleTapAt(app: activeApp, x: x, y: y)
}
}
return outcome
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
}
let (timing, outcome) = performGesture(activeApp) {
var outcome = RunnerInteractionOutcome.performed
runSeries(count: count, pauseMs: intervalMs) { _ in
if case .performed = outcome {
outcome = tapAt(app: activeApp, x: x, y: y)
}
}
return outcome
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: "tap series", timing: timing, frame: .touch(touchFrame))
case .longPress:
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
Expand Down Expand Up @@ -644,8 +587,8 @@ extension RunnerTests {
message: "dragged"
)
case .scroll:
// Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame exactly
// like .interactionFrame, computes drag endpoints with the Swift port of
// Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame via
// resolvedTouchReferenceFrame, computes drag endpoints with the Swift port of
// buildScrollGesturePlan, then runs the same non-synthesized drag path scroll's drag used.
guard let direction = command.direction,
direction == "up" || direction == "down" || direction == "left" || direction == "right"
Expand Down Expand Up @@ -690,66 +633,6 @@ extension RunnerTests {
synthesized: false,
message: "scrolled"
)
case .dragSeries:
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
}
let count = max(Int(command.count ?? 1), 1)
let pauseMs = max(command.pauseMs ?? 0, 0)
let pattern = command.pattern ?? "one-way"
if pattern != "one-way" && pattern != "ping-pong" {
return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
}
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
var fallback: GestureFallback?
if command.synthesized == true {
let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
performDragSeries(
count: count,
pauseMs: pauseMs,
pattern: pattern,
points: dragPoints
) { startX, startY, endX, endY in
synthesizedDragAt(
app: activeApp,
x: startX,
y: startY,
x2: endX,
y2: endY,
durationMs: durationMs
)
}
}
if case .performed = outcome {
return gestureResponse(message: "drag series", timing: timing)
}
fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome)
}
let holdDuration = command.synthesized == true
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
: coordinateDragHoldDuration()
let (timing, outcome) = performGesture(activeApp) {
performDragSeries(
count: count,
pauseMs: pauseMs,
pattern: pattern,
points: dragPoints
) { startX, startY, endX, endY in
dragAt(
app: activeApp,
x: startX,
y: startY,
x2: endX,
y2: endY,
holdDuration: holdDuration
)
}
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: "drag series", timing: timing, fallback: fallback)
case .remotePress:
guard let button = tvRemoteButton(from: command.remoteButton) else {
return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
Expand All @@ -768,17 +651,6 @@ extension RunnerTests {
response = executeTypeCommand(activeApp: activeApp, command: command)
}
return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
case .interactionFrame:
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
return Response(
ok: true,
data: DataPayload(
x: frame.minX,
y: frame.minY,
referenceWidth: frame.width,
referenceHeight: frame.height
)
)
case .swipe:
guard let direction = command.direction else {
return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ final class RunnerCommandJournal {
switch command.command {
case .snapshot, .screenshot:
return false
case .tap, .mouseClick, .tapSeries, .longPress, .interactionFrame, .drag, .dragSeries,
case .tap, .mouseClick, .longPress, .drag,
.remotePress, .type, .swipe, .scroll, .findText, .querySelector, .readText, .back,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
.alert, .pinch, .sequence, .rotateGesture, .transformGesture, .recordStart, .recordStop,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,16 +841,6 @@ extension RunnerTests {
#endif
}

func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
let total = max(count, 1)
let pause = max(pauseMs, 0)
for idx in 0..<total {
operation(idx)
if idx < total - 1 && pause > 0 {
sleepFor(pause / 1000.0)
}
}
}

func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
if performTvRemoteSwipeIfAvailable(direction: direction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
enum CommandType: String, Codable {
case tap
case mouseClick
case tapSeries
case longPress
case interactionFrame
case drag
case dragSeries
case remotePress
case type
case swipe
Expand Down Expand Up @@ -70,17 +67,16 @@ extension CommandType {
var traits: CommandTraits {
switch self {
// Interaction commands: require the foreground-guard + stabilization preflight.
// tapSeries/dragSeries are the series forms of tap/drag; keyboardReturn is the sibling
// of keyboardDismiss — all three were missing from the historical switch (drift the
// table now prevents) and are classified as interactions here.
// .scroll is the fused frame-resolve + drag scroll; same classification as .drag.
case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe, .scroll,
// keyboardReturn is the sibling of keyboardDismiss (missing from the historical switch —
// drift the table now prevents). .scroll is the fused frame-resolve + drag scroll; same
// classification as .drag. .sequence is the fused multi-step gesture batch.
case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll,
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
.keyboardDismiss, .keyboardReturn, .pinch, .sequence, .rotateGesture, .transformGesture:
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)

// Read-only reads: eligible for the session-invalidating retry.
case .interactionFrame, .findText, .readText, .snapshot:
case .findText, .readText, .snapshot:
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false)

// Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight).
Expand Down Expand Up @@ -126,11 +122,6 @@ struct Command: Codable {
let y: Double?
let button: String?
let remoteButton: String?
let count: Double?
let intervalMs: Double?
let doubleTap: Bool?
let pauseMs: Double?
let pattern: String?
let x2: Double?
let y2: Double?
let dx: Double?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extension RunnerTests {

/// 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<String> { ["tap", "longPress", "drag"] }
private var sequenceableStepKinds: Set<String> { ["tap", "doubleTap", "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.
Expand All @@ -34,7 +34,7 @@ extension RunnerTests {
}
}

// First-step touch frame mirrors tapSeries so recording-gestures works unchanged.
// Touch frame resolves from the first step's coords 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!)
Expand Down Expand Up @@ -125,7 +125,7 @@ extension RunnerTests {
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"
"sequence step \(index) has unsupported kind \"\(step.kind)\"; allowed: tap, doubleTap, longPress, drag"
)
}
guard let x = step.x, let y = step.y, x.isFinite, y.isFinite else {
Expand Down Expand Up @@ -166,6 +166,9 @@ extension RunnerTests {
}
let (timing, outcome) = performGesture(activeApp) {
switch step.kind {
case "doubleTap":
// doubleTapAt per step, matching the behavior of the retired tapSeries doubleTap path.
return doubleTapAt(app: activeApp, x: x, y: y)
case "longPress":
let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0
return longPressAt(app: activeApp, x: x, y: y, duration: duration)
Expand Down Expand Up @@ -233,16 +236,30 @@ extension RunnerTests {
let json = """
{"command":"sequence","commandId":"seq-1","steps":[
{"kind":"tap","x":100,"y":200},
{"kind":"doubleTap","x":101,"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?.count, 4)
XCTAssertEqual(command.steps?[0].kind, "tap")
XCTAssertEqual(command.steps?[2].x2, 10)
XCTAssertEqual(command.steps?[2].pauseMs, 50)
XCTAssertEqual(command.steps?[1].kind, "doubleTap")
XCTAssertEqual(command.steps?[3].x2, 10)
XCTAssertEqual(command.steps?[3].pauseMs, 50)
}

func testSequenceAcceptsDoubleTapKind() {
// A doubleTap step missing coords must fail on the coords check, not the kind allowlist —
// proving "doubleTap" passes validateSequenceStep without needing a device to execute on.
let response = executeSequenceForTest(steps: [
sequenceStep(kind: "doubleTap", x: nil)
])
XCTAssertEqual(response.ok, false)
XCTAssertEqual(response.error?.code, "INVALID_ARGS")
XCTAssertTrue(response.error?.message.contains("requires finite x and y") ?? false)
XCTAssertFalse(response.error?.message.contains("unsupported kind") ?? true)
}

func testSequenceRejectsUnknownKind() throws {
Expand Down
8 changes: 4 additions & 4 deletions scripts/perf/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,19 @@ export function buildSettingsTour(p: ResolvedProfile, ctx: StepContext): Scenari
}),
];

// These iOS-only repeated gesture forms route to dedicated XCTest runner commands:
// press --count > 1 -> tapSeries; swipe --count > 1 -> dragSeries.
// These iOS-only repeated gesture forms fuse into `sequence` runner requests:
// press --count > 1 and swipe --count > 1 both batch their steps into one command.
const iosRunnerSeries: ScenarioStep[] =
p.platform === 'ios'
? [
bat(
'press series (tapSeries)',
'press series (sequence)',
'press',
{ command: 'press', positionals: ['200', '95'], flags: { count: 2, intervalMs: 50 } },
{ freshRoot: true },
),
bat(
'swipe series (dragSeries)',
'swipe series (sequence)',
'swipe',
{
command: 'swipe',
Expand Down
Loading
Loading