agent-device 0.17.1 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.17.1.apk → agent-device-android-multitouch-helper-0.17.3.apk} +0 -0
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.3.apk.sha256 +1 -0
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.17.1.manifest.json → agent-device-android-multitouch-helper-0.17.3.manifest.json} +4 -4
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.17.1.apk → agent-device-android-snapshot-helper-0.17.3.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.3.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.17.1.manifest.json → agent-device-android-snapshot-helper-0.17.3.manifest.json} +6 -6
- package/dist/src/123.js +1 -0
- package/dist/src/1231.js +1 -1
- package/dist/src/1352.js +1 -1
- package/dist/src/221.js +2 -2
- package/dist/src/2403.js +3 -0
- package/dist/src/2415.js +36 -30
- package/dist/src/4778.js +1 -1
- package/dist/src/5611.js +1 -0
- package/dist/src/5827.js +1 -0
- package/dist/src/6088.js +1 -0
- package/dist/src/6133.js +1 -0
- package/dist/src/7556.js +1 -1
- package/dist/src/7847.js +1 -1
- package/dist/src/8806.js +3 -3
- package/dist/src/9010.js +1 -0
- package/dist/src/9238.js +3 -3
- package/dist/src/9471.js +1 -1
- package/dist/src/9542.js +6 -3
- package/dist/src/9616.js +2 -0
- package/dist/src/9818.js +1 -1
- package/dist/src/android-adb.d.ts +12 -6
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android-snapshot-helper.d.ts +15 -8
- package/dist/src/android.js +5 -5
- package/dist/src/apple.js +1 -1
- package/dist/src/apps.js +10 -10
- package/dist/src/args.js +53 -14
- package/dist/src/batch.d.ts +16 -4
- package/dist/src/batch.js +1 -1
- package/dist/src/cli-output.js +29 -0
- package/dist/src/cli.js +17 -42
- package/dist/src/command-surface.js +1 -1
- package/dist/src/contracts.d.ts +80 -4
- package/dist/src/contracts.js +1 -1
- package/dist/src/find.js +1 -1
- package/dist/src/finders.d.ts +3 -1
- package/dist/src/generic.js +7 -7
- package/dist/src/index.d.ts +250 -79
- package/dist/src/index.js +1 -1
- package/dist/src/input-actions.js +1 -1
- package/dist/src/interaction.js +1 -1
- package/dist/src/internal/png-worker.d.ts +26 -0
- package/dist/src/internal/png-worker.js +1 -0
- package/dist/src/metro.d.ts +3 -1
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace-recording.js +3 -3
- package/dist/src/remote-config.d.ts +33 -7
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/selectors.d.ts +17 -3
- package/dist/src/selectors.js +1 -1
- package/dist/src/server.js +2 -2
- package/dist/src/session.js +8 -11
- package/dist/src/snapshot.js +2 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +14 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +297 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +328 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +121 -172
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +153 -5
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +171 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +0 -10
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +64 -18
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +200 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +406 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +82 -169
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +425 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +77 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +10 -1
- package/ios-runner/README.md +5 -5
- package/ios-runner/RUNNER_PROTOCOL.md +0 -1
- package/package.json +8 -5
- package/server.json +2 -2
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.1.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.1.apk.sha256 +0 -1
- package/dist/src/1998.js +0 -1
- package/dist/src/2805.js +0 -1
- package/dist/src/4057.js +0 -1
- package/dist/src/5310.js +0 -1
- package/dist/src/5792.js +0 -1
- package/dist/src/6085.js +0 -1
- package/dist/src/6232.js +0 -1
- package/dist/src/7455.js +0 -1
- package/dist/src/8020.js +0 -1
- package/dist/src/8502.js +0 -1
- package/dist/src/8699.js +0 -1
- package/dist/src/940.js +0 -1
- package/dist/src/9404.js +0 -1
- package/dist/src/9533.js +0 -1
- package/dist/src/command-metadata.js +0 -1
- /package/dist/src/{1393.js → 3675.js} +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
// Swift port of buildScrollGesturePlan from src/core/scroll-gesture.ts.
|
|
4
|
+
//
|
|
5
|
+
// This is a deliberate two-place invariant: the daemon keeps the TS implementation (for Android,
|
|
6
|
+
// recording, and reported-pixels), and the runner places the gesture with this Swift copy. The
|
|
7
|
+
// parity test vectors at the bottom of this file mirror src/core/__tests__/scroll-gesture.test.ts —
|
|
8
|
+
// if you change the math in either language, update the other and both vector sets.
|
|
9
|
+
//
|
|
10
|
+
// All inputs here are positive (reference dims, travel, center), so Swift's `.rounded()`
|
|
11
|
+
// (half away from zero) matches JS `Math.round` (half up) on every value computed below.
|
|
12
|
+
|
|
13
|
+
struct RunnerScrollGesturePlan {
|
|
14
|
+
let x1: Double
|
|
15
|
+
let y1: Double
|
|
16
|
+
let x2: Double
|
|
17
|
+
let y2: Double
|
|
18
|
+
let travelPixels: Double
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private let runnerDefaultScrollAmount = 0.6
|
|
22
|
+
private let runnerDefaultEdgePaddingFraction = 0.05
|
|
23
|
+
|
|
24
|
+
func runnerScrollGesturePlan(
|
|
25
|
+
direction: String,
|
|
26
|
+
amount: Double?,
|
|
27
|
+
pixels: Double?,
|
|
28
|
+
referenceWidth: Double,
|
|
29
|
+
referenceHeight: Double
|
|
30
|
+
) -> RunnerScrollGesturePlan? {
|
|
31
|
+
// Mirror the TS INVALID_ARGS contract: non-positive or non-finite amount/pixels are rejected
|
|
32
|
+
// rather than clamped into a journaled 1px scroll. The daemon validates before sending, so
|
|
33
|
+
// this only triggers for non-daemon wire clients.
|
|
34
|
+
if let amount, !(amount.isFinite && amount > 0) { return nil }
|
|
35
|
+
if let pixels, !(pixels.isFinite && pixels > 0) { return nil }
|
|
36
|
+
let axisLength = (direction == "up" || direction == "down") ? referenceHeight : referenceWidth
|
|
37
|
+
let requestedAmount = amount ?? runnerDefaultScrollAmount
|
|
38
|
+
let requestedPixels: Double =
|
|
39
|
+
pixels.map { max(1, $0.rounded()) } ?? (axisLength * requestedAmount).rounded()
|
|
40
|
+
let edgePadding = max(1, (axisLength * runnerDefaultEdgePaddingFraction).rounded())
|
|
41
|
+
let maxTravelPixels = max(1, axisLength - edgePadding * 2)
|
|
42
|
+
let travelPixels = max(1, min(requestedPixels, maxTravelPixels))
|
|
43
|
+
let halfTravel = (travelPixels / 2).rounded()
|
|
44
|
+
let centerX = (referenceWidth / 2).rounded()
|
|
45
|
+
let centerY = (referenceHeight / 2).rounded()
|
|
46
|
+
|
|
47
|
+
func plan(_ x1: Double, _ y1: Double, _ x2: Double, _ y2: Double) -> RunnerScrollGesturePlan {
|
|
48
|
+
RunnerScrollGesturePlan(x1: x1, y1: y1, x2: x2, y2: y2, travelPixels: travelPixels)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch direction {
|
|
52
|
+
case "up":
|
|
53
|
+
return plan(centerX, centerY - halfTravel, centerX, centerY + halfTravel)
|
|
54
|
+
case "down":
|
|
55
|
+
return plan(centerX, centerY + halfTravel, centerX, centerY - halfTravel)
|
|
56
|
+
case "left":
|
|
57
|
+
return plan(centerX - halfTravel, centerY, centerX + halfTravel, centerY)
|
|
58
|
+
case "right":
|
|
59
|
+
return plan(centerX + halfTravel, centerY, centerX - halfTravel, centerY)
|
|
60
|
+
default:
|
|
61
|
+
return nil
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
extension RunnerTests {
|
|
66
|
+
// Cross-language parity vectors mirroring src/core/__tests__/scroll-gesture.test.ts. Keep these
|
|
67
|
+
// in sync with the vitest vectors so the two buildScrollGesturePlan implementations cannot drift.
|
|
68
|
+
|
|
69
|
+
func testRunnerScrollGesturePlanMapsRelativeAmount() throws {
|
|
70
|
+
let plan = try XCTUnwrap(
|
|
71
|
+
runnerScrollGesturePlan(
|
|
72
|
+
direction: "down",
|
|
73
|
+
amount: 0.5,
|
|
74
|
+
pixels: nil,
|
|
75
|
+
referenceWidth: 400,
|
|
76
|
+
referenceHeight: 800
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
XCTAssertEqual(plan.x1, 200)
|
|
80
|
+
XCTAssertEqual(plan.y1, 600)
|
|
81
|
+
XCTAssertEqual(plan.x2, 200)
|
|
82
|
+
XCTAssertEqual(plan.y2, 200)
|
|
83
|
+
XCTAssertEqual(plan.travelPixels, 400)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func testRunnerScrollGesturePlanPixelsDown() throws {
|
|
87
|
+
// 300x600, down, pixels 120 -> (150,360)->(150,240), travel 120.
|
|
88
|
+
let plan = try XCTUnwrap(
|
|
89
|
+
runnerScrollGesturePlan(
|
|
90
|
+
direction: "down",
|
|
91
|
+
amount: nil,
|
|
92
|
+
pixels: 120,
|
|
93
|
+
referenceWidth: 300,
|
|
94
|
+
referenceHeight: 600
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
XCTAssertEqual(plan.x1, 150)
|
|
98
|
+
XCTAssertEqual(plan.y1, 360)
|
|
99
|
+
XCTAssertEqual(plan.x2, 150)
|
|
100
|
+
XCTAssertEqual(plan.y2, 240)
|
|
101
|
+
XCTAssertEqual(plan.travelPixels, 120)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func testRunnerScrollGesturePlanClampsAmountAboveOne() throws {
|
|
105
|
+
// 400x800, down, amount 2 -> requested 1600 clamps to the safe band (720): (200,760)->(200,40).
|
|
106
|
+
let plan = try XCTUnwrap(
|
|
107
|
+
runnerScrollGesturePlan(
|
|
108
|
+
direction: "down",
|
|
109
|
+
amount: 2,
|
|
110
|
+
pixels: nil,
|
|
111
|
+
referenceWidth: 400,
|
|
112
|
+
referenceHeight: 800
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
XCTAssertEqual(plan.x1, 200)
|
|
116
|
+
XCTAssertEqual(plan.y1, 760)
|
|
117
|
+
XCTAssertEqual(plan.x2, 200)
|
|
118
|
+
XCTAssertEqual(plan.y2, 40)
|
|
119
|
+
XCTAssertEqual(plan.travelPixels, 720)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func testRunnerScrollGesturePlanFloorsTinyFrames() throws {
|
|
123
|
+
// 2x2, down, pixels 10 engages every max(1, ...) floor and the .5 rounding cases the two
|
|
124
|
+
// ports must agree on (halfTravel 0.5 -> 1, center 1 from 2/2): (1,2)->(1,0), travel 1.
|
|
125
|
+
let plan = try XCTUnwrap(
|
|
126
|
+
runnerScrollGesturePlan(
|
|
127
|
+
direction: "down",
|
|
128
|
+
amount: nil,
|
|
129
|
+
pixels: 10,
|
|
130
|
+
referenceWidth: 2,
|
|
131
|
+
referenceHeight: 2
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
XCTAssertEqual(plan.x1, 1)
|
|
135
|
+
XCTAssertEqual(plan.y1, 2)
|
|
136
|
+
XCTAssertEqual(plan.x2, 1)
|
|
137
|
+
XCTAssertEqual(plan.y2, 0)
|
|
138
|
+
XCTAssertEqual(plan.travelPixels, 1)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func testRunnerScrollGesturePlanClampsToSafeBand() throws {
|
|
142
|
+
// 300x600, right, pixels 500 clamps travel to the safe band (270).
|
|
143
|
+
let plan = try XCTUnwrap(
|
|
144
|
+
runnerScrollGesturePlan(
|
|
145
|
+
direction: "right",
|
|
146
|
+
amount: nil,
|
|
147
|
+
pixels: 500,
|
|
148
|
+
referenceWidth: 300,
|
|
149
|
+
referenceHeight: 600
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
XCTAssertEqual(plan.x1, 285)
|
|
153
|
+
XCTAssertEqual(plan.x2, 15)
|
|
154
|
+
XCTAssertEqual(plan.y1, 300)
|
|
155
|
+
XCTAssertEqual(plan.y2, 300)
|
|
156
|
+
XCTAssertEqual(plan.travelPixels, 270)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func testRunnerScrollGesturePlanRejectsUnknownDirection() {
|
|
160
|
+
XCTAssertNil(
|
|
161
|
+
runnerScrollGesturePlan(
|
|
162
|
+
direction: "sideways",
|
|
163
|
+
amount: nil,
|
|
164
|
+
pixels: 100,
|
|
165
|
+
referenceWidth: 300,
|
|
166
|
+
referenceHeight: 600
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func testRunnerScrollGesturePlanRejectsInvalidAmountAndPixels() {
|
|
172
|
+
XCTAssertNil(
|
|
173
|
+
runnerScrollGesturePlan(
|
|
174
|
+
direction: "down",
|
|
175
|
+
amount: 0,
|
|
176
|
+
pixels: nil,
|
|
177
|
+
referenceWidth: 300,
|
|
178
|
+
referenceHeight: 600
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
XCTAssertNil(
|
|
182
|
+
runnerScrollGesturePlan(
|
|
183
|
+
direction: "down",
|
|
184
|
+
amount: nil,
|
|
185
|
+
pixels: -10,
|
|
186
|
+
referenceWidth: 300,
|
|
187
|
+
referenceHeight: 600
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
XCTAssertNil(
|
|
191
|
+
runnerScrollGesturePlan(
|
|
192
|
+
direction: "down",
|
|
193
|
+
amount: .infinity,
|
|
194
|
+
pixels: nil,
|
|
195
|
+
referenceWidth: 300,
|
|
196
|
+
referenceHeight: 600
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
extension RunnerTests {
|
|
4
|
+
// MARK: - Sequence command
|
|
5
|
+
|
|
6
|
+
/// Hard cap mirrored from the daemon (MAX_RUNNER_SEQUENCE_STEPS). Keeps the worst-case
|
|
7
|
+
/// retained journal response well under the 16KB cap and bounds the lost-response window.
|
|
8
|
+
var maxSequenceSteps: Int { 20 }
|
|
9
|
+
|
|
10
|
+
/// Allowlisted step kinds. Validated on both sides so an unsupported kind is rejected with a
|
|
11
|
+
/// clear INVALID_ARGS naming the step index, executing nothing.
|
|
12
|
+
private var sequenceableStepKinds: Set<String> { ["tap", "doubleTap", "longPress", "drag"] }
|
|
13
|
+
|
|
14
|
+
/// Per-step outcome carried by `assembleSequenceExecution`. The timing is captured by the
|
|
15
|
+
/// executor closure (via performGesture) so ordering/stop-on-failure stay device-free testable.
|
|
16
|
+
struct SequenceStepOutcome {
|
|
17
|
+
let outcome: RunnerInteractionOutcome
|
|
18
|
+
let gestureStartUptimeMs: Double
|
|
19
|
+
let gestureEndUptimeMs: Double
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func executeSequence(command: Command, activeApp: XCUIApplication) -> Response {
|
|
23
|
+
guard let steps = command.steps, !steps.isEmpty else {
|
|
24
|
+
return sequenceInvalidArgs("sequence requires at least one step")
|
|
25
|
+
}
|
|
26
|
+
guard steps.count <= maxSequenceSteps else {
|
|
27
|
+
return sequenceInvalidArgs(
|
|
28
|
+
"sequence accepts at most \(maxSequenceSteps) steps, received \(steps.count)"
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
for (index, step) in steps.enumerated() {
|
|
32
|
+
if let error = validateSequenceStep(step, index: index) {
|
|
33
|
+
return error
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Touch frame resolves from the first step's coords so recording-gestures works unchanged.
|
|
38
|
+
let firstStep = steps[0]
|
|
39
|
+
let firstFrame = (firstStep.x != nil && firstStep.y != nil)
|
|
40
|
+
? resolvedTouchVisualizationFrame(app: activeApp, x: firstStep.x!, y: firstStep.y!)
|
|
41
|
+
: nil
|
|
42
|
+
|
|
43
|
+
let execution = assembleSequenceExecution(steps: steps) { _, step in
|
|
44
|
+
performSequenceStep(step, activeApp: activeApp)
|
|
45
|
+
}
|
|
46
|
+
return sequenceResponse(execution: execution, touchFrame: firstFrame)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Pure, device-free assembler: runs each step in order via `perform`, stops at the first
|
|
50
|
+
/// `.unsupported` outcome, and assembles the DataPayload (completedSteps, optional
|
|
51
|
+
/// failedStepIndex, per-step results, top-level gesture timing spanning first..last executed).
|
|
52
|
+
/// Steps after the failed index are never invoked and produce no result entries, so
|
|
53
|
+
/// results.count == completedSteps + (failedStepIndex != nil ? 1 : 0).
|
|
54
|
+
func assembleSequenceExecution(
|
|
55
|
+
steps: [SequenceStep],
|
|
56
|
+
perform: (Int, SequenceStep) -> SequenceStepOutcome
|
|
57
|
+
) -> SequenceExecutionResult {
|
|
58
|
+
var results: [SequenceStepResult] = []
|
|
59
|
+
var completedSteps = 0
|
|
60
|
+
var failedStepIndex: Int?
|
|
61
|
+
var gestureStartUptimeMs: Double?
|
|
62
|
+
var gestureEndUptimeMs: Double?
|
|
63
|
+
|
|
64
|
+
for (index, step) in steps.enumerated() {
|
|
65
|
+
let stepOutcome = perform(index, step)
|
|
66
|
+
if gestureStartUptimeMs == nil {
|
|
67
|
+
gestureStartUptimeMs = stepOutcome.gestureStartUptimeMs
|
|
68
|
+
}
|
|
69
|
+
gestureEndUptimeMs = stepOutcome.gestureEndUptimeMs
|
|
70
|
+
|
|
71
|
+
switch stepOutcome.outcome {
|
|
72
|
+
case .performed:
|
|
73
|
+
results.append(
|
|
74
|
+
SequenceStepResult(
|
|
75
|
+
ok: true,
|
|
76
|
+
kind: step.kind,
|
|
77
|
+
errorCode: nil,
|
|
78
|
+
errorMessage: nil,
|
|
79
|
+
gestureStartUptimeMs: stepOutcome.gestureStartUptimeMs,
|
|
80
|
+
gestureEndUptimeMs: stepOutcome.gestureEndUptimeMs
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
completedSteps += 1
|
|
84
|
+
case .unsupported(let message, _):
|
|
85
|
+
results.append(
|
|
86
|
+
SequenceStepResult(
|
|
87
|
+
ok: false,
|
|
88
|
+
kind: step.kind,
|
|
89
|
+
errorCode: "UNSUPPORTED_OPERATION",
|
|
90
|
+
errorMessage: message,
|
|
91
|
+
gestureStartUptimeMs: stepOutcome.gestureStartUptimeMs,
|
|
92
|
+
gestureEndUptimeMs: stepOutcome.gestureEndUptimeMs
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
failedStepIndex = index
|
|
96
|
+
return SequenceExecutionResult(
|
|
97
|
+
results: results,
|
|
98
|
+
completedSteps: completedSteps,
|
|
99
|
+
failedStepIndex: failedStepIndex,
|
|
100
|
+
gestureStartUptimeMs: gestureStartUptimeMs,
|
|
101
|
+
gestureEndUptimeMs: gestureEndUptimeMs
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return SequenceExecutionResult(
|
|
107
|
+
results: results,
|
|
108
|
+
completedSteps: completedSteps,
|
|
109
|
+
failedStepIndex: nil,
|
|
110
|
+
gestureStartUptimeMs: gestureStartUptimeMs,
|
|
111
|
+
gestureEndUptimeMs: gestureEndUptimeMs
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
struct SequenceExecutionResult {
|
|
116
|
+
let results: [SequenceStepResult]
|
|
117
|
+
let completedSteps: Int
|
|
118
|
+
let failedStepIndex: Int?
|
|
119
|
+
let gestureStartUptimeMs: Double?
|
|
120
|
+
let gestureEndUptimeMs: Double?
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MARK: - Step validation / execution
|
|
124
|
+
|
|
125
|
+
private func validateSequenceStep(_ step: SequenceStep, index: Int) -> Response? {
|
|
126
|
+
guard sequenceableStepKinds.contains(step.kind) else {
|
|
127
|
+
return sequenceInvalidArgs(
|
|
128
|
+
"sequence step \(index) has unsupported kind \"\(step.kind)\"; allowed: tap, doubleTap, longPress, drag"
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
guard let x = step.x, let y = step.y, x.isFinite, y.isFinite else {
|
|
132
|
+
return sequenceInvalidArgs("sequence step \(index) (\(step.kind)) requires finite x and y")
|
|
133
|
+
}
|
|
134
|
+
if step.kind == "drag" {
|
|
135
|
+
guard let x2 = step.x2, let y2 = step.y2, x2.isFinite, y2.isFinite else {
|
|
136
|
+
return sequenceInvalidArgs("sequence step \(index) (drag) requires finite x2 and y2")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func performSequenceStep(
|
|
143
|
+
_ step: SequenceStep,
|
|
144
|
+
activeApp: XCUIApplication
|
|
145
|
+
) -> SequenceStepOutcome {
|
|
146
|
+
let x = step.x ?? 0
|
|
147
|
+
let y = step.y ?? 0
|
|
148
|
+
// Synthesized HID tap fast path mirrors the individual `tap` command (idleTimeout:false, with
|
|
149
|
+
// a tapAt fallback when synthesis is unsupported), so fusing a jittered tap series does not
|
|
150
|
+
// change the touch mechanism for these inputs.
|
|
151
|
+
if step.kind == "tap", step.synthesized == true {
|
|
152
|
+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
153
|
+
synthesizedTapAt(app: activeApp, x: x, y: y)
|
|
154
|
+
}
|
|
155
|
+
if case .performed = outcome {
|
|
156
|
+
if let pauseMs = step.pauseMs, pauseMs > 0 {
|
|
157
|
+
sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
|
|
158
|
+
}
|
|
159
|
+
return SequenceStepOutcome(
|
|
160
|
+
outcome: outcome,
|
|
161
|
+
gestureStartUptimeMs: timing.gestureStartUptimeMs,
|
|
162
|
+
gestureEndUptimeMs: timing.gestureEndUptimeMs
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
// Synthesis unsupported (e.g. macOS) — fall through to the drag-based tapAt below.
|
|
166
|
+
}
|
|
167
|
+
let (timing, outcome) = performGesture(activeApp) {
|
|
168
|
+
switch step.kind {
|
|
169
|
+
case "doubleTap":
|
|
170
|
+
// doubleTapAt per step, matching the behavior of the retired tapSeries doubleTap path.
|
|
171
|
+
return doubleTapAt(app: activeApp, x: x, y: y)
|
|
172
|
+
case "longPress":
|
|
173
|
+
let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0
|
|
174
|
+
return longPressAt(app: activeApp, x: x, y: y, duration: duration)
|
|
175
|
+
case "drag":
|
|
176
|
+
// Route through keyboardAvoidingDragPoints for parity with the individual `.drag` command
|
|
177
|
+
// (RunnerTests+CommandExecution.swift). durationMs is intentionally ignored on this
|
|
178
|
+
// coordinate-drag path, matching that command's non-synthesized branch.
|
|
179
|
+
let dragPoints = keyboardAvoidingDragPoints(
|
|
180
|
+
app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
|
|
181
|
+
return dragAt(
|
|
182
|
+
app: activeApp,
|
|
183
|
+
x: dragPoints.x,
|
|
184
|
+
y: dragPoints.y,
|
|
185
|
+
x2: dragPoints.x2,
|
|
186
|
+
y2: dragPoints.y2,
|
|
187
|
+
holdDuration: coordinateDragHoldDuration()
|
|
188
|
+
)
|
|
189
|
+
default:
|
|
190
|
+
return tapAt(app: activeApp, x: x, y: y)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Sleep AFTER the step — pauseMs is the inter-step gap — but only when the step performed.
|
|
194
|
+
// assembleSequenceExecution stops at the first unsupported outcome, so pausing after a failed
|
|
195
|
+
// step would burn up to 10s of watchdog budget with no following step to separate from.
|
|
196
|
+
if case .performed = outcome, let pauseMs = step.pauseMs, pauseMs > 0 {
|
|
197
|
+
sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
|
|
198
|
+
}
|
|
199
|
+
return SequenceStepOutcome(
|
|
200
|
+
outcome: outcome,
|
|
201
|
+
gestureStartUptimeMs: timing.gestureStartUptimeMs,
|
|
202
|
+
gestureEndUptimeMs: timing.gestureEndUptimeMs
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func sequenceResponse(
|
|
207
|
+
execution: SequenceExecutionResult,
|
|
208
|
+
touchFrame: TouchVisualizationFrame?
|
|
209
|
+
) -> Response {
|
|
210
|
+
return Response(
|
|
211
|
+
ok: true,
|
|
212
|
+
data: DataPayload(
|
|
213
|
+
message: "sequence",
|
|
214
|
+
gestureStartUptimeMs: execution.gestureStartUptimeMs,
|
|
215
|
+
gestureEndUptimeMs: execution.gestureEndUptimeMs,
|
|
216
|
+
x: touchFrame?.x,
|
|
217
|
+
y: touchFrame?.y,
|
|
218
|
+
referenceWidth: touchFrame?.referenceWidth,
|
|
219
|
+
referenceHeight: touchFrame?.referenceHeight,
|
|
220
|
+
completedSteps: execution.completedSteps,
|
|
221
|
+
failedStepIndex: execution.failedStepIndex,
|
|
222
|
+
sequenceResults: execution.results
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private func sequenceInvalidArgs(_ message: String) -> Response {
|
|
228
|
+
Response(ok: false, error: ErrorPayload(code: "INVALID_ARGS", message: message))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// MARK: - In-bundle unit tests (device-free)
|
|
233
|
+
|
|
234
|
+
extension RunnerTests {
|
|
235
|
+
func testSequenceDecodesStepsFromWire() throws {
|
|
236
|
+
let json = """
|
|
237
|
+
{"command":"sequence","commandId":"seq-1","steps":[
|
|
238
|
+
{"kind":"tap","x":100,"y":200},
|
|
239
|
+
{"kind":"doubleTap","x":101,"y":200},
|
|
240
|
+
{"kind":"longPress","x":102,"y":200,"durationMs":300},
|
|
241
|
+
{"kind":"drag","x":10,"y":600,"x2":10,"y2":200,"durationMs":250,"pauseMs":50}
|
|
242
|
+
]}
|
|
243
|
+
"""
|
|
244
|
+
let command = try JSONDecoder().decode(Command.self, from: Data(json.utf8))
|
|
245
|
+
XCTAssertEqual(command.command, .sequence)
|
|
246
|
+
XCTAssertEqual(command.steps?.count, 4)
|
|
247
|
+
XCTAssertEqual(command.steps?[0].kind, "tap")
|
|
248
|
+
XCTAssertEqual(command.steps?[1].kind, "doubleTap")
|
|
249
|
+
XCTAssertEqual(command.steps?[3].x2, 10)
|
|
250
|
+
XCTAssertEqual(command.steps?[3].pauseMs, 50)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func testSequenceAcceptsDoubleTapKind() {
|
|
254
|
+
// A doubleTap step missing coords must fail on the coords check, not the kind allowlist —
|
|
255
|
+
// proving "doubleTap" passes validateSequenceStep without needing a device to execute on.
|
|
256
|
+
let response = executeSequenceForTest(steps: [
|
|
257
|
+
sequenceStep(kind: "doubleTap", x: nil)
|
|
258
|
+
])
|
|
259
|
+
XCTAssertEqual(response.ok, false)
|
|
260
|
+
XCTAssertEqual(response.error?.code, "INVALID_ARGS")
|
|
261
|
+
XCTAssertTrue(response.error?.message.contains("requires finite x and y") ?? false)
|
|
262
|
+
XCTAssertFalse(response.error?.message.contains("unsupported kind") ?? true)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func testSequenceRejectsUnknownKind() throws {
|
|
266
|
+
let response = executeSequenceForTest(steps: [
|
|
267
|
+
sequenceStep(kind: "tap", x: 1, y: 2),
|
|
268
|
+
sequenceStep(kind: "pinch", x: 3, y: 4),
|
|
269
|
+
])
|
|
270
|
+
XCTAssertEqual(response.ok, false)
|
|
271
|
+
XCTAssertEqual(response.error?.code, "INVALID_ARGS")
|
|
272
|
+
XCTAssertTrue(response.error?.message.contains("step 1") ?? false)
|
|
273
|
+
XCTAssertTrue(response.error?.message.contains("pinch") ?? false)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func testSequenceRejectsEmpty() {
|
|
277
|
+
let response = executeSequenceForTest(steps: [])
|
|
278
|
+
XCTAssertEqual(response.ok, false)
|
|
279
|
+
XCTAssertEqual(response.error?.code, "INVALID_ARGS")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
func testSequenceRejectsTooManySteps() {
|
|
283
|
+
let steps = (0..<21).map { _ in sequenceStep(kind: "tap", x: 1, y: 2) }
|
|
284
|
+
let response = executeSequenceForTest(steps: steps)
|
|
285
|
+
XCTAssertEqual(response.ok, false)
|
|
286
|
+
XCTAssertEqual(response.error?.code, "INVALID_ARGS")
|
|
287
|
+
XCTAssertTrue(response.error?.message.contains("at most 20") ?? false)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func testSequenceRejectsDragMissingSecondPoint() {
|
|
291
|
+
let response = executeSequenceForTest(steps: [
|
|
292
|
+
sequenceStep(kind: "tap", x: 1, y: 2),
|
|
293
|
+
sequenceStep(kind: "drag", x: 3, y: 4),
|
|
294
|
+
])
|
|
295
|
+
XCTAssertEqual(response.ok, false)
|
|
296
|
+
XCTAssertEqual(response.error?.code, "INVALID_ARGS")
|
|
297
|
+
XCTAssertTrue(response.error?.message.contains("step 1") ?? false)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func testAssembleSequencePreservesOrderOnSuccess() {
|
|
301
|
+
let steps = [
|
|
302
|
+
sequenceStep(kind: "tap", x: 1, y: 1),
|
|
303
|
+
sequenceStep(kind: "longPress", x: 2, y: 2),
|
|
304
|
+
sequenceStep(kind: "tap", x: 3, y: 3),
|
|
305
|
+
]
|
|
306
|
+
var calls: [Int] = []
|
|
307
|
+
let execution = assembleSequenceExecution(steps: steps) { index, _ in
|
|
308
|
+
calls.append(index)
|
|
309
|
+
return SequenceStepOutcome(
|
|
310
|
+
outcome: .performed,
|
|
311
|
+
gestureStartUptimeMs: Double(index * 10),
|
|
312
|
+
gestureEndUptimeMs: Double(index * 10 + 5)
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
XCTAssertEqual(calls, [0, 1, 2])
|
|
316
|
+
XCTAssertEqual(execution.completedSteps, 3)
|
|
317
|
+
XCTAssertNil(execution.failedStepIndex)
|
|
318
|
+
XCTAssertEqual(execution.results.map { $0.kind }, ["tap", "longPress", "tap"])
|
|
319
|
+
XCTAssertEqual(execution.gestureStartUptimeMs, 0)
|
|
320
|
+
XCTAssertEqual(execution.gestureEndUptimeMs, 25)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func testAssembleSequenceStopsAtFirstFailure() {
|
|
324
|
+
let steps = [
|
|
325
|
+
sequenceStep(kind: "tap", x: 1, y: 1),
|
|
326
|
+
sequenceStep(kind: "drag", x: 2, y: 2),
|
|
327
|
+
sequenceStep(kind: "tap", x: 3, y: 3),
|
|
328
|
+
]
|
|
329
|
+
var calls: [Int] = []
|
|
330
|
+
let execution = assembleSequenceExecution(steps: steps) { index, _ in
|
|
331
|
+
calls.append(index)
|
|
332
|
+
if index == 1 {
|
|
333
|
+
return SequenceStepOutcome(
|
|
334
|
+
outcome: .unsupported(message: "drag unsupported", hint: nil),
|
|
335
|
+
gestureStartUptimeMs: 10,
|
|
336
|
+
gestureEndUptimeMs: 15
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
return SequenceStepOutcome(outcome: .performed, gestureStartUptimeMs: 0, gestureEndUptimeMs: 5)
|
|
340
|
+
}
|
|
341
|
+
// Step 2 is never invoked.
|
|
342
|
+
XCTAssertEqual(calls, [0, 1])
|
|
343
|
+
XCTAssertEqual(execution.completedSteps, 1)
|
|
344
|
+
XCTAssertEqual(execution.failedStepIndex, 1)
|
|
345
|
+
// results.count == completedSteps + 1 (the failed step).
|
|
346
|
+
XCTAssertEqual(execution.results.count, 2)
|
|
347
|
+
XCTAssertEqual(execution.results[1].ok, false)
|
|
348
|
+
XCTAssertEqual(execution.results[1].errorCode, "UNSUPPORTED_OPERATION")
|
|
349
|
+
XCTAssertEqual(execution.results[1].errorMessage, "drag unsupported")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
func testSequenceWorstCaseResponseStaysUnderJournalCap() throws {
|
|
353
|
+
let longMessage = String(repeating: "e", count: 200)
|
|
354
|
+
let results = (0..<20).map { index in
|
|
355
|
+
SequenceStepResult(
|
|
356
|
+
ok: index < 19,
|
|
357
|
+
kind: "drag",
|
|
358
|
+
errorCode: index < 19 ? nil : "UNSUPPORTED_OPERATION",
|
|
359
|
+
errorMessage: index < 19 ? nil : longMessage,
|
|
360
|
+
gestureStartUptimeMs: 123456.789,
|
|
361
|
+
gestureEndUptimeMs: 123466.789
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
let response = Response(
|
|
365
|
+
ok: true,
|
|
366
|
+
data: DataPayload(
|
|
367
|
+
message: "sequence",
|
|
368
|
+
completedSteps: 19,
|
|
369
|
+
failedStepIndex: 19,
|
|
370
|
+
sequenceResults: results
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
let encoded = try JSONEncoder().encode(response)
|
|
374
|
+
XCTAssertLessThan(encoded.count, 16 * 1024)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private func sequenceStep(
|
|
378
|
+
kind: String,
|
|
379
|
+
x: Double?,
|
|
380
|
+
y: Double? = nil,
|
|
381
|
+
x2: Double? = nil,
|
|
382
|
+
y2: Double? = nil
|
|
383
|
+
) -> SequenceStep {
|
|
384
|
+
SequenceStep(
|
|
385
|
+
kind: kind, x: x, y: y, x2: x2, y2: y2, durationMs: nil, pauseMs: nil, synthesized: nil)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// Validation runs before any executor call, so the INVALID_ARGS paths are exercised without
|
|
389
|
+
/// reaching the device executor (which is never invoked when validation rejects).
|
|
390
|
+
private func executeSequenceForTest(steps: [SequenceStep]) -> Response {
|
|
391
|
+
let command = makeSequenceCommand(steps: steps)
|
|
392
|
+
return executeSequence(command: command, activeApp: app)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/// Build a sequence Command via JSON so the test does not depend on the memberwise init's
|
|
396
|
+
/// parameter order.
|
|
397
|
+
private func makeSequenceCommand(steps: [SequenceStep]) -> Command {
|
|
398
|
+
struct SequenceCommandFixture: Encodable {
|
|
399
|
+
let command = "sequence"
|
|
400
|
+
let commandId = "seq-test"
|
|
401
|
+
let steps: [SequenceStep]
|
|
402
|
+
}
|
|
403
|
+
let data = try! JSONEncoder().encode(SequenceCommandFixture(steps: steps))
|
|
404
|
+
return try! JSONDecoder().decode(Command.self, from: data)
|
|
405
|
+
}
|
|
406
|
+
}
|