agent-device 0.14.8 → 0.15.0

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.
Files changed (49) hide show
  1. package/README.md +8 -6
  2. package/android-snapshot-helper/README.md +4 -2
  3. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.8.apk → agent-device-android-snapshot-helper-0.15.0.apk} +0 -0
  4. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.0.apk.sha256 +1 -0
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.8.manifest.json → agent-device-android-snapshot-helper-0.15.0.manifest.json} +6 -6
  6. package/dist/src/1769.js +7 -0
  7. package/dist/src/2151.js +429 -0
  8. package/dist/src/221.js +4 -4
  9. package/dist/src/2842.js +1 -0
  10. package/dist/src/3572.js +1 -0
  11. package/dist/src/4057.js +1 -1
  12. package/dist/src/840.js +2 -0
  13. package/dist/src/9542.js +2 -2
  14. package/dist/src/9639.js +2 -2
  15. package/dist/src/9818.js +1 -1
  16. package/dist/src/android-adb.d.ts +49 -11
  17. package/dist/src/android-adb.js +1 -1
  18. package/dist/src/android-snapshot-helper.d.ts +35 -2
  19. package/dist/src/cli.js +60 -57
  20. package/dist/src/contracts.d.ts +2 -0
  21. package/dist/src/finders.d.ts +2 -0
  22. package/dist/src/index.d.ts +25 -22
  23. package/dist/src/internal/companion-tunnel.js +1 -1
  24. package/dist/src/internal/daemon.js +51 -23
  25. package/dist/src/remote-config.d.ts +17 -14
  26. package/dist/src/selectors.d.ts +3 -0
  27. package/dist/src/server.js +2 -20
  28. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +7 -1
  29. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +210 -56
  30. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +890 -99
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +94 -7
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +8 -0
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +24 -0
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +2 -0
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +185 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -2
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +26 -0
  38. package/package.json +25 -11
  39. package/server.json +3 -3
  40. package/skills/agent-device/SKILL.md +6 -1
  41. package/skills/dogfood/SKILL.md +3 -1
  42. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.8.apk.sha256 +0 -1
  43. package/dist/src/180.js +0 -1
  44. package/dist/src/6108.js +0 -26
  45. package/dist/src/6642.js +0 -1
  46. package/dist/src/7462.js +0 -1
  47. package/dist/src/8809.js +0 -8
  48. package/dist/src/command-schema.js +0 -381
  49. package/skills/react-devtools/SKILL.md +0 -48
