agent-device 0.18.3 → 0.19.1
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 +5 -1
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.18.3.apk → agent-device-android-multitouch-helper-0.19.1.apk} +0 -0
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.1.apk.sha256 +1 -0
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.18.3.manifest.json → agent-device-android-multitouch-helper-0.19.1.manifest.json} +4 -4
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.18.3.apk → agent-device-android-snapshot-helper-0.19.1.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.1.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.18.3.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTapPointPolicy.swift +28 -0
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +33 -127
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +630 -177
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +155 -0
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +67 -0
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +173 -122
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +64 -0
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -182
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +92 -51
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +213 -137
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +208 -46
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +82 -11
- package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +74 -0
- package/dist/src/agent-browser-tool.js +1 -0
- package/dist/src/android-adb.d.ts +63 -234
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android-input-ownership.js +6 -0
- package/dist/src/android.js +1 -5
- package/dist/src/app-lifecycle.js +7 -0
- package/dist/src/apple-runner-options.js +1 -0
- package/dist/src/apps.js +5 -11
- package/dist/src/artifacts.d.ts +4 -3
- package/dist/src/artifacts.js +1 -1
- package/dist/src/audio-probe.js +1 -1
- package/dist/src/batch.d.ts +2 -177
- package/dist/src/batch.js +1 -1
- package/dist/src/cli-help.js +178 -60
- package/dist/src/cli-output.js +1 -1
- package/dist/src/cli-runner.js +2 -0
- package/dist/src/cli.js +12 -41
- package/dist/src/client-metro-companion.js +1 -0
- package/dist/src/client.js +3 -0
- package/dist/src/command-metadata.js +1 -0
- package/dist/src/command-schema.js +22 -0
- package/dist/src/command-surface.js +1 -1
- package/dist/src/contracts.d.ts +2 -213
- package/dist/src/contracts.js +1 -1
- package/dist/src/debug-symbols.js +5 -3
- package/dist/src/device-input-state.js +2 -0
- package/dist/src/devices.js +1 -1
- package/dist/src/devices2.js +1 -0
- package/dist/src/devices3.js +1 -0
- package/dist/src/find.js +1 -1
- package/dist/src/finders.d.ts +2 -81
- package/dist/src/finders.js +1 -1
- package/dist/src/generic.js +1 -1
- package/dist/src/handler-utils.js +1 -1
- package/dist/src/helper.js +2 -0
- package/dist/src/host-process.js +2 -0
- package/dist/src/index.d.ts +1444 -1786
- package/dist/src/index.js +1 -1
- package/dist/src/input-actions.js +3 -1
- package/dist/src/input-actions2.js +1 -0
- package/dist/src/install-source.d.ts +13 -14
- package/dist/src/install-source.js +1 -1
- package/dist/src/install-source2.js +1 -0
- package/dist/src/interaction-snapshot.js +1 -0
- package/dist/src/interaction.js +1 -1
- package/dist/src/interactor.js +2 -1
- package/dist/src/internal/bin.d.ts +1 -1
- package/dist/src/internal/bin.js +2 -5
- package/dist/src/internal/companion-tunnel.d.ts +1 -1
- package/dist/src/internal/companion-tunnel.js +1 -1
- package/dist/src/internal/daemon.d.ts +1 -1
- package/dist/src/internal/daemon.js +18 -1
- package/dist/src/internal/png-worker.d.ts +29 -26
- package/dist/src/internal/png-worker.js +1 -1
- package/dist/src/internal/update-check-entry.d.ts +1 -1
- package/dist/src/internal/update-check-entry.js +1 -1
- package/dist/src/io.d.ts +2 -85
- package/dist/src/io.js +1 -1
- package/dist/src/lease.js +1 -1
- package/dist/src/linux.js +1 -1
- package/dist/src/manifest.js +1 -0
- package/dist/src/metro.d.ts +69 -113
- package/dist/src/metro.js +1 -1
- package/dist/src/metro2.js +1 -0
- package/dist/src/notifications.js +1 -1
- package/dist/src/output-path.js +1 -0
- package/dist/src/path-resolution.js +1 -0
- package/dist/src/png-worker-contract.js +1 -0
- package/dist/src/points.js +4 -0
- package/dist/src/project-runtime.js +1 -0
- package/dist/src/provider.js +1 -1
- package/dist/src/providers.js +1 -0
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace-recording.js +2 -5
- package/dist/src/record-trace.js +1 -1
- package/dist/src/recording-provider.js +1 -1
- package/dist/src/registry.js +38 -0
- package/dist/src/remote-config.d.ts +2 -83
- package/dist/src/reporting.js +9 -0
- package/dist/src/rolldown-runtime.js +1 -0
- package/dist/src/runner-client.js +1 -0
- package/dist/src/runner-provider.js +1 -0
- package/dist/src/runner-sequence.js +1 -1
- package/dist/src/runtime-transport.js +1 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/scoped-provider.js +1 -0
- package/dist/src/sdk-android-adb.d.ts +144 -0
- package/dist/src/sdk-android-adb.js +4 -0
- package/dist/src/sdk-app-inventory.d.ts +4 -0
- package/dist/src/sdk-app-inventory.js +1 -0
- package/dist/src/sdk-batch-runner.d.ts +39 -0
- package/dist/src/sdk-batch-runner.js +2 -0
- package/dist/src/sdk-batch.js +2 -0
- package/dist/src/sdk-contracts.d.ts +386 -0
- package/dist/src/sdk-contracts.js +2 -0
- package/dist/src/sdk-finders.d.ts +60 -0
- package/dist/src/sdk-finders.js +1 -0
- package/dist/src/sdk-io.d.ts +78 -0
- package/dist/src/sdk-io.js +1 -0
- package/dist/src/sdk-remote-config.d.ts +139 -0
- package/dist/src/sdk-remote-config.js +1 -0
- package/dist/src/sdk-selectors.js +1 -0
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/selectors.d.ts +38 -137
- package/dist/src/selectors.js +1 -1
- package/dist/src/selectors2.js +1 -0
- package/dist/src/server.js +3 -2
- package/dist/src/session.js +10 -11
- package/dist/src/setting-state.js +1 -0
- package/dist/src/simctl.js +1 -0
- package/dist/src/simulator.js +1 -2
- package/dist/src/snapshot-diagnostics.js +3 -0
- package/dist/src/snapshot-helper.js +5 -0
- package/dist/src/snapshot-quality.js +1 -0
- package/dist/src/snapshot.js +1 -2
- package/dist/src/snapshot2.js +2 -0
- package/dist/src/{5560.js → timeouts.js} +1 -1
- package/dist/src/tool-provider.js +3 -0
- package/dist/src/tool-provider2.js +1 -0
- package/dist/src/ttl-memo.js +1 -0
- package/dist/src/tv-remote.js +14 -0
- package/dist/src/update-check.js +1 -0
- package/dist/src/url.js +1 -0
- package/dist/src/version.js +1 -0
- package/dist/src/web.js +1 -1
- package/dist/src/xml.js +2 -0
- package/package.json +13 -19
- package/server.json +2 -2
- package/skills/agent-device/SKILL.md +8 -7
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.18.3.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.18.3.apk.sha256 +0 -1
- package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +0 -443
- package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +0 -169
- package/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +0 -220
- package/dist/src/1404.js +0 -1
- package/dist/src/1986.js +0 -1
- package/dist/src/2748.js +0 -1
- package/dist/src/2948.js +0 -30
- package/dist/src/3006.js +0 -1
- package/dist/src/314.js +0 -1
- package/dist/src/3340.js +0 -8
- package/dist/src/4418.js +0 -1
- package/dist/src/4428.js +0 -3
- package/dist/src/485.js +0 -1
- package/dist/src/4917.js +0 -1
- package/dist/src/495.js +0 -5
- package/dist/src/5448.js +0 -3
- package/dist/src/5484.js +0 -1
- package/dist/src/5643.js +0 -1
- package/dist/src/587.js +0 -1
- package/dist/src/5913.js +0 -4
- package/dist/src/6849.js +0 -1
- package/dist/src/6918.js +0 -1
- package/dist/src/6967.js +0 -1
- package/dist/src/760.js +0 -1
- package/dist/src/7871.js +0 -1
- package/dist/src/8184.js +0 -1
- package/dist/src/8225.js +0 -1
- package/dist/src/8391.js +0 -1
- package/dist/src/8404.js +0 -2
- package/dist/src/8414.js +0 -2
- package/dist/src/8875.js +0 -4
- package/dist/src/893.js +0 -1
- package/dist/src/8936.js +0 -1
- package/dist/src/9414.js +0 -1
- package/dist/src/9581.js +0 -22
- package/dist/src/9612.js +0 -1
- package/dist/src/9624.js +0 -1
- package/dist/src/9722.js +0 -39
- package/dist/src/devices~1.js +0 -1
- package/dist/src/devices~2.js +0 -1
- package/dist/src/input-actions~1.js +0 -1
- package/dist/src/rslib-runtime.js +0 -1
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests.entitlements +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.h +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerObjCExceptionCatcher.m +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -0
- /package/{apple-runner → dist/apple-runner}/AgentDeviceRunner/RecordingScripts/recording-trim.swift +0 -0
|
@@ -130,58 +130,6 @@ extension RunnerTests {
|
|
|
130
130
|
return Response(ok: true, data: data)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
|
134
|
-
func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
|
|
135
|
-
let response = gestureResponse(
|
|
136
|
-
message: "tapped",
|
|
137
|
-
timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
|
|
138
|
-
fallback: GestureFallback(
|
|
139
|
-
strategy: "xctest-coordinate-tap",
|
|
140
|
-
message: "Runner synthesized coordinate tap is unavailable",
|
|
141
|
-
hint: "Using XCTest coordinate tap fallback."
|
|
142
|
-
)
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
XCTAssertEqual(response.ok, true)
|
|
146
|
-
XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
|
|
147
|
-
XCTAssertEqual(
|
|
148
|
-
response.data?.gestureFallbackMessage,
|
|
149
|
-
"Runner synthesized coordinate tap is unavailable"
|
|
150
|
-
)
|
|
151
|
-
XCTAssertEqual(response.data?.gestureFallbackHint, "Using XCTest coordinate tap fallback.")
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
|
|
155
|
-
let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
|
|
156
|
-
let response = Response(ok: true, data: DataPayload(message: "tapped"))
|
|
157
|
-
|
|
158
|
-
let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
|
|
159
|
-
|
|
160
|
-
XCTAssertEqual(failureResponse?.ok, false)
|
|
161
|
-
XCTAssertEqual(failureResponse?.error?.code, "XCTEST_RECORDED_FAILURE")
|
|
162
|
-
XCTAssertEqual(
|
|
163
|
-
failureResponse?.error?.message,
|
|
164
|
-
"XCTest recorded a failure while executing tap; the action may not have been performed."
|
|
165
|
-
)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
func testXCTestRecordedFailureResponseDoesNotWrapReadOnlyOrRunnerFatalResponses() throws {
|
|
169
|
-
let snapshotCommand = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-1"}"#)
|
|
170
|
-
let tapCommand = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
|
|
171
|
-
let runnerFatalResponse = Response(
|
|
172
|
-
ok: true,
|
|
173
|
-
data: DataPayload(runnerFatal: true, runnerFatalReason: "ax_snapshot_unavailable")
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
XCTAssertNil(
|
|
177
|
-
xctestRecordedFailureResponse(
|
|
178
|
-
command: snapshotCommand,
|
|
179
|
-
response: Response(ok: true, data: DataPayload(nodes: [], truncated: false))
|
|
180
|
-
)
|
|
181
|
-
)
|
|
182
|
-
XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse))
|
|
183
|
-
}
|
|
184
|
-
#endif
|
|
185
133
|
|
|
186
134
|
func execute(command: Command) throws -> Response {
|
|
187
135
|
if command.command == .status {
|
|
@@ -233,32 +181,158 @@ extension RunnerTests {
|
|
|
233
181
|
)
|
|
234
182
|
}
|
|
235
183
|
|
|
184
|
+
/// Tracks one main-queue dispatch so the watchdog and the dispatched block can agree —
|
|
185
|
+
/// under `mainThreadWorkLock` — on exactly one of: finished in time, or abandoned.
|
|
186
|
+
private final class MainThreadWorkState {
|
|
187
|
+
var finished = false
|
|
188
|
+
var abandoned = false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
struct ActiveCommandContext {
|
|
192
|
+
let app: XCUIApplication
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
enum ActiveCommandPreparation {
|
|
196
|
+
case response(Response)
|
|
197
|
+
case context(ActiveCommandContext)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
enum MainThreadBusyState {
|
|
201
|
+
case idle
|
|
202
|
+
case busy(abandonedForSeconds: TimeInterval)
|
|
203
|
+
case wedged(abandonedForSeconds: TimeInterval)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func currentMainThreadBusyState() -> MainThreadBusyState {
|
|
207
|
+
mainThreadWorkLock.lock()
|
|
208
|
+
defer { mainThreadWorkLock.unlock() }
|
|
209
|
+
guard abandonedMainThreadWorkCount > 0 else { return .idle }
|
|
210
|
+
let abandonedFor = abandonedMainThreadWorkSince.map { Date().timeIntervalSince($0) } ?? 0
|
|
211
|
+
if abandonedFor > mainThreadWedgeThreshold {
|
|
212
|
+
return .wedged(abandonedForSeconds: abandonedFor)
|
|
213
|
+
}
|
|
214
|
+
return .busy(abandonedForSeconds: abandonedFor)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private func runnerBusyResponse(command: Command, abandonedForSeconds: TimeInterval) -> Response {
|
|
218
|
+
NSLog(
|
|
219
|
+
"AGENT_DEVICE_RUNNER_BUSY command=%@ commandId=%@ abandonedForSeconds=%.1f",
|
|
220
|
+
command.command.rawValue,
|
|
221
|
+
command.commandId ?? "",
|
|
222
|
+
abandonedForSeconds
|
|
223
|
+
)
|
|
224
|
+
return Response(
|
|
225
|
+
ok: false,
|
|
226
|
+
error: ErrorPayload(
|
|
227
|
+
code: "RUNNER_BUSY",
|
|
228
|
+
message:
|
|
229
|
+
"The iOS runner is still finishing a previous command that exceeded its execution watchdog (usually an accessibility capture on a heavy or animating screen).",
|
|
230
|
+
hint:
|
|
231
|
+
"Wait a few seconds and retry. If snapshots keep failing on this screen, use screenshot as visual truth and interact by coordinates, or navigate to another screen."
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private func runnerWedgedResponse(command: Command, abandonedForSeconds: TimeInterval) -> Response {
|
|
237
|
+
NSLog(
|
|
238
|
+
"AGENT_DEVICE_RUNNER_WEDGED command=%@ commandId=%@ abandonedForSeconds=%.1f",
|
|
239
|
+
command.command.rawValue,
|
|
240
|
+
command.commandId ?? "",
|
|
241
|
+
abandonedForSeconds
|
|
242
|
+
)
|
|
243
|
+
return Response(
|
|
244
|
+
ok: false,
|
|
245
|
+
error: ErrorPayload(
|
|
246
|
+
code: "RUNNER_WEDGED",
|
|
247
|
+
message:
|
|
248
|
+
"The iOS runner main thread has been stuck in abandoned work for \(Int(abandonedForSeconds)) seconds and cannot recover on its own.",
|
|
249
|
+
hint:
|
|
250
|
+
"The runner session will be restarted. Retry the command after the restart; if this screen keeps wedging captures, use screenshot as visual truth and interact by coordinates."
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
236
255
|
private func executeDispatched(command: Command) throws -> Response {
|
|
256
|
+
// XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue
|
|
257
|
+
// block, queueing more main-thread commands behind it only buries the runner deeper.
|
|
258
|
+
// Refuse fast instead so the daemon backs off while the abandoned work drains; past the
|
|
259
|
+
// wedge threshold, escalate so the daemon recycles this runner (#1105).
|
|
260
|
+
switch currentMainThreadBusyState() {
|
|
261
|
+
case .idle:
|
|
262
|
+
break
|
|
263
|
+
case .busy(let abandonedForSeconds):
|
|
264
|
+
return runnerBusyResponse(command: command, abandonedForSeconds: abandonedForSeconds)
|
|
265
|
+
case .wedged(let abandonedForSeconds):
|
|
266
|
+
return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds)
|
|
267
|
+
}
|
|
237
268
|
if Thread.isMainThread {
|
|
238
269
|
return try executeOnMainSafely(command: command)
|
|
239
270
|
}
|
|
240
|
-
|
|
271
|
+
if command.command == .snapshot {
|
|
272
|
+
return try executeSnapshotDispatched(command: command)
|
|
273
|
+
}
|
|
274
|
+
return try runMainThreadWork(
|
|
275
|
+
command: command,
|
|
276
|
+
timeout: mainThreadExecutionTimeout,
|
|
277
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
278
|
+
) {
|
|
279
|
+
try self.executeOnMainSafely(command: command)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func runMainThreadWork<T>(
|
|
284
|
+
command: Command?,
|
|
285
|
+
timeout: TimeInterval,
|
|
286
|
+
timeoutError: @escaping () -> Error,
|
|
287
|
+
onAbandoned: (() -> Void)? = nil,
|
|
288
|
+
onDrained: (() -> Void)? = nil,
|
|
289
|
+
_ work: @escaping () throws -> T
|
|
290
|
+
) throws -> T {
|
|
291
|
+
if Thread.isMainThread {
|
|
292
|
+
return try work()
|
|
293
|
+
}
|
|
294
|
+
var result: Result<T, Error>?
|
|
241
295
|
let semaphore = DispatchSemaphore(value: 0)
|
|
296
|
+
let workState = MainThreadWorkState()
|
|
242
297
|
DispatchQueue.main.async {
|
|
243
298
|
do {
|
|
244
|
-
result = .success(try
|
|
299
|
+
result = .success(try work())
|
|
245
300
|
} catch {
|
|
246
301
|
result = .failure(error)
|
|
247
302
|
}
|
|
303
|
+
self.mainThreadWorkLock.lock()
|
|
304
|
+
if workState.abandoned {
|
|
305
|
+
self.abandonedMainThreadWorkCount -= 1
|
|
306
|
+
if self.abandonedMainThreadWorkCount == 0 {
|
|
307
|
+
self.abandonedMainThreadWorkSince = nil
|
|
308
|
+
NSLog("AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED")
|
|
309
|
+
}
|
|
310
|
+
self.mainThreadWorkLock.unlock()
|
|
311
|
+
onDrained?()
|
|
312
|
+
} else {
|
|
313
|
+
workState.finished = true
|
|
314
|
+
self.mainThreadWorkLock.unlock()
|
|
315
|
+
}
|
|
248
316
|
semaphore.signal()
|
|
249
317
|
}
|
|
250
|
-
let waitResult = semaphore.wait(timeout: .now() +
|
|
318
|
+
let waitResult = semaphore.wait(timeout: .now() + timeout)
|
|
251
319
|
if waitResult == .timedOut {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
mainThreadWorkLock.lock()
|
|
321
|
+
let stillRunning = !workState.finished
|
|
322
|
+
if stillRunning {
|
|
323
|
+
workState.abandoned = true
|
|
324
|
+
abandonedMainThreadWorkCount += 1
|
|
325
|
+
if abandonedMainThreadWorkSince == nil {
|
|
326
|
+
abandonedMainThreadWorkSince = Date()
|
|
327
|
+
}
|
|
328
|
+
onAbandoned?()
|
|
329
|
+
}
|
|
330
|
+
mainThreadWorkLock.unlock()
|
|
331
|
+
throw timeoutError()
|
|
258
332
|
}
|
|
259
333
|
switch result {
|
|
260
|
-
case .success(let
|
|
261
|
-
return
|
|
334
|
+
case .success(let value):
|
|
335
|
+
return value
|
|
262
336
|
case .failure(let error):
|
|
263
337
|
throw error
|
|
264
338
|
case .none:
|
|
@@ -270,6 +344,14 @@ extension RunnerTests {
|
|
|
270
344
|
}
|
|
271
345
|
}
|
|
272
346
|
|
|
347
|
+
private func mainThreadExecutionTimeoutError() -> Error {
|
|
348
|
+
NSError(
|
|
349
|
+
domain: RunnerErrorDomain.general,
|
|
350
|
+
code: RunnerErrorCode.mainThreadExecutionTimedOut,
|
|
351
|
+
userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
273
355
|
// MARK: - Command Handling
|
|
274
356
|
|
|
275
357
|
private func executeOnMainSafely(command: Command) throws -> Response {
|
|
@@ -333,68 +415,171 @@ extension RunnerTests {
|
|
|
333
415
|
}
|
|
334
416
|
}
|
|
335
417
|
|
|
336
|
-
private func
|
|
337
|
-
var
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
418
|
+
private func executeSnapshotDispatched(command: Command) throws -> Response {
|
|
419
|
+
var hasRetried = false
|
|
420
|
+
while true {
|
|
421
|
+
let failureCountBefore = try runMainThreadWork(
|
|
422
|
+
command: command,
|
|
423
|
+
timeout: mainThreadExecutionTimeout,
|
|
424
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
425
|
+
) {
|
|
426
|
+
self.currentXCTestFailureCount()
|
|
427
|
+
}
|
|
428
|
+
let response = try executeSnapshotDispatchedOnce(command: command)
|
|
429
|
+
let recordedFailureResponse = try runMainThreadWork(
|
|
430
|
+
command: command,
|
|
431
|
+
timeout: mainThreadExecutionTimeout,
|
|
432
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
433
|
+
) {
|
|
434
|
+
self.didRecordXCTestFailure(since: failureCountBefore)
|
|
435
|
+
? self.xctestRecordedFailureResponse(command: command, response: response)
|
|
436
|
+
: nil
|
|
437
|
+
}
|
|
438
|
+
if let recordedFailureResponse {
|
|
439
|
+
try runMainThreadWork(
|
|
440
|
+
command: command,
|
|
441
|
+
timeout: mainThreadExecutionTimeout,
|
|
442
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
443
|
+
) {
|
|
444
|
+
self.invalidateCachedTarget(reason: "xctest_recorded_failure")
|
|
347
445
|
}
|
|
348
|
-
|
|
349
|
-
// Do not reuse stale bundle targets when the caller does not explicitly request one.
|
|
350
|
-
currentApp = nil
|
|
351
|
-
currentBundleId = nil
|
|
446
|
+
return recordedFailureResponse
|
|
352
447
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
448
|
+
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
|
|
449
|
+
NSLog(
|
|
450
|
+
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
|
|
451
|
+
command.command.rawValue
|
|
452
|
+
)
|
|
453
|
+
hasRetried = true
|
|
454
|
+
try runMainThreadWork(
|
|
455
|
+
command: command,
|
|
456
|
+
timeout: mainThreadExecutionTimeout,
|
|
457
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
458
|
+
) {
|
|
459
|
+
self.invalidateCachedTarget(reason: "response_unavailable")
|
|
460
|
+
self.sleepFor(self.retryCooldown)
|
|
461
|
+
}
|
|
462
|
+
continue
|
|
360
463
|
}
|
|
464
|
+
return response
|
|
465
|
+
}
|
|
466
|
+
}
|
|
361
467
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
468
|
+
private func executeSnapshotDispatchedOnce(command: Command) throws -> Response {
|
|
469
|
+
let preparation = try runMainThreadWork(
|
|
470
|
+
command: command,
|
|
471
|
+
timeout: mainThreadExecutionTimeout,
|
|
472
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
473
|
+
) {
|
|
474
|
+
try self.prepareActiveCommandContextSafely(command: command)
|
|
475
|
+
}
|
|
476
|
+
switch preparation {
|
|
477
|
+
case .response(let response):
|
|
478
|
+
return response
|
|
479
|
+
case .context(let context):
|
|
480
|
+
return try executeSnapshotPrepared(command: command, activeApp: context.app)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private func executeSnapshotPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
|
|
485
|
+
let options = SnapshotOptions(
|
|
486
|
+
interactiveOnly: command.interactiveOnly ?? false,
|
|
487
|
+
depth: command.depth,
|
|
488
|
+
scope: command.scope,
|
|
489
|
+
raw: command.raw ?? false
|
|
490
|
+
)
|
|
491
|
+
do {
|
|
492
|
+
let payload: DataPayload
|
|
493
|
+
if options.raw {
|
|
494
|
+
payload = try snapshotRaw(app: activeApp, options: options)
|
|
495
|
+
} else {
|
|
496
|
+
payload = try snapshotFast(app: activeApp, options: options)
|
|
497
|
+
}
|
|
498
|
+
setNeedsPostSnapshotInteractionDelay()
|
|
499
|
+
return Response(ok: true, data: payload)
|
|
500
|
+
} catch let failure as SnapshotCaptureFailure {
|
|
501
|
+
invalidateCachedTargetAfterSnapshotFailure()
|
|
502
|
+
return Response(
|
|
503
|
+
ok: false,
|
|
504
|
+
error: ErrorPayload(
|
|
505
|
+
code: failure.code,
|
|
506
|
+
message: failure.message,
|
|
507
|
+
hint: failure.hint
|
|
508
|
+
)
|
|
366
509
|
)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private func setNeedsPostSnapshotInteractionDelay() {
|
|
514
|
+
if Thread.isMainThread {
|
|
515
|
+
needsPostSnapshotInteractionDelay = true
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
guard !hasAbandonedTreeCapture() else {
|
|
519
|
+
NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED")
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
do {
|
|
523
|
+
try runMainThreadWork(
|
|
524
|
+
command: nil,
|
|
525
|
+
timeout: 1,
|
|
526
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
527
|
+
) {
|
|
528
|
+
self.needsPostSnapshotInteractionDelay = true
|
|
376
529
|
}
|
|
530
|
+
} catch {
|
|
531
|
+
NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error))
|
|
532
|
+
}
|
|
533
|
+
}
|
|
377
534
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
)
|
|
390
|
-
if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
|
|
391
|
-
if let bundleId = requestedBundleId {
|
|
392
|
-
return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
|
|
393
|
-
}
|
|
394
|
-
return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
|
|
395
|
-
}
|
|
396
|
-
applyInteractionStabilizationIfNeeded()
|
|
535
|
+
private func invalidateCachedTargetAfterSnapshotFailure() {
|
|
536
|
+
if Thread.isMainThread {
|
|
537
|
+
invalidateCachedTarget(reason: "ax_snapshot_failure")
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
do {
|
|
541
|
+
try runMainThreadWork(
|
|
542
|
+
command: nil,
|
|
543
|
+
timeout: 1,
|
|
544
|
+
timeoutError: mainThreadExecutionTimeoutError
|
|
545
|
+
) {
|
|
546
|
+
self.invalidateCachedTarget(reason: "ax_snapshot_failure")
|
|
397
547
|
}
|
|
548
|
+
} catch {
|
|
549
|
+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_FAILED=%@", String(describing: error))
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private func prepareActiveCommandContextSafely(command: Command) throws -> ActiveCommandPreparation {
|
|
554
|
+
var preparation: ActiveCommandPreparation?
|
|
555
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
556
|
+
preparation = self.prepareActiveCommandContext(command: command)
|
|
557
|
+
})
|
|
558
|
+
if let exceptionMessage {
|
|
559
|
+
throw NSError(
|
|
560
|
+
domain: RunnerErrorDomain.exception,
|
|
561
|
+
code: RunnerErrorCode.objcException,
|
|
562
|
+
userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
guard let preparation else {
|
|
566
|
+
throw NSError(
|
|
567
|
+
domain: RunnerErrorDomain.general,
|
|
568
|
+
code: RunnerErrorCode.commandReturnedNoResponse,
|
|
569
|
+
userInfo: [NSLocalizedDescriptionKey: "snapshot preflight returned no response"]
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
return preparation
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private func executeOnMain(command: Command) throws -> Response {
|
|
576
|
+
let preparation = prepareActiveCommandContext(command: command)
|
|
577
|
+
let activeApp: XCUIApplication
|
|
578
|
+
switch preparation {
|
|
579
|
+
case .response(let response):
|
|
580
|
+
return response
|
|
581
|
+
case .context(let context):
|
|
582
|
+
activeApp = context.app
|
|
398
583
|
}
|
|
399
584
|
|
|
400
585
|
switch command.command {
|
|
@@ -464,6 +649,89 @@ extension RunnerTests {
|
|
|
464
649
|
}
|
|
465
650
|
case .uptime:
|
|
466
651
|
return executeUptime()
|
|
652
|
+
default:
|
|
653
|
+
break
|
|
654
|
+
}
|
|
655
|
+
return try executeOnMainPrepared(command: command, activeApp: activeApp)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation {
|
|
659
|
+
var activeApp = currentApp ?? app
|
|
660
|
+
if shouldSkipAppActivationPreflight(command) {
|
|
661
|
+
activeApp = resolveAppWithoutActivation(command: command)
|
|
662
|
+
} else if !isRunnerLifecycleCommand(command.command) {
|
|
663
|
+
let normalizedBundleId = command.appBundleId?
|
|
664
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
665
|
+
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
|
|
666
|
+
if let bundleId = requestedBundleId {
|
|
667
|
+
if currentBundleId != bundleId || currentApp == nil {
|
|
668
|
+
_ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
// Do not reuse stale bundle targets when the caller does not explicitly request one.
|
|
672
|
+
currentApp = nil
|
|
673
|
+
currentBundleId = nil
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
activeApp = currentApp ?? app
|
|
677
|
+
if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
|
|
678
|
+
activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
|
|
679
|
+
} else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
|
|
680
|
+
ensureRunnerHostAppActive(reason: "missing_app_bundle")
|
|
681
|
+
activeApp = app
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let skipExistenceWait = canUseFastForegroundAppGuard(
|
|
685
|
+
activeApp: activeApp,
|
|
686
|
+
requestedBundleId: requestedBundleId,
|
|
687
|
+
command: command.command
|
|
688
|
+
)
|
|
689
|
+
if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
|
|
690
|
+
if let bundleId = requestedBundleId {
|
|
691
|
+
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
|
|
692
|
+
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
|
|
693
|
+
return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")))
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available")))
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if isInteractionCommand(command.command) {
|
|
701
|
+
if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
|
|
702
|
+
activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
|
|
703
|
+
} else if requestedBundleId == nil, activeApp.state != .runningForeground {
|
|
704
|
+
ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
|
|
705
|
+
activeApp = app
|
|
706
|
+
}
|
|
707
|
+
let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
|
|
708
|
+
activeApp: activeApp,
|
|
709
|
+
requestedBundleId: requestedBundleId,
|
|
710
|
+
command: command.command
|
|
711
|
+
)
|
|
712
|
+
if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
|
|
713
|
+
if let bundleId = requestedBundleId {
|
|
714
|
+
return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")))
|
|
715
|
+
}
|
|
716
|
+
return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available")))
|
|
717
|
+
}
|
|
718
|
+
applyInteractionStabilizationIfNeeded()
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return .context(ActiveCommandContext(app: activeApp))
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private func executeOnMainPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
|
|
725
|
+
var activeApp = activeApp
|
|
726
|
+
switch command.command {
|
|
727
|
+
case .status, .shutdown, .recordStart, .recordStop, .uptime:
|
|
728
|
+
return Response(
|
|
729
|
+
ok: false,
|
|
730
|
+
error: ErrorPayload(
|
|
731
|
+
code: "UNSUPPORTED_OPERATION",
|
|
732
|
+
message: "\(command.command.rawValue) cannot be executed through the prepared command path"
|
|
733
|
+
)
|
|
734
|
+
)
|
|
467
735
|
case .tap:
|
|
468
736
|
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
|
|
469
737
|
let match = findElement(
|
|
@@ -477,6 +745,24 @@ extension RunnerTests {
|
|
|
477
745
|
}
|
|
478
746
|
if let element = match.element {
|
|
479
747
|
let frame = element.frame
|
|
748
|
+
// XCTest reports closed-drawer/off-viewport items as hittable, then
|
|
749
|
+
// "taps" coordinates outside the visible window as a silent no-op.
|
|
750
|
+
// Refuse instead; the daemon falls back to tree-based resolution,
|
|
751
|
+
// which can prefer an on-screen candidate or explain the off-screen
|
|
752
|
+
// state. The check uses the main window frame, not app.frame: on RN
|
|
753
|
+
// apps app.frame unions transformed subtrees (a closed drawer at
|
|
754
|
+
// negative x), so it happily "contains" unreachable coordinates.
|
|
755
|
+
// The DECISION lives in TapPointPolicy (golden parity table with the
|
|
756
|
+
// TS twin); onScreenWindowFrame only supplies the frame.
|
|
757
|
+
if !match.usedNonHittableFallback
|
|
758
|
+
&& !TapPointPolicy.isAllowed(
|
|
759
|
+
elementFrame: frame,
|
|
760
|
+
windowFrame: onScreenWindowFrame(app: activeApp)
|
|
761
|
+
) {
|
|
762
|
+
return Response(ok: false, error: ErrorPayload(
|
|
763
|
+
code: "ELEMENT_OFFSCREEN",
|
|
764
|
+
message: "element resolved off-screen at (\(Int(frame.midX)), \(Int(frame.midY)))"))
|
|
765
|
+
}
|
|
480
766
|
let isTextEntry = isTextEntryElement(element)
|
|
481
767
|
let touchFrame = frame.isEmpty
|
|
482
768
|
? nil
|
|
@@ -504,27 +790,19 @@ extension RunnerTests {
|
|
|
504
790
|
}
|
|
505
791
|
return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
|
|
506
792
|
}
|
|
507
|
-
if let text = command.text {
|
|
508
|
-
if let element = findElement(app: activeApp, text: text) {
|
|
509
|
-
let (timing, outcome) = performGesture(activeApp) {
|
|
510
|
-
activateElement(app: activeApp, element: element, action: "tap by text")
|
|
511
|
-
}
|
|
512
|
-
if let response = unsupportedResponse(for: outcome) {
|
|
513
|
-
return response
|
|
514
|
-
}
|
|
515
|
-
return gestureResponse(message: "tapped", timing: timing)
|
|
516
|
-
}
|
|
517
|
-
return Response(ok: false, error: ErrorPayload(message: "element not found"))
|
|
518
|
-
}
|
|
519
793
|
if let x = command.x, let y = command.y {
|
|
520
794
|
var fallback: GestureFallback?
|
|
521
795
|
if command.synthesized == true {
|
|
796
|
+
let policyKind = SynthesizedGesturePolicyKind.coordinateTap
|
|
797
|
+
let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(policyKind))
|
|
522
798
|
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
523
|
-
synthesizedTapAt(app: activeApp, x: x, y: y)
|
|
799
|
+
synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
|
|
524
800
|
}
|
|
525
801
|
if case .performed = outcome {
|
|
802
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
|
|
526
803
|
return gestureResponse(message: "tapped", timing: timing)
|
|
527
804
|
}
|
|
805
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
|
|
528
806
|
fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
|
|
529
807
|
}
|
|
530
808
|
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
|
|
@@ -539,7 +817,7 @@ extension RunnerTests {
|
|
|
539
817
|
fallback: fallback
|
|
540
818
|
)
|
|
541
819
|
}
|
|
542
|
-
return Response(ok: false, error: ErrorPayload(message: "tap requires
|
|
820
|
+
return Response(ok: false, error: ErrorPayload(message: "tap requires a selector or x/y"))
|
|
543
821
|
case .mouseClick:
|
|
544
822
|
guard let x = command.x, let y = command.y else {
|
|
545
823
|
return Response(ok: false, error: ErrorPayload(message: "mouseClick requires x and y"))
|
|
@@ -588,12 +866,13 @@ extension RunnerTests {
|
|
|
588
866
|
y2: y2,
|
|
589
867
|
durationMs: command.durationMs,
|
|
590
868
|
synthesized: command.synthesized == true,
|
|
591
|
-
message: "dragged"
|
|
869
|
+
message: "dragged",
|
|
870
|
+
synthesizedPolicyKind: .synthesizedDrag
|
|
592
871
|
)
|
|
593
872
|
case .scroll:
|
|
594
|
-
// Fused frame-resolve + drag scroll for non-tvOS.
|
|
595
|
-
//
|
|
596
|
-
//
|
|
873
|
+
// Fused frame-resolve + drag scroll for non-tvOS. On iOS this intentionally stays on the
|
|
874
|
+
// AX-free synthesized coordinate lane so scroll keeps working when XCTest cannot serialize
|
|
875
|
+
// the accessibility tree.
|
|
597
876
|
guard let direction = command.direction,
|
|
598
877
|
direction == "up" || direction == "down" || direction == "left" || direction == "right"
|
|
599
878
|
else {
|
|
@@ -605,7 +884,14 @@ extension RunnerTests {
|
|
|
605
884
|
)
|
|
606
885
|
)
|
|
607
886
|
}
|
|
608
|
-
let
|
|
887
|
+
let scrollPolicyKind = SynthesizedGesturePolicyKind.scroll
|
|
888
|
+
guard let scrollContext = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(scrollPolicyKind)) else {
|
|
889
|
+
return Response(
|
|
890
|
+
ok: false,
|
|
891
|
+
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
let frame = scrollReferenceFrame(app: activeApp, context: scrollContext)
|
|
609
895
|
guard frame.width > 0, frame.height > 0 else {
|
|
610
896
|
return Response(
|
|
611
897
|
ok: false,
|
|
@@ -645,8 +931,10 @@ extension RunnerTests {
|
|
|
645
931
|
x2: frame.minX + plan.x2,
|
|
646
932
|
y2: frame.minY + plan.y2,
|
|
647
933
|
durationMs: command.durationMs,
|
|
648
|
-
synthesized:
|
|
649
|
-
message: "scrolled"
|
|
934
|
+
synthesized: shouldUseSynthesizedScrollPath(),
|
|
935
|
+
message: "scrolled",
|
|
936
|
+
synthesizedContext: scrollContext.withReferenceFrame(frame),
|
|
937
|
+
synthesizedPolicyKind: scrollPolicyKind
|
|
650
938
|
)
|
|
651
939
|
case .desktopScroll:
|
|
652
940
|
guard let direction = command.direction,
|
|
@@ -780,33 +1068,7 @@ extension RunnerTests {
|
|
|
780
1068
|
}
|
|
781
1069
|
return Response(ok: true, data: DataPayload(text: text))
|
|
782
1070
|
case .snapshot:
|
|
783
|
-
|
|
784
|
-
interactiveOnly: command.interactiveOnly ?? false,
|
|
785
|
-
depth: command.depth,
|
|
786
|
-
scope: command.scope,
|
|
787
|
-
raw: command.raw ?? false
|
|
788
|
-
)
|
|
789
|
-
do {
|
|
790
|
-
let payload: DataPayload
|
|
791
|
-
if options.raw {
|
|
792
|
-
payload = try snapshotRaw(app: activeApp, options: options)
|
|
793
|
-
} else {
|
|
794
|
-
payload = try snapshotFast(app: activeApp, options: options)
|
|
795
|
-
}
|
|
796
|
-
needsPostSnapshotInteractionDelay = true
|
|
797
|
-
return Response(ok: true, data: payload)
|
|
798
|
-
} catch let failure as SnapshotCaptureFailure {
|
|
799
|
-
invalidateCachedTarget(reason: "ax_snapshot_failure")
|
|
800
|
-
// Other thrown errors fall through to executeOnMainSafely's generic error response.
|
|
801
|
-
return Response(
|
|
802
|
-
ok: false,
|
|
803
|
-
error: ErrorPayload(
|
|
804
|
-
code: failure.code,
|
|
805
|
-
message: failure.message,
|
|
806
|
-
hint: failure.hint
|
|
807
|
-
)
|
|
808
|
-
)
|
|
809
|
-
}
|
|
1071
|
+
return try executeSnapshotPrepared(command: command, activeApp: activeApp)
|
|
810
1072
|
case .screenshot:
|
|
811
1073
|
let screenshot: XCUIScreenshot
|
|
812
1074
|
#if os(macOS)
|
|
@@ -998,10 +1260,9 @@ extension RunnerTests {
|
|
|
998
1260
|
}
|
|
999
1261
|
}
|
|
1000
1262
|
|
|
1001
|
-
/// Shared drag execution for `.drag` and the fused `.scroll`.
|
|
1002
|
-
///
|
|
1003
|
-
///
|
|
1004
|
-
/// gestureResponse(.drag). `.scroll` uses the synthesized path only when a duration is requested.
|
|
1263
|
+
/// Shared drag execution for `.drag` and the fused `.scroll`. The iOS synthesized lane keeps
|
|
1264
|
+
/// each command's fallback policy explicit: scroll requires private synthesis, while explicit
|
|
1265
|
+
/// synthesized drag can still use the coordinate fallback unless AX is known unavailable.
|
|
1005
1266
|
private func executeDragGesture(
|
|
1006
1267
|
activeApp: XCUIApplication,
|
|
1007
1268
|
x: Double,
|
|
@@ -1010,8 +1271,30 @@ extension RunnerTests {
|
|
|
1010
1271
|
y2: Double,
|
|
1011
1272
|
durationMs: Double?,
|
|
1012
1273
|
synthesized: Bool,
|
|
1013
|
-
message: String
|
|
1274
|
+
message: String,
|
|
1275
|
+
synthesizedContext: SynthesizedCoordinateContext? = nil,
|
|
1276
|
+
synthesizedPolicyKind: SynthesizedGesturePolicyKind
|
|
1014
1277
|
) -> Response {
|
|
1278
|
+
let commandName = dragCommandName(message: message)
|
|
1279
|
+
guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
|
|
1280
|
+
return Response(
|
|
1281
|
+
ok: false,
|
|
1282
|
+
error: ErrorPayload(code: "INVALID_ARGS", message: "\(commandName) requires finite coordinates")
|
|
1283
|
+
)
|
|
1284
|
+
}
|
|
1285
|
+
if synthesized, let synthesizedResponse = executeSynthesizedDragGesture(
|
|
1286
|
+
activeApp: activeApp,
|
|
1287
|
+
x: x,
|
|
1288
|
+
y: y,
|
|
1289
|
+
x2: x2,
|
|
1290
|
+
y2: y2,
|
|
1291
|
+
durationMs: durationMs,
|
|
1292
|
+
message: message,
|
|
1293
|
+
context: synthesizedContext,
|
|
1294
|
+
policyKind: synthesizedPolicyKind
|
|
1295
|
+
) {
|
|
1296
|
+
return synthesizedResponse
|
|
1297
|
+
}
|
|
1015
1298
|
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
|
|
1016
1299
|
let dragFrame = resolvedDragVisualizationFrame(
|
|
1017
1300
|
app: activeApp,
|
|
@@ -1023,6 +1306,7 @@ extension RunnerTests {
|
|
|
1023
1306
|
var fallback: GestureFallback?
|
|
1024
1307
|
if synthesized {
|
|
1025
1308
|
let durationMs = min(max(durationMs ?? 250, 16), 10000)
|
|
1309
|
+
let context = synthesizedCoordinateContext(policy: synthesizedGesturePolicy(synthesizedPolicyKind))
|
|
1026
1310
|
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
1027
1311
|
synthesizedDragAt(
|
|
1028
1312
|
app: activeApp,
|
|
@@ -1030,7 +1314,8 @@ extension RunnerTests {
|
|
|
1030
1314
|
y: dragPoints.y,
|
|
1031
1315
|
x2: dragPoints.x2,
|
|
1032
1316
|
y2: dragPoints.y2,
|
|
1033
|
-
durationMs: durationMs
|
|
1317
|
+
durationMs: durationMs,
|
|
1318
|
+
context: context
|
|
1034
1319
|
)
|
|
1035
1320
|
}
|
|
1036
1321
|
if case .performed = outcome {
|
|
@@ -1062,6 +1347,154 @@ extension RunnerTests {
|
|
|
1062
1347
|
)
|
|
1063
1348
|
}
|
|
1064
1349
|
|
|
1350
|
+
private func executeSynthesizedDragGesture(
|
|
1351
|
+
activeApp: XCUIApplication,
|
|
1352
|
+
x: Double,
|
|
1353
|
+
y: Double,
|
|
1354
|
+
x2: Double,
|
|
1355
|
+
y2: Double,
|
|
1356
|
+
durationMs: Double?,
|
|
1357
|
+
message: String,
|
|
1358
|
+
context: SynthesizedCoordinateContext?,
|
|
1359
|
+
policyKind: SynthesizedGesturePolicyKind
|
|
1360
|
+
) -> Response? {
|
|
1361
|
+
#if os(iOS)
|
|
1362
|
+
let policy = synthesizedGesturePolicy(policyKind)
|
|
1363
|
+
let context = context ?? synthesizedCoordinateContext(policy: policy)
|
|
1364
|
+
guard let plan = axFreeSynthesizedDragPlan(
|
|
1365
|
+
app: activeApp,
|
|
1366
|
+
x: x,
|
|
1367
|
+
y: y,
|
|
1368
|
+
x2: x2,
|
|
1369
|
+
y2: y2,
|
|
1370
|
+
context: context
|
|
1371
|
+
)
|
|
1372
|
+
else {
|
|
1373
|
+
if context?.allowsXCTestCoordinateFallback == true {
|
|
1374
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
|
|
1375
|
+
return executeCoordinateDragFallback(
|
|
1376
|
+
activeApp: activeApp,
|
|
1377
|
+
x: x,
|
|
1378
|
+
y: y,
|
|
1379
|
+
x2: x2,
|
|
1380
|
+
y2: y2,
|
|
1381
|
+
durationMs: durationMs,
|
|
1382
|
+
message: message,
|
|
1383
|
+
fallback: nil
|
|
1384
|
+
)
|
|
1385
|
+
}
|
|
1386
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
|
|
1387
|
+
return Response(
|
|
1388
|
+
ok: false,
|
|
1389
|
+
error: ErrorPayload(
|
|
1390
|
+
code: "INVALID_ARGS",
|
|
1391
|
+
message: "\(dragCommandName(message: message)) could not resolve a finite synthesized coordinate frame"
|
|
1392
|
+
)
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
let durationMs = min(max(durationMs ?? 250, 16), 10000)
|
|
1396
|
+
let dragFrame = axFreeDragVisualizationFrame(
|
|
1397
|
+
x: plan.points.x,
|
|
1398
|
+
y: plan.points.y,
|
|
1399
|
+
x2: plan.points.x2,
|
|
1400
|
+
y2: plan.points.y2,
|
|
1401
|
+
referenceFrame: plan.referenceFrame
|
|
1402
|
+
)
|
|
1403
|
+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
|
|
1404
|
+
synthesizedDragAt(
|
|
1405
|
+
app: activeApp,
|
|
1406
|
+
x: plan.points.x,
|
|
1407
|
+
y: plan.points.y,
|
|
1408
|
+
x2: plan.points.x2,
|
|
1409
|
+
y2: plan.points.y2,
|
|
1410
|
+
durationMs: durationMs,
|
|
1411
|
+
context: plan.context
|
|
1412
|
+
)
|
|
1413
|
+
}
|
|
1414
|
+
if case .performed = outcome {
|
|
1415
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: false)
|
|
1416
|
+
return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
|
|
1417
|
+
}
|
|
1418
|
+
if plan.context.allowsXCTestCoordinateFallback {
|
|
1419
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: true)
|
|
1420
|
+
return executeCoordinateDragFallback(
|
|
1421
|
+
activeApp: activeApp,
|
|
1422
|
+
x: plan.points.x,
|
|
1423
|
+
y: plan.points.y,
|
|
1424
|
+
x2: plan.points.x2,
|
|
1425
|
+
y2: plan.points.y2,
|
|
1426
|
+
durationMs: durationMs,
|
|
1427
|
+
message: message,
|
|
1428
|
+
fallback: gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
|
|
1429
|
+
)
|
|
1430
|
+
}
|
|
1431
|
+
logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: false)
|
|
1432
|
+
return unsupportedResponse(for: outcome)
|
|
1433
|
+
#else
|
|
1434
|
+
return nil
|
|
1435
|
+
#endif
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private func executeCoordinateDragFallback(
|
|
1439
|
+
activeApp: XCUIApplication,
|
|
1440
|
+
x: Double,
|
|
1441
|
+
y: Double,
|
|
1442
|
+
x2: Double,
|
|
1443
|
+
y2: Double,
|
|
1444
|
+
durationMs: Double?,
|
|
1445
|
+
message: String,
|
|
1446
|
+
fallback: GestureFallback?
|
|
1447
|
+
) -> Response {
|
|
1448
|
+
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
|
|
1449
|
+
let dragFrame = resolvedDragVisualizationFrame(
|
|
1450
|
+
app: activeApp,
|
|
1451
|
+
x: dragPoints.x,
|
|
1452
|
+
y: dragPoints.y,
|
|
1453
|
+
x2: dragPoints.x2,
|
|
1454
|
+
y2: dragPoints.y2
|
|
1455
|
+
)
|
|
1456
|
+
let holdDuration = synthesizedSwipeFallbackHoldDuration(durationMs: durationMs ?? 250)
|
|
1457
|
+
let (timing, outcome) = performGesture(activeApp) {
|
|
1458
|
+
dragAt(
|
|
1459
|
+
app: activeApp,
|
|
1460
|
+
x: dragPoints.x,
|
|
1461
|
+
y: dragPoints.y,
|
|
1462
|
+
x2: dragPoints.x2,
|
|
1463
|
+
y2: dragPoints.y2,
|
|
1464
|
+
holdDuration: holdDuration
|
|
1465
|
+
)
|
|
1466
|
+
}
|
|
1467
|
+
if let response = unsupportedResponse(for: outcome) {
|
|
1468
|
+
return response
|
|
1469
|
+
}
|
|
1470
|
+
return gestureResponse(
|
|
1471
|
+
message: message,
|
|
1472
|
+
timing: timing,
|
|
1473
|
+
frame: .drag(dragFrame),
|
|
1474
|
+
fallback: fallback
|
|
1475
|
+
)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private func scrollReferenceFrame(app: XCUIApplication, context: SynthesizedCoordinateContext) -> CGRect {
|
|
1479
|
+
#if os(iOS)
|
|
1480
|
+
return synthesizedFrameAvoidingKeyboardWhenAllowed(app: app, context: context)
|
|
1481
|
+
#else
|
|
1482
|
+
return resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
|
|
1483
|
+
#endif
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private func shouldUseSynthesizedScrollPath() -> Bool {
|
|
1487
|
+
#if os(iOS)
|
|
1488
|
+
return true
|
|
1489
|
+
#else
|
|
1490
|
+
return false
|
|
1491
|
+
#endif
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private func dragCommandName(message: String) -> String {
|
|
1495
|
+
return message == "scrolled" ? "scroll" : "drag"
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1065
1498
|
private func currentXCTestFailureCount() -> Int {
|
|
1066
1499
|
return testRun?.failureCount ?? 0
|
|
1067
1500
|
}
|
|
@@ -1094,20 +1527,31 @@ extension RunnerTests {
|
|
|
1094
1527
|
|
|
1095
1528
|
private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
|
|
1096
1529
|
#if os(iOS)
|
|
1097
|
-
// Coordinate-only synthesized taps can run after an AX-fatal screen because they do not
|
|
1098
|
-
// app activation, window lookup, keyboard lookup, or element resolution. Selector/text
|
|
1099
|
-
// intentionally stay on the normal AX path because they need an element query.
|
|
1530
|
+
// Coordinate-only synthesized taps can run after an AX-fatal screen because they do not
|
|
1531
|
+
// need app activation, window lookup, keyboard lookup, or element resolution. Selector/text
|
|
1532
|
+
// interactions intentionally stay on the normal AX path because they need an element query.
|
|
1533
|
+
// Scroll/drag/sequence keep the normal foreground guard and stabilization path.
|
|
1534
|
+
guard command.text == nil, command.selectorKey == nil else { return false }
|
|
1535
|
+
guard hasCachedTargetForActivationSkip(command: command) else { return false }
|
|
1100
1536
|
return command.command == .tap
|
|
1101
1537
|
&& command.synthesized == true
|
|
1102
1538
|
&& command.x != nil
|
|
1103
1539
|
&& command.y != nil
|
|
1104
|
-
&& command.text == nil
|
|
1105
|
-
&& command.selectorKey == nil
|
|
1106
1540
|
#else
|
|
1107
1541
|
return false
|
|
1108
1542
|
#endif
|
|
1109
1543
|
}
|
|
1110
1544
|
|
|
1545
|
+
private func hasCachedTargetForActivationSkip(command: Command) -> Bool {
|
|
1546
|
+
guard currentApp != nil else { return false }
|
|
1547
|
+
guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
1548
|
+
!bundleId.isEmpty
|
|
1549
|
+
else {
|
|
1550
|
+
return true
|
|
1551
|
+
}
|
|
1552
|
+
return currentBundleId == bundleId
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1111
1555
|
private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
|
|
1112
1556
|
guard let bundleId = command.appBundleId?
|
|
1113
1557
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
@@ -1128,6 +1572,7 @@ extension RunnerTests {
|
|
|
1128
1572
|
let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
|
|
1129
1573
|
let textEntryMode = resolveTextEntryMode(command)
|
|
1130
1574
|
let target: TextEntryTarget
|
|
1575
|
+
let focusStartedAt = Date()
|
|
1131
1576
|
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
|
|
1132
1577
|
let match = findElement(
|
|
1133
1578
|
app: activeApp,
|
|
@@ -1148,6 +1593,13 @@ extension RunnerTests {
|
|
|
1148
1593
|
} else {
|
|
1149
1594
|
target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
|
|
1150
1595
|
}
|
|
1596
|
+
NSLog(
|
|
1597
|
+
"AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=focus durationMs=%.1f chars=%d mode=%@",
|
|
1598
|
+
command.commandId ?? "",
|
|
1599
|
+
Date().timeIntervalSince(focusStartedAt) * 1000.0,
|
|
1600
|
+
text.count,
|
|
1601
|
+
textEntryModeName(textEntryMode)
|
|
1602
|
+
)
|
|
1151
1603
|
if textEntryMode == .replacement {
|
|
1152
1604
|
guard target.element != nil else {
|
|
1153
1605
|
let message =
|
|
@@ -1162,7 +1614,8 @@ extension RunnerTests {
|
|
|
1162
1614
|
target: target,
|
|
1163
1615
|
text: text,
|
|
1164
1616
|
delaySeconds: delaySeconds,
|
|
1165
|
-
repairMode: textEntryMode
|
|
1617
|
+
repairMode: textEntryMode,
|
|
1618
|
+
commandId: command.commandId
|
|
1166
1619
|
)
|
|
1167
1620
|
if textResult.verified == false {
|
|
1168
1621
|
let expected = textResult.expectedText ?? ""
|