agent-device 0.14.9 → 0.15.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.
Files changed (50) hide show
  1. package/README.md +7 -4
  2. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.9.apk → agent-device-android-snapshot-helper-0.15.1.apk} +0 -0
  3. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.1.apk.sha256 +1 -0
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.9.manifest.json → agent-device-android-snapshot-helper-0.15.1.manifest.json} +6 -6
  5. package/dist/src/1393.js +1 -0
  6. package/dist/src/1769.js +7 -0
  7. package/dist/src/1974.js +2 -2
  8. package/dist/src/208.js +1 -1
  9. package/dist/src/2151.js +434 -0
  10. package/dist/src/221.js +4 -4
  11. package/dist/src/2842.js +1 -0
  12. package/dist/src/3572.js +1 -0
  13. package/dist/src/4057.js +1 -1
  14. package/dist/src/4829.js +1 -1
  15. package/dist/src/9542.js +2 -2
  16. package/dist/src/9639.js +2 -2
  17. package/dist/src/989.js +1 -1
  18. package/dist/src/android-adb.d.ts +38 -9
  19. package/dist/src/android-adb.js +1 -1
  20. package/dist/src/android-snapshot-helper.d.ts +23 -0
  21. package/dist/src/cli.js +60 -57
  22. package/dist/src/contracts.d.ts +1 -0
  23. package/dist/src/finders.d.ts +1 -0
  24. package/dist/src/index.d.ts +56 -27
  25. package/dist/src/internal/companion-tunnel.js +1 -1
  26. package/dist/src/internal/daemon.js +51 -23
  27. package/dist/src/remote-config.d.ts +17 -14
  28. package/dist/src/selectors.d.ts +2 -0
  29. package/dist/src/server.js +2 -20
  30. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +7 -1
  31. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +155 -0
  32. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +131 -72
  33. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +734 -10
  34. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +89 -16
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +5 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +9 -0
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +4 -3
  38. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -2
  39. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +26 -0
  40. package/ios-runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +7 -1
  41. package/package.json +30 -11
  42. package/server.json +3 -3
  43. package/skills/agent-device/SKILL.md +2 -7
  44. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.9.apk.sha256 +0 -1
  45. package/dist/src/180.js +0 -1
  46. package/dist/src/6108.js +0 -26
  47. package/dist/src/6642.js +0 -1
  48. package/dist/src/7462.js +0 -1
  49. package/dist/src/8809.js +0 -8
  50. package/dist/src/command-schema.js +0 -382