@@ -1,4 +1,20 @@
1
- export declare type RemoteConfigProfile = {
1
+ declare type RemoteConfigMetroOptions = {
2
+ metroProjectRoot?: string;
3
+ metroKind?: 'auto' | 'react-native' | 'expo';
4
+ metroPublicBaseUrl?: string;
5
+ metroProxyBaseUrl?: string;
6
+ metroBearerToken?: string;
7
+ metroPreparePort?: number;
8
+ metroListenHost?: string;
9
+ metroStatusHost?: string;
10
+ metroStartupTimeoutMs?: number;
11
+ metroProbeTimeoutMs?: number;
12
+ metroRuntimeFile?: string;
13
+ metroNoReuseExisting?: boolean;
14
+ metroNoInstallDeps?: boolean;
15
+ };
16
+
17
+ export declare type RemoteConfigProfile = RemoteConfigMetroOptions & {
2
18
  stateDir?: string;
3
19
  daemonBaseUrl?: string;
4
20
  daemonAuthToken?: string;
@@ -17,19 +33,6 @@ export declare type RemoteConfigProfile = {
17
33
  iosSimulatorDeviceSet?: string;
18
34
  androidDeviceAllowlist?: string;
19
35
  session?: string;
20
- metroProjectRoot?: string;
21
- metroKind?: 'auto' | 'react-native' | 'expo';
22
- metroPublicBaseUrl?: string;
23
- metroProxyBaseUrl?: string;
24
- metroBearerToken?: string;
25
- metroPreparePort?: number;
26
- metroListenHost?: string;
27
- metroStatusHost?: string;
28
- metroStartupTimeoutMs?: number;
29
- metroProbeTimeoutMs?: number;
30
- metroRuntimeFile?: string;
31
- metroNoReuseExisting?: boolean;
32
- metroNoInstallDeps?: boolean;
33
36
  };
34
37
 
35
38
  export declare type RemoteConfigProfileOptions = {
@@ -35,6 +35,7 @@ declare type RawSnapshotNode = {
35
35
  rect?: Rect;
36
36
  enabled?: boolean;
37
37
  selected?: boolean;
38
+ focused?: boolean;
38
39
  hittable?: boolean;
39
40
  depth?: number;
40
41
  parentIndex?: number;
@@ -45,6 +46,7 @@ declare type RawSnapshotNode = {
45
46
  surface?: string;
46
47
  hiddenContentAbove?: boolean;
47
48
  hiddenContentBelow?: boolean;
49
+ presentationHints?: string[];
48
50
  };
49
51
 
50
52
  declare type Rect = {
@@ -103,6 +105,7 @@ declare type SnapshotState = {
103
105
  truncated?: boolean;
104
106
  backend?: SnapshotBackend;
105
107
  comparisonSafe?: boolean;
108
+ presentationKey?: string;
106
109
  };
107
110
 
108
111
  export declare function tryParseSelectorChain(expression: string): SelectorChain | null;
@@ -1,20 +1,2 @@
1
- import{buildCommandUsageText as e,buildUsageText as t}from"./command-schema.js";import{readVersion as r}from"./9671.js";let n="agent-device",o=["workflow","debugging","react-devtools","remote","macos","dogfood"];function i(e={}){let t=!1===e.global?"npx -y agent-device mcp":"agent-device mcp",r=!1===e.global?"No global install required.":"npm install -g agent-device",n=e.client?`
2
- Client hint: ${e.client}`:"";return`${r}
3
-
4
- MCP server command:
5
- ${t}
6
-
7
- Generic MCP JSON:
8
- {
9
- "mcpServers": {
10
- "agent-device": {
11
- "command": "${!1===e.global?"npx":"agent-device"}",
12
- "args": ${JSON.stringify(!1===e.global?["-y","agent-device","mcp"]:["mcp"])}
13
- }
14
- }
15
- }
16
-
17
- Use this server for discovery and routing only. For device actions, call the CLI commands returned by the help tool, starting with:
18
- agent-device help workflow${n}
19
- `}function a(r={}){if(r.topic&&r.command)throw Error("Provide either topic or command, not both.");let n=r.topic??r.command;if(!n)return t();if(r.topic&&!d(r.topic))throw Error(`Unknown help topic: ${r.topic}. Expected one of: ${o.join(", ")}`);let i=e(n);if(!i)throw Error(`Unknown command or help topic: ${n}`);return i}function s(){return[{uri:"agent-device://install",name:"agent-device MCP install",description:"Install and client configuration snippets.",mimeType:"text/markdown"},{uri:"agent-device://help",name:"agent-device command list",description:"Version-matched command list and global flags.",mimeType:"text/plain"},...o.map(e=>({uri:`agent-device://help/${e}`,name:`agent-device help ${e}`,description:`Version-matched ${e} workflow guidance.`,mimeType:"text/plain"}))]}function c(){return[l("agent-device-workflow","Plan a normal app automation loop."),l("agent-device-debugging","Collect focused debugging evidence."),l("agent-device-dogfood","Run exploratory QA with reproducible evidence."),l("agent-device-react-native-performance","Inspect React Native renders."),l("agent-device-macos","Inspect a macOS app or surface.")]}function l(e,t){return{name:e,description:t,arguments:[{name:"target",description:"Optional app, device, or task target.",required:!1}]}}function d(e){return o.includes(e)}function p(e){if("2.0"!==e.jsonrpc||"string"!=typeof e.method)return g(e.id??null,-32600,"Invalid JSON-RPC request.");if(void 0===e.id)return null;try{var l,p;return l=e.id,p=function(e,l){switch(e){case"initialize":return{protocolVersion:"2025-11-25",capabilities:{tools:{},resources:{},prompts:{}},serverInfo:{name:n,version:r()}};case"ping":return{};case"tools/list":return{tools:[{name:"status",description:"Report the installed agent-device MCP router and CLI metadata.",inputSchema:{type:"object",properties:{},additionalProperties:!1}},{name:"install",description:"Return install and MCP client configuration snippets for agent-device.",inputSchema:{type:"object",properties:{client:{type:"string",description:"Optional client name for labeling the returned guidance."},global:{type:"boolean",description:"Use a global agent-device binary when true; use npx when false."}},additionalProperties:!1}},{name:"help",description:"Return version-matched CLI help for a workflow topic or command.",inputSchema:{type:"object",properties:{topic:{type:"string",enum:o,description:"Agent workflow topic."},command:{type:"string",description:"CLI command name such as snapshot, open, logs, or react-devtools."}},additionalProperties:!1}}]};case"tools/call":var p,g,w,y=l;let b=m(y),$=h(b,"name"),k=f(b.arguments);try{if("status"===$)return u(JSON.stringify({name:n,registryName:"io.github.callstackincubator/agent-device",version:r(),transport:"stdio",command:"agent-device mcp",node:process.version,capabilities:{tools:["status","install","help"],resources:s().map(e=>e.uri),prompts:c().map(e=>e.name)},note:"This MCP server routes agents to the agent-device CLI. It does not expose device automation or shell execution tools."},null,2));if("install"===$)return u(i(k));if("help"===$)return u(a(k));throw Error(`Unknown tool: ${$}`)}catch(e){return u(e instanceof Error?e.message:String(e),!0)}return;case"resources/list":return{resources:s()};case"resources/read":let x;return p=l,{contents:[{uri:x=h(m(p),"uri"),mimeType:"agent-device://install"===x?"text/markdown":"text/plain",text:function(e){if("agent-device://install"===e)return i();if("agent-device://help"===e)return t();let r=e.startsWith("agent-device://help/")?e.slice(20):"";if(d(r))return a({topic:r});throw Error(`Unknown resource: ${e}`)}(x)}]};case"prompts/list":return{prompts:c()};case"prompts/get":let C;return g=l,C=m(g),function(e,t={}){let r={"agent-device-workflow":"workflow","agent-device-debugging":"debugging","agent-device-dogfood":"dogfood","agent-device-react-native-performance":"react-devtools","agent-device-macos":"macos"}[e];if(!r)throw Error(`Unknown prompt: ${e}`);let n=t.target?` Target: ${t.target}.`:"";return{description:`Use agent-device help ${r} before planning commands.${n}`,messages:[{role:"user",content:{type:"text",text:`Read the agent-device ${r} guidance through the MCP help tool, then produce a concise command plan using agent-device CLI commands only.${n}`}}]}}(h(C,"name"),(w=C.arguments,Object.fromEntries(Object.entries(f(w)).filter(e=>"string"==typeof e[1]))));default:throw new v(`Unsupported MCP method: ${e}`)}}(e.method,e.params),{jsonrpc:"2.0",id:l,result:p}}catch(t){if(t instanceof v)return g(e.id,-32601,t.message);return g(e.id,-32602,t instanceof Error?t.message:String(t))}}function u(e,t=!1){return{isError:t,content:[{type:"text",text:e}]}}function g(e,t,r){return{jsonrpc:"2.0",id:e,error:{code:t,message:r}}}function m(e){if(!e||"object"!=typeof e||Array.isArray(e))throw Error("Expected object parameters.");return e}function f(e){return void 0===e?{}:m(e)}function h(e,t){let r=e[t];if("string"!=typeof r||0===r.length)throw Error(`Expected ${t} to be a non-empty string.`);return r}class v extends Error{}async function w(){let e=new y(e=>{let t=function(e){if(Array.isArray(e)){let t=e.flatMap(e=>{var t;return(t=p(e))?[t]:[]});return t.length>0?t:null}return p(e)}(e);t&&b(t)});process.stdin.setEncoding("utf8"),process.stdin.on("data",t=>{try{e.push(t)}catch(e){b({jsonrpc:"2.0",id:null,error:{code:-32700,message:e instanceof Error?e.message:String(e)}})}}),await new Promise(e=>{process.stdin.on("end",e),process.stdin.on("close",e),process.stdin.resume()})}class y{buffer="";sink;constructor(e){this.sink=e}push(e){for(this.buffer+=e;;){let e=this.tryReadLineMessage();if(void 0!==e){this.emit(e);continue}break}}tryReadLineMessage(){let e=this.buffer.indexOf("\n");if(-1===e)return;let t=this.buffer.slice(0,e).trim();return this.buffer=this.buffer.slice(e+1),t.length>0?t:void 0}emit(e){let t=JSON.parse(e);Array.isArray(t),this.sink(t)}}function b(e){process.stdout.write(`${JSON.stringify(e)}
20
- `)}export{w as runAgentDeviceMcpServer};
1
+ import{readVersion as e}from"./9671.js";function t(t){if("2.0"!==t.jsonrpc||"string"!=typeof t.method)return r(t.id??null,-32600,"Invalid JSON-RPC request.");if(void 0===t.id)return null;try{var i,s;return i=t.id,s=function(t,r){switch(t){case"initialize":return{protocolVersion:"2025-11-25",capabilities:{tools:{}},serverInfo:{name:"agent-device",version:e()}};case"ping":return{};case"tools/list":return{tools:[{name:"status",description:"Return discovery-only handoff metadata for installing, verifying, and using the agent-device CLI.",inputSchema:{type:"object",properties:{},additionalProperties:!1},outputSchema:{type:"object",properties:{packageName:{type:"string"},installedPackageVersion:{type:"string"},cliCommandName:{type:"string"},installCommand:{type:"string"},verifyCommand:{type:"string"},startingHelpCommand:{type:"string"},supportedTargets:{type:"array",items:{type:"string"}},capabilities:{type:"array",items:{type:"string"}},prerequisites:{type:"array",items:{type:"string"}},docsUrl:{type:"string"},agentDocsUrl:{type:"string"},firstCommands:{type:"array",items:{type:"string"}},automationInterface:{type:"string",const:"cli"},automationNote:{type:"string"},installRequiresHumanApproval:{type:"boolean",const:!0},installSafetyNote:{type:"string"}},required:["packageName","installedPackageVersion","cliCommandName","installCommand","verifyCommand","startingHelpCommand","supportedTargets","capabilities","prerequisites","docsUrl","agentDocsUrl","firstCommands","automationInterface","automationNote","installRequiresHumanApproval","installSafetyNote"],additionalProperties:!1}}]};case"tools/call":var i=r;let s=function(e,t){let r=e[t];if("string"!=typeof r||0===r.length)throw Error(`Expected ${t} to be a non-empty string.`);return r}(function(e){if(!e||"object"!=typeof e||Array.isArray(e))throw Error("Expected object parameters.");return e}(i),"name");try{if("status"===s){let t;return t={packageName:"agent-device",installedPackageVersion:e(),cliCommandName:"agent-device",installCommand:"npm install -g agent-device@latest",verifyCommand:"agent-device --version",startingHelpCommand:"agent-device help workflow",supportedTargets:["ios-simulator","android-emulator","ios-device","android-device","tvos-simulator","macos","linux"],capabilities:["inspect-ui","interact-with-elements","open-apps","install-app","capture-screenshot","accessibility-snapshot","collect-logs","collect-network","collect-performance","record-replay","react-native","expo","android-adb","ios-xcuitest"],prerequisites:["node>=22","xcode-for-ios","android-sdk-adb-for-android","macos-accessibility-permission-for-desktop"],docsUrl:"https://agent-device.dev/",agentDocsUrl:"https://incubator.callstack.com/agent-device/llms-full.txt",firstCommands:["agent-device help workflow","agent-device apps --platform ios","agent-device apps --platform android"],automationInterface:"cli",automationNote:"Device automation happens through the agent-device CLI. This MCP server is discovery-only and does not expose device automation tools.",installRequiresHumanApproval:!0,installSafetyNote:"Agents should not install or update the package unless the human has approved the environment change. If the CLI is missing, ask the human to run the install command, then run the verify command."},{isError:!1,structuredContent:t,content:[{type:"text",text:JSON.stringify(t,null,2)}]}}throw Error(`Unknown tool: ${s}`)}catch(e){return function(e,t=!1){return{isError:t,content:[{type:"text",text:e}]}}(e instanceof Error?e.message:String(e),!0)}return;default:throw new n(`Unsupported MCP method: ${t}`)}}(t.method,t.params),{jsonrpc:"2.0",id:i,result:s}}catch(e){if(e instanceof n)return r(t.id,-32601,e.message);return r(t.id,-32602,e instanceof Error?e.message:String(e))}}function r(e,t,r){return{jsonrpc:"2.0",id:e,error:{code:t,message:r}}}class n extends Error{}async function i(){let e=new s(e=>{let r=function(e){if(Array.isArray(e)){let r=e.flatMap(e=>{var r;return(r=t(e))?[r]:[]});return r.length>0?r:null}return t(e)}(e);r&&a(r)});process.stdin.setEncoding("utf8"),process.stdin.on("data",t=>{try{e.push(t)}catch(e){a({jsonrpc:"2.0",id:null,error:{code:-32700,message:e instanceof Error?e.message:String(e)}})}}),await new Promise(e=>{process.stdin.on("end",e),process.stdin.on("close",e),process.stdin.resume()})}class s{buffer="";sink;constructor(e){this.sink=e}push(e){for(this.buffer+=e;;){let e=this.tryReadLineMessage();if(void 0!==e){this.emit(e);continue}break}}tryReadLineMessage(){let e=this.buffer.indexOf("\n");if(-1===e)return;let t=this.buffer.slice(0,e).trim();return this.buffer=this.buffer.slice(e+1),t.length>0?t:void 0}emit(e){let t=JSON.parse(e);Array.isArray(t),this.sink(t)}}function a(e){process.stdout.write(`${JSON.stringify(e)}
2
+ `)}export{i as runAgentDeviceMcpServer};
@@ -28,7 +28,13 @@
28
28
  selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29
29
  selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30
30
  shouldUseLaunchSchemeArgsEnv = "YES"
31
- shouldAutocreateTestPlan = "YES">
31
+ shouldAutocreateTestPlan = "NO">
32
+ <TestPlans>
33
+ <TestPlanReference
34
+ reference = "container:AgentDeviceRunnerUITests.xctestplan"
35
+ default = "YES">
36
+ </TestPlanReference>
37
+ </TestPlans>
32
38
  <Testables>
33
39
  <TestableReference
34
40
  skipped = "NO"
@@ -13,6 +13,18 @@ extension RunnerTests {
13
13
  return (gestureStartUptimeMs, currentUptimeMs())
14
14
  }
15
15
 
16
+ private func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
17
+ switch outcome {
18
+ case .performed:
19
+ return nil
20
+ case .unsupported(let message):
21
+ return Response(
22
+ ok: false,
23
+ error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message)
24
+ )
25
+ }
26
+ }
27
+
16
28
  func execute(command: Command) throws -> Response {
17
29
  if Thread.isMainThread {
18
30
  return try executeOnMainSafely(command: command)
@@ -128,11 +140,16 @@ extension RunnerTests {
128
140
  if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
129
141
  activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
130
142
  } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
131
- app.activate()
143
+ ensureRunnerHostAppActive(reason: "missing_app_bundle")
132
144
  activeApp = app
133
145
  }
134
146
 
135
- if !activeApp.waitForExistence(timeout: appExistenceTimeout) {
147
+ let skipExistenceWait = canUseFastForegroundAppGuard(
148
+ activeApp: activeApp,
149
+ requestedBundleId: requestedBundleId,
150
+ command: command.command
151
+ )
152
+ if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
136
153
  if let bundleId = requestedBundleId {
137
154
  activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
138
155
  guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
@@ -147,10 +164,15 @@ extension RunnerTests {
147
164
  if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
148
165
  activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
149
166
  } else if requestedBundleId == nil, activeApp.state != .runningForeground {
150
- app.activate()
167
+ ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
151
168
  activeApp = app
152
169
  }
153
- if !activeApp.waitForExistence(timeout: 2) {
170
+ let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
171
+ activeApp: activeApp,
172
+ requestedBundleId: requestedBundleId,
173
+ command: command.command
174
+ )
175
+ if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
154
176
  if let bundleId = requestedBundleId {
155
177
  return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
156
178
  }
@@ -229,13 +251,51 @@ extension RunnerTests {
229
251
  data: DataPayload(currentUptimeMs: currentUptimeMs())
230
252
  )
231
253
  case .tap:
254
+ if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
255
+ let match = findElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue)
256
+ if match.isAmbiguous {
257
+ return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
258
+ }
259
+ if let element = match.element {
260
+ let frame = element.frame
261
+ let touchFrame = frame.isEmpty
262
+ ? nil
263
+ : resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
264
+ var outcome = RunnerInteractionOutcome.performed
265
+ let timing = measureGesture {
266
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
267
+ outcome = activateElement(app: activeApp, element: element, action: "tap by selector")
268
+ }
269
+ }
270
+ if let response = unsupportedResponse(for: outcome) {
271
+ return response
272
+ }
273
+ return Response(
274
+ ok: true,
275
+ data: DataPayload(
276
+ message: "tapped",
277
+ gestureStartUptimeMs: timing.gestureStartUptimeMs,
278
+ gestureEndUptimeMs: timing.gestureEndUptimeMs,
279
+ x: touchFrame?.x,
280
+ y: touchFrame?.y,
281
+ referenceWidth: touchFrame?.referenceWidth,
282
+ referenceHeight: touchFrame?.referenceHeight
283
+ )
284
+ )
285
+ }
286
+ return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
287
+ }
232
288
  if let text = command.text {
233
289
  if let element = findElement(app: activeApp, text: text) {
290
+ var outcome = RunnerInteractionOutcome.performed
234
291
  let timing = measureGesture {
235
292
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
236
- element.tap()
293
+ outcome = activateElement(app: activeApp, element: element, action: "tap by text")
237
294
  }
238
295
  }
296
+ if let response = unsupportedResponse(for: outcome) {
297
+ return response
298
+ }
239
299
  return Response(
240
300
  ok: true,
241
301
  data: DataPayload(
@@ -249,11 +309,15 @@ extension RunnerTests {
249
309
  }
250
310
  if let x = command.x, let y = command.y {
251
311
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
312
+ var outcome = RunnerInteractionOutcome.performed
252
313
  let timing = measureGesture {
253
314
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
254
- tapAt(app: activeApp, x: x, y: y)
315
+ outcome = tapAt(app: activeApp, x: x, y: y)
255
316
  }
256
317
  }
318
+ if let response = unsupportedResponse(for: outcome) {
319
+ return response
320
+ }
257
321
  return Response(
258
322
  ok: true,
259
323
  data: DataPayload(
@@ -309,13 +373,19 @@ extension RunnerTests {
309
373
  let doubleTap = command.doubleTap ?? false
310
374
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
311
375
  if doubleTap {
376
+ var outcome = RunnerInteractionOutcome.performed
312
377
  let timing = measureGesture {
313
378
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
314
379
  runSeries(count: count, pauseMs: intervalMs) { _ in
315
- doubleTapAt(app: activeApp, x: x, y: y)
380
+ if case .performed = outcome {
381
+ outcome = doubleTapAt(app: activeApp, x: x, y: y)
382
+ }
316
383
  }
317
384
  }
318
385
  }
386
+ if let response = unsupportedResponse(for: outcome) {
387
+ return response
388
+ }
319
389
  return Response(
320
390
  ok: true,
321
391
  data: DataPayload(
@@ -329,13 +399,19 @@ extension RunnerTests {
329
399
  )
330
400
  )
331
401
  }
402
+ var outcome = RunnerInteractionOutcome.performed
332
403
  let timing = measureGesture {
333
404
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
334
405
  runSeries(count: count, pauseMs: intervalMs) { _ in
335
- tapAt(app: activeApp, x: x, y: y)
406
+ if case .performed = outcome {
407
+ outcome = tapAt(app: activeApp, x: x, y: y)
408
+ }
336
409
  }
337
410
  }
338
411
  }
412
+ if let response = unsupportedResponse(for: outcome) {
413
+ return response
414
+ }
339
415
  return Response(
340
416
  ok: true,
341
417
  data: DataPayload(
@@ -354,11 +430,15 @@ extension RunnerTests {
354
430
  }
355
431
  let duration = (command.durationMs ?? 800) / 1000.0
356
432
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
433
+ var outcome = RunnerInteractionOutcome.performed
357
434
  let timing = measureGesture {
358
435
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
359
- longPressAt(app: activeApp, x: x, y: y, duration: duration)
436
+ outcome = longPressAt(app: activeApp, x: x, y: y, duration: duration)
360
437
  }
361
438
  }
439
+ if let response = unsupportedResponse(for: outcome) {
440
+ return response
441
+ }
362
442
  return Response(
363
443
  ok: true,
364
444
  data: DataPayload(
@@ -376,12 +456,30 @@ extension RunnerTests {
376
456
  return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
377
457
  }
378
458
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
379
- let dragFrame = resolvedDragVisualizationFrame(app: activeApp, x: x, y: y, x2: x2, y2: y2)
459
+ let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
460
+ let dragFrame = resolvedDragVisualizationFrame(
461
+ app: activeApp,
462
+ x: dragPoints.x,
463
+ y: dragPoints.y,
464
+ x2: dragPoints.x2,
465
+ y2: dragPoints.y2
466
+ )
467
+ var outcome = RunnerInteractionOutcome.performed
380
468
  let timing = measureGesture {
381
469
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
382
- dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
470
+ outcome = dragAt(
471
+ app: activeApp,
472
+ x: dragPoints.x,
473
+ y: dragPoints.y,
474
+ x2: dragPoints.x2,
475
+ y2: dragPoints.y2,
476
+ holdDuration: holdDuration
477
+ )
383
478
  }
384
479
  }
480
+ if let response = unsupportedResponse(for: outcome) {
481
+ return response
482
+ }
385
483
  return Response(
386
484
  ok: true,
387
485
  data: DataPayload(
@@ -407,18 +505,40 @@ extension RunnerTests {
407
505
  return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
408
506
  }
409
507
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
508
+ let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
509
+ var outcome = RunnerInteractionOutcome.performed
410
510
  let timing = measureGesture {
411
511
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
412
512
  runSeries(count: count, pauseMs: pauseMs) { idx in
513
+ guard case .performed = outcome else {
514
+ return
515
+ }
413
516
  let reverse = pattern == "ping-pong" && (idx % 2 == 1)
414
517
  if reverse {
415
- dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
518
+ outcome = dragAt(
519
+ app: activeApp,
520
+ x: dragPoints.x2,
521
+ y: dragPoints.y2,
522
+ x2: dragPoints.x,
523
+ y2: dragPoints.y,
524
+ holdDuration: holdDuration
525
+ )
416
526
  } else {
417
- dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
527
+ outcome = dragAt(
528
+ app: activeApp,
529
+ x: dragPoints.x,
530
+ y: dragPoints.y,
531
+ x2: dragPoints.x2,
532
+ y2: dragPoints.y2,
533
+ holdDuration: holdDuration
534
+ )
418
535
  }
419
536
  }
420
537
  }
421
538
  }
539
+ if let response = unsupportedResponse(for: outcome) {
540
+ return response
541
+ }
422
542
  return Response(
423
543
  ok: true,
424
544
  data: DataPayload(
@@ -427,46 +547,24 @@ extension RunnerTests {
427
547
  gestureEndUptimeMs: timing.gestureEndUptimeMs
428
548
  )
429
549
  )
430
- case .type:
431
- guard let text = command.text else {
432
- return Response(ok: false, error: ErrorPayload(message: "type requires text"))
550
+ case .remotePress:
551
+ guard let button = tvRemoteButton(from: command.remoteButton) else {
552
+ return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
433
553
  }
434
- let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
435
- let target: XCUIElement?
436
- if let x = command.x, let y = command.y {
437
- target = textInputAt(app: activeApp, x: x, y: y) ?? focusedTextInput(app: activeApp)
438
- } else {
439
- target = focusedTextInput(app: activeApp)
440
- }
441
- func typeIntoTarget(_ value: String) {
442
- if let focused = target {
443
- focused.typeText(value)
444
- } else {
445
- activeApp.typeText(value)
446
- }
554
+ let duration = (command.durationMs ?? 0) / 1000.0
555
+ guard pressTvRemote(button, duration: duration) else {
556
+ return Response(
557
+ ok: false,
558
+ error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: "remotePress is only supported on tvOS")
559
+ )
447
560
  }
448
- if command.clearFirst == true {
449
- guard let focused = target else {
450
- let message =
451
- (command.x != nil && command.y != nil)
452
- ? "no text input found at the provided coordinates to clear"
453
- : "no focused text input to clear"
454
- return Response(ok: false, error: ErrorPayload(message: message))
455
- }
456
- clearTextInput(focused)
457
- }
458
- if delaySeconds > 0 && text.count > 1 {
459
- let chunks = Array(text)
460
- for (index, character) in chunks.enumerated() {
461
- typeIntoTarget(String(character))
462
- if index + 1 < chunks.count {
463
- Thread.sleep(forTimeInterval: delaySeconds)
464
- }
465
- }
466
- } else {
467
- typeIntoTarget(text)
561
+ return Response(ok: true, data: DataPayload(message: "remote pressed"))
562
+ case .type:
563
+ var response: Response?
564
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
565
+ response = executeTypeCommand(activeApp: activeApp, command: command)
468
566
  }
469
- return Response(ok: true, data: DataPayload(message: "typed"))
567
+ return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
470
568
  case .interactionFrame:
471
569
  let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
472
570
  return Response(
@@ -514,6 +612,11 @@ extension RunnerTests {
514
612
  }
515
613
  let found = findElement(app: activeApp, text: text) != nil
516
614
  return Response(ok: true, data: DataPayload(found: found))
615
+ case .querySelector:
616
+ guard let selectorKey = command.selectorKey, let selectorValue = command.selectorValue else {
617
+ return Response(ok: false, error: ErrorPayload(message: "querySelector requires selectorKey and selectorValue"))
618
+ }
619
+ return queryElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue)
517
620
  case .readText:
518
621
  guard let x = command.x, let y = command.y else {
519
622
  return Response(ok: false, error: ErrorPayload(message: "readText requires x and y"))
@@ -545,7 +648,7 @@ extension RunnerTests {
545
648
  targetApp.activate()
546
649
  activeApp = targetApp
547
650
  // Brief wait for the app transition animation to complete
548
- Thread.sleep(forTimeInterval: 0.5)
651
+ sleepFor(0.5)
549
652
  }
550
653
  if command.fullscreen == true {
551
654
  screenshot = XCUIScreen.main.screenshot()
@@ -633,13 +736,23 @@ extension RunnerTests {
633
736
  return Response(ok: false, error: ErrorPayload(message: "alert not found"))
634
737
  }
635
738
  if action == "accept" {
636
- let button = alert.buttons.allElementsBoundByIndex.first
637
- button?.tap()
739
+ guard let button = alert.buttons.allElementsBoundByIndex.first else {
740
+ return Response(ok: false, error: ErrorPayload(message: "alert accept button not found"))
741
+ }
742
+ let outcome = activateElement(app: activeApp, element: button, action: "alert accept")
743
+ if let response = unsupportedResponse(for: outcome) {
744
+ return response
745
+ }
638
746
  return Response(ok: true, data: DataPayload(message: "accepted"))
639
747
  }
640
748
  if action == "dismiss" {
641
- let button = alert.buttons.allElementsBoundByIndex.last
642
- button?.tap()
749
+ guard let button = alert.buttons.allElementsBoundByIndex.last else {
750
+ return Response(ok: false, error: ErrorPayload(message: "alert dismiss button not found"))
751
+ }
752
+ let outcome = activateElement(app: activeApp, element: button, action: "alert dismiss")
753
+ if let response = unsupportedResponse(for: outcome) {
754
+ return response
755
+ }
643
756
  return Response(ok: true, data: DataPayload(message: "dismissed"))
644
757
  }
645
758
  let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
@@ -648,8 +761,12 @@ extension RunnerTests {
648
761
  guard let scale = command.scale, scale > 0 else {
649
762
  return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
650
763
  }
764
+ var outcome = RunnerInteractionOutcome.performed
651
765
  let timing = measureGesture {
652
- pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
766
+ outcome = pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
767
+ }
768
+ if let response = unsupportedResponse(for: outcome) {
769
+ return response
653
770
  }
654
771
  return Response(
655
772
  ok: true,
@@ -661,4 +778,41 @@ extension RunnerTests {
661
778
  )
662
779
  }
663
780
  }
781
+
782
+ private func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
783
+ guard let text = command.text else {
784
+ return Response(ok: false, error: ErrorPayload(message: "type requires text"))
785
+ }
786
+ let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
787
+ let textEntryMode = resolveTextEntryMode(command)
788
+ let target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
789
+ if textEntryMode == .replacement {
790
+ guard target.element != nil else {
791
+ let message =
792
+ (command.x != nil && command.y != nil)
793
+ ? "no text input found at the provided coordinates to clear"
794
+ : "no focused text input to clear"
795
+ return Response(ok: false, error: ErrorPayload(message: message))
796
+ }
797
+ }
798
+ let textResult = typeTextReliably(
799
+ app: activeApp,
800
+ target: target,
801
+ text: text,
802
+ delaySeconds: delaySeconds,
803
+ repairMode: textEntryMode
804
+ )
805
+ if textResult.verified == false {
806
+ let expected = textResult.expectedText ?? ""
807
+ let observed = textResult.observedText ?? ""
808
+ return Response(
809
+ ok: false,
810
+ error: ErrorPayload(
811
+ code: "TEXT_ENTRY_MISMATCH",
812
+ message: "text entry verification failed: expected \"\(expected)\", observed \"\(observed)\""
813
+ )
814
+ )
815
+ }
816
+ return Response(ok: true, data: DataPayload(message: textResult.repaired ? "typed after repair" : "typed"))
817
+ }
664
818
  }