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.
- package/README.md +7 -4
- 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
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.1.apk.sha256 +1 -0
- 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
- package/dist/src/1393.js +1 -0
- package/dist/src/1769.js +7 -0
- package/dist/src/1974.js +2 -2
- package/dist/src/208.js +1 -1
- package/dist/src/2151.js +434 -0
- package/dist/src/221.js +4 -4
- package/dist/src/2842.js +1 -0
- package/dist/src/3572.js +1 -0
- package/dist/src/4057.js +1 -1
- package/dist/src/4829.js +1 -1
- package/dist/src/9542.js +2 -2
- package/dist/src/9639.js +2 -2
- package/dist/src/989.js +1 -1
- package/dist/src/android-adb.d.ts +38 -9
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android-snapshot-helper.d.ts +23 -0
- package/dist/src/cli.js +60 -57
- package/dist/src/contracts.d.ts +1 -0
- package/dist/src/finders.d.ts +1 -0
- package/dist/src/index.d.ts +56 -27
- package/dist/src/internal/companion-tunnel.js +1 -1
- package/dist/src/internal/daemon.js +51 -23
- package/dist/src/remote-config.d.ts +17 -14
- package/dist/src/selectors.d.ts +2 -0
- package/dist/src/server.js +2 -20
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +7 -1
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +155 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +131 -72
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +734 -10
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +89 -16
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +5 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +9 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +4 -3
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +26 -0
- package/ios-runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift +7 -1
- package/package.json +30 -11
- package/server.json +3 -3
- package/skills/agent-device/SKILL.md +2 -7
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.9.apk.sha256 +0 -1
- package/dist/src/180.js +0 -1
- package/dist/src/6108.js +0 -26
- package/dist/src/6642.js +0 -1
- package/dist/src/7462.js +0 -1
- package/dist/src/8809.js +0 -8
- package/dist/src/command-schema.js +0 -382
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
|
|
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 = {
|
package/dist/src/selectors.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/server.js
CHANGED
|
@@ -1,20 +1,2 @@
|
|
|
1
|
-
import{
|
|
2
|
-
|
|
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 = "
|
|
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
|
+
}
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift
CHANGED
|
@@ -13,7 +13,7 @@ extension RunnerTests {
|
|
|
13
13
|
return (gestureStartUptimeMs, currentUptimeMs())
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
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
|
-
|
|
143
|
+
ensureRunnerHostAppActive(reason: "missing_app_bundle")
|
|
144
144
|
activeApp = app
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
|
|
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
|
-
|
|
167
|
+
ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
|
|
163
168
|
activeApp = app
|
|
164
169
|
}
|
|
165
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
491
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|