@@ -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 = {
@@ -46,6 +46,7 @@ declare type RawSnapshotNode = {
46
46
  surface?: string;
47
47
  hiddenContentAbove?: boolean;
48
48
  hiddenContentBelow?: boolean;
49
+ presentationHints?: string[];
49
50
  };
50
51
 
51
52
  declare type Rect = {
@@ -104,6 +105,7 @@ declare type SnapshotState = {
104
105
  truncated?: boolean;
105
106
  backend?: SnapshotBackend;
106
107
  comparisonSafe?: boolean;
108
+ presentationKey?: string;
107
109
  };
108
110
 
109
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"
@@ -0,0 +1,155 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ struct RunnerAlert {
5
+ let root: XCUIElement
6
+ let ownerApp: XCUIApplication
7
+ let buttons: [XCUIElement]
8
+ }
9
+
10
+ func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
11
+ if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
12
+ return runnerAlert(root: alert, ownerApp: activeApp)
13
+ }
14
+ if let popup = firstDismissPopupWindow(in: activeApp) {
15
+ return runnerAlert(root: popup, ownerApp: activeApp)
16
+ }
17
+ #if os(macOS)
18
+ return nil
19
+ #else
20
+ if let systemModal = firstBlockingSystemModal(in: springboard) {
21
+ return runnerAlert(root: systemModal, ownerApp: springboard)
22
+ }
23
+ return nil
24
+ #endif
25
+ }
26
+
27
+ func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
28
+ if action == "accept" || action == "dismiss" {
29
+ guard let button = chooseAlertButton(alert.buttons, action: action) else {
30
+ return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
31
+ }
32
+ let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
33
+ if let response = unsupportedResponse(for: outcome) {
34
+ return response
35
+ }
36
+ return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
37
+ }
38
+
39
+ return Response(
40
+ ok: true,
41
+ data: DataPayload(
42
+ message: preferredAlertTitle(alert.root, buttons: alert.buttons),
43
+ items: alert.buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
44
+ )
45
+ )
46
+ }
47
+
48
+ private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? {
49
+ let buttons = actionableElements(in: root).filter { isEnabledElement($0) }
50
+ guard !buttons.isEmpty else {
51
+ return nil
52
+ }
53
+ return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
54
+ }
55
+
56
+ private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
57
+ elements.first { isVisibleElement($0) }
58
+ }
59
+
60
+ private func firstDismissPopupWindow(in app: XCUIApplication) -> XCUIElement? {
61
+ safeElementsQuery {
62
+ app.windows.allElementsBoundByIndex
63
+ }.first { window in
64
+ if !isVisibleElement(window) { return false }
65
+ if isDismissPopupMarker(window.label) || isDismissPopupMarker(window.identifier) {
66
+ return true
67
+ }
68
+ return safeElementsQuery {
69
+ window.descendants(matching: .any).allElementsBoundByIndex
70
+ }.contains { descendant in
71
+ isDismissPopupMarker(descendant.label) || isDismissPopupMarker(descendant.identifier)
72
+ }
73
+ }
74
+ }
75
+
76
+ private func chooseAlertButton(_ buttons: [XCUIElement], action: String) -> XCUIElement? {
77
+ if action == "accept" {
78
+ if let accept = buttons.first(where: { isAcceptButton($0.label) }) {
79
+ return accept
80
+ }
81
+ return buttons.count == 1 && !isDismissButton(buttons[0].label) ? buttons[0] : nil
82
+ }
83
+
84
+ return buttons.first(where: { isDismissButton($0.label) }) ?? buttons.last
85
+ }
86
+
87
+ private func isAcceptButton(_ label: String) -> Bool {
88
+ let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
89
+ return [
90
+ "ok",
91
+ "allow",
92
+ "yes",
93
+ "continue",
94
+ "done",
95
+ "open settings"
96
+ ].contains(normalized) || normalized.hasPrefix("confirm")
97
+ }
98
+
99
+ private func isDismissButton(_ label: String) -> Bool {
100
+ [
101
+ "cancel",
102
+ "close",
103
+ "dismiss",
104
+ "don't allow",
105
+ "don’t allow",
106
+ "not now",
107
+ "no",
108
+ "keep browsing",
109
+ "later"
110
+ ].contains(label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
111
+ }
112
+
113
+ private func preferredAlertTitle(_ element: XCUIElement, buttons: [XCUIElement]) -> String {
114
+ let buttonLabels = Set(buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() })
115
+ let descendants = element.descendants(matching: .any).allElementsBoundByIndex
116
+ for descendant in descendants {
117
+ let text = descendant.label.trimmingCharacters(in: .whitespacesAndNewlines)
118
+ if text.isEmpty ||
119
+ isGenericAlertLabel(text) ||
120
+ buttonLabels.contains(text.lowercased()) ||
121
+ descendant.elementType == .navigationBar ||
122
+ actionableTypes.contains(descendant.elementType)
123
+ {
124
+ continue
125
+ }
126
+ return text
127
+ }
128
+ let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
129
+ return label.isEmpty || isGenericAlertLabel(label) ? "Alert" : label
130
+ }
131
+
132
+ private func isGenericAlertLabel(_ label: String) -> Bool {
133
+ let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
134
+ return isDismissPopupMarker(normalized) ||
135
+ normalized.hasPrefix("vertical scroll bar") ||
136
+ normalized.hasPrefix("horizontal scroll bar") ||
137
+ normalized == "tab bar"
138
+ }
139
+
140
+ private func isVisibleElement(_ element: XCUIElement) -> Bool {
141
+ element.exists && !element.frame.isNull && !element.frame.isEmpty
142
+ }
143
+
144
+ private func isEnabledElement(_ element: XCUIElement) -> Bool {
145
+ var enabled = false
146
+ _ = RunnerObjCExceptionCatcher.catchException({
147
+ enabled = element.exists && element.isEnabled
148
+ })
149
+ return enabled
150
+ }
151
+
152
+ private func isDismissPopupMarker(_ label: String) -> Bool {
153
+ label.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
154
+ }
155
+ }
@@ -13,7 +13,7 @@ extension RunnerTests {
13
13
  return (gestureStartUptimeMs, currentUptimeMs())
14
14
  }
15
15
 
16
- private func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
16
+ func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
17
17
  switch outcome {
18
18
  case .performed:
19
19
  return nil
@@ -140,11 +140,16 @@ extension RunnerTests {
140
140
  if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
141
141
  activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
142
142
  } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
143
- app.activate()
143
+ ensureRunnerHostAppActive(reason: "missing_app_bundle")
144
144
  activeApp = app
145
145
  }
146
146
 
147
- 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) {
148
153
  if let bundleId = requestedBundleId {
149
154
  activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
150
155
  guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
@@ -159,10 +164,15 @@ extension RunnerTests {
159
164
  if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
160
165
  activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
161
166
  } else if requestedBundleId == nil, activeApp.state != .runningForeground {
162
- app.activate()
167
+ ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
163
168
  activeApp = app
164
169
  }
165
- 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) {
166
176
  if let bundleId = requestedBundleId {
167
177
  return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
168
178
  }
@@ -241,6 +251,40 @@ extension RunnerTests {
241
251
  data: DataPayload(currentUptimeMs: currentUptimeMs())
242
252
  )
243
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
+ }
244
288
  if let text = command.text {
245
289
  if let element = findElement(app: activeApp, text: text) {
246
290
  var outcome = RunnerInteractionOutcome.performed
@@ -412,11 +456,25 @@ extension RunnerTests {
412
456
  return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
413
457
  }
414
458
  let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
415
- 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
+ )
416
467
  var outcome = RunnerInteractionOutcome.performed
417
468
  let timing = measureGesture {
418
469
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
419
- outcome = 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
+ )
420
478
  }
421
479
  }
422
480
  if let response = unsupportedResponse(for: outcome) {
@@ -447,6 +505,7 @@ extension RunnerTests {
447
505
  return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
448
506
  }
449
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)
450
509
  var outcome = RunnerInteractionOutcome.performed
451
510
  let timing = measureGesture {
452
511
  withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
@@ -456,9 +515,23 @@ extension RunnerTests {
456
515
  }
457
516
  let reverse = pattern == "ping-pong" && (idx % 2 == 1)
458
517
  if reverse {
459
- outcome = 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
+ )
460
526
  } else {
461
- outcome = 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
+ )
462
535
  }
463
536
  }
464
537
  }
@@ -487,45 +560,11 @@ extension RunnerTests {
487
560
  }
488
561
  return Response(ok: true, data: DataPayload(message: "remote pressed"))
489
562
  case .type:
490
- guard let text = command.text else {
491
- return Response(ok: false, error: ErrorPayload(message: "type requires text"))
492
- }
493
- let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
494
- let target: XCUIElement?
495
- if let x = command.x, let y = command.y {
496
- target = textInputAt(app: activeApp, x: x, y: y) ?? focusedTextInput(app: activeApp)
497
- } else {
498
- target = focusedTextInput(app: activeApp)
499
- }
500
- func typeIntoTarget(_ value: String) {
501
- if let focused = target {
502
- focused.typeText(value)
503
- } else {
504
- activeApp.typeText(value)
505
- }
506
- }
507
- if command.clearFirst == true {
508
- guard let focused = target else {
509
- let message =
510
- (command.x != nil && command.y != nil)
511
- ? "no text input found at the provided coordinates to clear"
512
- : "no focused text input to clear"
513
- return Response(ok: false, error: ErrorPayload(message: message))
514
- }
515
- clearTextInput(focused)
516
- }
517
- if delaySeconds > 0 && text.count > 1 {
518
- let chunks = Array(text)
519
- for (index, character) in chunks.enumerated() {
520
- typeIntoTarget(String(character))
521
- if index + 1 < chunks.count {
522
- Thread.sleep(forTimeInterval: delaySeconds)
523
- }
524
- }
525
- } else {
526
- typeIntoTarget(text)
563
+ var response: Response?
564
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
565
+ response = executeTypeCommand(activeApp: activeApp, command: command)
527
566
  }
528
- return Response(ok: true, data: DataPayload(message: "typed"))
567
+ return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
529
568
  case .interactionFrame:
530
569
  let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
531
570
  return Response(
@@ -573,6 +612,11 @@ extension RunnerTests {
573
612
  }
574
613
  let found = findElement(app: activeApp, text: text) != nil
575
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)
576
620
  case .readText:
577
621
  guard let x = command.x, let y = command.y else {
578
622
  return Response(ok: false, error: ErrorPayload(message: "readText requires x and y"))
@@ -604,7 +648,7 @@ extension RunnerTests {
604
648
  targetApp.activate()
605
649
  activeApp = targetApp
606
650
  // Brief wait for the app transition animation to complete
607
- Thread.sleep(forTimeInterval: 0.5)
651
+ sleepFor(0.5)
608
652
  }
609
653
  if command.fullscreen == true {
610
654
  screenshot = XCUIScreen.main.screenshot()
@@ -687,32 +731,10 @@ extension RunnerTests {
687
731
  )
688
732
  case .alert:
689
733
  let action = (command.action ?? "get").lowercased()
690
- let alert = activeApp.alerts.firstMatch
691
- if !alert.exists {
734
+ guard let alert = resolveAlert(app: activeApp) else {
692
735
  return Response(ok: false, error: ErrorPayload(message: "alert not found"))
693
736
  }
694
- if action == "accept" {
695
- guard let button = alert.buttons.allElementsBoundByIndex.first else {
696
- return Response(ok: false, error: ErrorPayload(message: "alert accept button not found"))
697
- }
698
- let outcome = activateElement(app: activeApp, element: button, action: "alert accept")
699
- if let response = unsupportedResponse(for: outcome) {
700
- return response
701
- }
702
- return Response(ok: true, data: DataPayload(message: "accepted"))
703
- }
704
- if action == "dismiss" {
705
- guard let button = alert.buttons.allElementsBoundByIndex.last else {
706
- return Response(ok: false, error: ErrorPayload(message: "alert dismiss button not found"))
707
- }
708
- let outcome = activateElement(app: activeApp, element: button, action: "alert dismiss")
709
- if let response = unsupportedResponse(for: outcome) {
710
- return response
711
- }
712
- return Response(ok: true, data: DataPayload(message: "dismissed"))
713
- }
714
- let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
715
- return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
737
+ return handleAlert(alert, action: action)
716
738
  case .pinch:
717
739
  guard let scale = command.scale, scale > 0 else {
718
740
  return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
@@ -734,4 +756,41 @@ extension RunnerTests {
734
756
  )
735
757
  }
736
758
  }
759
+
760
+ private func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
761
+ guard let text = command.text else {
762
+ return Response(ok: false, error: ErrorPayload(message: "type requires text"))
763
+ }
764
+ let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
765
+ let textEntryMode = resolveTextEntryMode(command)
766
+ let target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
767
+ if textEntryMode == .replacement {
768
+ guard target.element != nil else {
769
+ let message =
770
+ (command.x != nil && command.y != nil)
771
+ ? "no text input found at the provided coordinates to clear"
772
+ : "no focused text input to clear"
773
+ return Response(ok: false, error: ErrorPayload(message: message))
774
+ }
775
+ }
776
+ let textResult = typeTextReliably(
777
+ app: activeApp,
778
+ target: target,
779
+ text: text,
780
+ delaySeconds: delaySeconds,
781
+ repairMode: textEntryMode
782
+ )
783
+ if textResult.verified == false {
784
+ let expected = textResult.expectedText ?? ""
785
+ let observed = textResult.observedText ?? ""
786
+ return Response(
787
+ ok: false,
788
+ error: ErrorPayload(
789
+ code: "TEXT_ENTRY_MISMATCH",
790
+ message: "text entry verification failed: expected \"\(expected)\", observed \"\(observed)\""
791
+ )
792
+ )
793
+ }
794
+ return Response(ok: true, data: DataPayload(message: textResult.repaired ? "typed after repair" : "typed"))
795
+ }
737
796
  }