@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2
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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
package/bin/ios-provider.js
CHANGED
|
@@ -1,26 +1,86 @@
|
|
|
1
1
|
const { execFile, spawn } = require('child_process');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const http = require('http');
|
|
4
3
|
const os = require('os');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const { URL } = require('url');
|
|
7
|
-
const {
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
8
|
+
const { defaultArtifactPath, pruneGeneratedArtifacts } = require('./artifact-paths');
|
|
9
|
+
const { execFileBounded, httpRequestBounded } = require('./shared-kernel/execution-io');
|
|
10
|
+
const { runExecution, checkExecution, currentExecution, executionSleep, markExecutionDispatched } = require('./shared-kernel/execution-scope');
|
|
11
|
+
const { getProcessDeviceMutationLease, runDeviceEffect } = require('./shared-kernel/device-mutation-lease');
|
|
12
|
+
const { iosDeviceKey, executeIOSAction, lookupCompletion, reconcileIOS } = require('./ios-execution');
|
|
13
|
+
const { isMutationCommand, executionTimeoutMs } = require('./command-registry');
|
|
14
|
+
const { normalizeExecutionTarget } = require('./shared-kernel/execution-target');
|
|
15
|
+
const { descriptorBinding, bindingHeaders, assertRuntimeResponse, bindingFailure } = require('./ios-runtime-binding');
|
|
16
|
+
const { openWdaPort, target: wdaTarget } = require('./ios-wda-port');
|
|
17
|
+
const { prepareWdaProject, wdaBuildEnvironment } = require('./ios-wda-project');
|
|
18
|
+
const { executeWDAAction, reconcileWDA, completionPort } = require('./ios-wda-execution');
|
|
19
|
+
const { deviceCommandRejection } = require('./ios-device-outcome');
|
|
20
|
+
const { bindFlutterAction } = require('./shared-kernel/flutter-target');
|
|
21
|
+
const nativeTarget = require('./shared-kernel/ios-native-target');
|
|
22
|
+
const h5Target = require('./shared-kernel/ios-h5-target');
|
|
23
|
+
const h5Controls = new Map([['ios-h5-click','click'], ['ios-h5-input','input'], ['ios-h5-scroll','scroll']]);
|
|
24
|
+
|
|
25
|
+
const nativeControls = new Map([['ios-tap-native', 'native-tap'], ['ios-input-native-text', 'native-input']]);
|
|
26
|
+
|
|
27
|
+
const flutterControls = new Map([
|
|
28
|
+
['ios-tap-flutter', 'tapTarget'], ['ios-input-flutter-text', 'inputText'],
|
|
29
|
+
['ios-scroll-flutter', 'scrollBy'], ['ios-flutter-back', 'back'],
|
|
30
|
+
['ios-flutter-hide-keyboard', 'hideKeyboard'],
|
|
31
|
+
]);
|
|
8
32
|
|
|
9
|
-
const defaultRuntimePort = 18080;
|
|
10
|
-
const runtimePortSearchCount = 50;
|
|
11
33
|
const defaultHttpTimeoutMs = 5000;
|
|
12
|
-
const defaultProbeTimeoutMs = 700;
|
|
13
34
|
const defaultDeviceTimeoutSec = 30;
|
|
14
|
-
const
|
|
35
|
+
const defaultWdaTestBundleId = 'io.github.mobileaidev.aiappbridge.wda';
|
|
15
36
|
|
|
16
37
|
class IOSBridgeProvider {
|
|
17
38
|
constructor(options = {}) {
|
|
18
39
|
this.execFile = options.execFile || execFile;
|
|
19
40
|
this.httpRequest = options.httpRequest || requestJson;
|
|
41
|
+
this.lease = options.lease;
|
|
20
42
|
}
|
|
21
43
|
|
|
22
44
|
async run(command, args = {}) {
|
|
23
45
|
try {
|
|
46
|
+
return await runExecution({ timeoutMs: executionTimeoutMs(command, args) ?? 30000,
|
|
47
|
+
mutation: isMutationCommand(command, args) }, async () => {
|
|
48
|
+
if (!isMutationCommand(command, args)) return this.dispatch(command, args);
|
|
49
|
+
const device = await this.requireDevice(args);
|
|
50
|
+
const key = iosDeviceKey(device);
|
|
51
|
+
const lease = this.lease || getProcessDeviceMutationLease();
|
|
52
|
+
return lease.run(key, async () => {
|
|
53
|
+
if (command === 'ios-h5-eval' || h5Controls.has(command) || command === 'ios-flutter-action' || flutterControls.has(command) || nativeControls.has(command)
|
|
54
|
+
|| ['ios-wda-session', 'ios-tap', 'ios-input', 'ios-swipe', 'ios-set-orientation'].includes(command)) return this.dispatch(command, args, { device });
|
|
55
|
+
return runDeviceEffect({ kind: 'ios-command', command, target: { deviceId: device.udid, bundleId: args.bundleId ?? null } }, async () => {
|
|
56
|
+
let result;
|
|
57
|
+
try { result = await this.dispatch(command, args, { device }); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (!error.deviceOutcome) throw error;
|
|
60
|
+
result = { ...error.deviceOutcome, command };
|
|
61
|
+
}
|
|
62
|
+
return currentExecution()?.dispatched ? result : { ...result, dispatched: false, ambiguous: false };
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: iosErrorCode(command, error),
|
|
70
|
+
command,
|
|
71
|
+
message: firstErrorLine(error.message || String(error)),
|
|
72
|
+
details: truncateText(error.message || String(error), 4000),
|
|
73
|
+
...(error.field ? { field: error.field } : {}),
|
|
74
|
+
dispatched: typeof error.dispatched === 'boolean' ? error.dispatched : null,
|
|
75
|
+
ambiguous: error.ambiguous !== false,
|
|
76
|
+
...(error.devicectlReply ? { deviceOutcome: error.devicectlReply } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async dispatch(command, args, context = {}) {
|
|
82
|
+
if (flutterControls.has(command)) return this.flutterControl(command, args, context);
|
|
83
|
+
if (nativeControls.has(command)) return this.nativeControl(command, args, context);
|
|
24
84
|
switch (command) {
|
|
25
85
|
case 'ios-devices':
|
|
26
86
|
return await this.devices(args);
|
|
@@ -45,19 +105,25 @@ class IOSBridgeProvider {
|
|
|
45
105
|
case 'ios-events':
|
|
46
106
|
return await this.runtimeGet(args, withQuery('/v1/events', captureQuery(args)));
|
|
47
107
|
case 'ios-h5-dom':
|
|
48
|
-
return await this.runtimeGet(args, '/v1/h5/dom');
|
|
108
|
+
return await this.runtimeGet(args, withQuery('/v1/h5/dom', args.webViewId === undefined ? {} : { webViewId: args.webViewId }));
|
|
49
109
|
case 'ios-h5-eval':
|
|
50
|
-
return await this.runtimePost(args, '/v1/h5/
|
|
110
|
+
return await this.runtimePost(args, '/v1/h5/action', { payload: { action: 'eval', pageRef: args.expectedPage, script: requiredString(args.script, 'script') } }, context);
|
|
111
|
+
case 'ios-h5-click': case 'ios-h5-input': case 'ios-h5-scroll':
|
|
112
|
+
return await this.h5Control(command, args, context);
|
|
51
113
|
case 'ios-flutter-tree':
|
|
52
114
|
return await this.flutterTree(args);
|
|
53
115
|
case 'ios-flutter-nodes':
|
|
54
116
|
return await this.flutterNodes(args);
|
|
55
117
|
case 'ios-flutter-action':
|
|
56
|
-
return await this.runtimePost(args, '/v1/flutter/action', parsePayload(args));
|
|
118
|
+
return await this.runtimePost(args, '/v1/flutter/action', parsePayload(args), context);
|
|
119
|
+
case 'ios-execution':
|
|
120
|
+
return await this.executionControl(args);
|
|
57
121
|
case 'ios-screenshot':
|
|
58
122
|
return await this.screenshot(args);
|
|
59
123
|
case 'ios-wda-status':
|
|
60
124
|
return await this.wdaStatus(args);
|
|
125
|
+
case 'ios-wda-session':
|
|
126
|
+
return await this.wdaSession(args, context);
|
|
61
127
|
case 'ios-uia-tree':
|
|
62
128
|
return await this.wdaSource(args);
|
|
63
129
|
case 'ios-tap':
|
|
@@ -66,18 +132,11 @@ class IOSBridgeProvider {
|
|
|
66
132
|
return await this.wdaInput(args);
|
|
67
133
|
case 'ios-swipe':
|
|
68
134
|
return await this.wdaSwipe(args);
|
|
135
|
+
case 'ios-set-orientation':
|
|
136
|
+
return await this.wdaSetOrientation(args, context);
|
|
69
137
|
default:
|
|
70
138
|
return { ok: false, error: 'unknown_ios_command', command };
|
|
71
139
|
}
|
|
72
|
-
} catch (error) {
|
|
73
|
-
return {
|
|
74
|
-
ok: false,
|
|
75
|
-
error: iosErrorCode(command, error),
|
|
76
|
-
command,
|
|
77
|
-
message: firstErrorLine(error.message || String(error)),
|
|
78
|
-
details: truncateText(error.message || String(error), 4000),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
140
|
}
|
|
82
141
|
|
|
83
142
|
context(args = {}) {
|
|
@@ -85,7 +144,7 @@ class IOSBridgeProvider {
|
|
|
85
144
|
devicectl: stringArg(args.devicectl, process.env.AI_APP_BRIDGE_DEVICECTL || 'xcrun'),
|
|
86
145
|
xcodebuild: stringArg(args.xcodebuild, process.env.XCODEBUILD || 'xcodebuild'),
|
|
87
146
|
deviceTimeoutSec: numberArg(args.deviceTimeoutSec, defaultDeviceTimeoutSec),
|
|
88
|
-
httpTimeoutMs: numberArg(args.
|
|
147
|
+
httpTimeoutMs: numberArg(args.timeoutMs, defaultHttpTimeoutMs),
|
|
89
148
|
};
|
|
90
149
|
}
|
|
91
150
|
|
|
@@ -136,15 +195,16 @@ class IOSBridgeProvider {
|
|
|
136
195
|
}
|
|
137
196
|
checks.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
|
|
138
197
|
|
|
139
|
-
let wda = { ok: false, error: '
|
|
140
|
-
if (args.
|
|
198
|
+
let wda = { ok: false, error: 'ios_wda_target_required' };
|
|
199
|
+
if (args.wdaRunnerBundleId) {
|
|
141
200
|
wda = await this.wdaStatus(args);
|
|
142
201
|
}
|
|
143
202
|
checks.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
|
|
144
203
|
|
|
145
204
|
const developerModeReady = selected ? selected.developerModeStatus === 'enabled' : false;
|
|
146
|
-
const ddiReady = selected
|
|
147
|
-
const
|
|
205
|
+
const ddiReady = selected?.ddiServicesAvailable === true;
|
|
206
|
+
const deviceConnected = selected?.tunnelState === 'connected';
|
|
207
|
+
const ready = Boolean(xcode.ok && deviceConnected && developerModeReady && ddiReady && runtime.ok === true && wda.ok === true);
|
|
148
208
|
return {
|
|
149
209
|
ok: true,
|
|
150
210
|
ready,
|
|
@@ -154,7 +214,7 @@ class IOSBridgeProvider {
|
|
|
154
214
|
selectedDevice: selected,
|
|
155
215
|
requirements: {
|
|
156
216
|
xcode: xcode.ok,
|
|
157
|
-
deviceConnected
|
|
217
|
+
deviceConnected,
|
|
158
218
|
developerModeEnabled: developerModeReady,
|
|
159
219
|
ddiServicesAvailable: ddiReady,
|
|
160
220
|
runtimeReachable: runtime.ok === true,
|
|
@@ -206,23 +266,14 @@ class IOSBridgeProvider {
|
|
|
206
266
|
steps.push({ name: 'install-app', ...install });
|
|
207
267
|
if (install.ok === false) return { ok: false, error: 'ios_app_install_failed', device, steps };
|
|
208
268
|
}
|
|
209
|
-
if (args.bundleId) {
|
|
210
|
-
const launch = await this.launchApp({ ...args, deviceId: device.identifier || device.udid });
|
|
211
|
-
steps.push({ name: 'launch-app', ...launch });
|
|
212
|
-
if (launch.ok === false) return { ok: false, error: 'ios_app_launch_failed', device, steps };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const runtime = args.bundleId || args.runtimeUrl || args.iosHost || args.host
|
|
216
|
-
? await this.runtimeGet(args, '/v1/status', { allowUnavailable: true, device })
|
|
217
|
-
: { ok: false, error: 'bundle_id_or_runtime_endpoint_required' };
|
|
218
|
-
steps.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
|
|
219
|
-
|
|
220
269
|
let wda = await this.wdaStatus(args);
|
|
221
270
|
if (wda.ok !== true && booleanArg(args.startWda)) {
|
|
222
271
|
const start = await this.startWda({ ...args, deviceId: device.identifier || device.udid });
|
|
223
272
|
steps.push({ name: 'start-wda', ...start });
|
|
224
273
|
if (start.ok === true) {
|
|
225
274
|
wda = start.status || await this.wdaStatus(args);
|
|
275
|
+
} else {
|
|
276
|
+
return { ok: false, error: start.error, message: start.message, device, steps };
|
|
226
277
|
}
|
|
227
278
|
}
|
|
228
279
|
steps.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
|
|
@@ -233,16 +284,27 @@ class IOSBridgeProvider {
|
|
|
233
284
|
error: 'ios_wda_required',
|
|
234
285
|
device,
|
|
235
286
|
steps,
|
|
236
|
-
message: 'Full iOS control requires
|
|
287
|
+
message: 'Full iOS control requires the prepared, signed WDA Runner. Pass --wda-runner-bundle-id for an existing bound Runner, or --team-id and --start-wda to build and start it. An optional --wda-url must match the selected Runner container identity.',
|
|
237
288
|
};
|
|
238
289
|
}
|
|
290
|
+
// Starting the XCTest Runner changes the foreground App. Launch the target
|
|
291
|
+
// afterwards and verify a fresh SDK response, not its pre-WDA snapshot.
|
|
292
|
+
if (args.bundleId) {
|
|
293
|
+
const launch = await this.launchApp({ ...args, deviceId: device.identifier || device.udid });
|
|
294
|
+
steps.push({ name: 'launch-app', ...launch });
|
|
295
|
+
if (launch.ok === false) return { ok: false, error: 'ios_app_launch_failed', device, steps };
|
|
296
|
+
}
|
|
297
|
+
const runtime = args.bundleId || args.runtimeUrl || args.iosHost || args.host
|
|
298
|
+
? await this.runtimeGet(args, '/v1/status', { allowUnavailable: true, device })
|
|
299
|
+
: { ok: false, error: 'bundle_id_or_runtime_endpoint_required' };
|
|
300
|
+
steps.push({ name: 'runtime', ok: runtime.ok === true, endpoint: runtime.endpoint || null, error: runtime.error || null });
|
|
239
301
|
if (runtime.ok !== true) {
|
|
240
302
|
return {
|
|
241
303
|
ok: false,
|
|
242
304
|
error: 'ios_runtime_required',
|
|
243
305
|
device,
|
|
244
306
|
steps,
|
|
245
|
-
message: 'WDA is reachable, but the
|
|
307
|
+
message: 'The bound WDA Runner is reachable, but the App SDK is not. Launch a debug App with AiAppBridgeIOS and supply its exact deviceId and bundleId; runtimeUrl or iosHost/iosPort may specify an explicitly forwarded endpoint.',
|
|
246
308
|
};
|
|
247
309
|
}
|
|
248
310
|
|
|
@@ -253,6 +315,7 @@ class IOSBridgeProvider {
|
|
|
253
315
|
device,
|
|
254
316
|
runtimeEndpoint: runtime.endpoint,
|
|
255
317
|
wdaUrl: wda.url,
|
|
318
|
+
wdaRunnerBundleId: wda.runtimeBinding.bundleId,
|
|
256
319
|
steps,
|
|
257
320
|
};
|
|
258
321
|
}
|
|
@@ -269,7 +332,7 @@ class IOSBridgeProvider {
|
|
|
269
332
|
'--device',
|
|
270
333
|
device.identifier || device.udid,
|
|
271
334
|
resolvedPath,
|
|
272
|
-
]);
|
|
335
|
+
], { mutation: true });
|
|
273
336
|
return {
|
|
274
337
|
ok: true,
|
|
275
338
|
device,
|
|
@@ -291,7 +354,7 @@ class IOSBridgeProvider {
|
|
|
291
354
|
];
|
|
292
355
|
if (args.terminateExisting !== false) launchArgs.push('--terminate-existing');
|
|
293
356
|
launchArgs.push(bundleId);
|
|
294
|
-
const raw = await this.devicectlJson(ctx, launchArgs);
|
|
357
|
+
const raw = await this.devicectlJson(ctx, launchArgs, { mutation: true });
|
|
295
358
|
return {
|
|
296
359
|
ok: true,
|
|
297
360
|
device,
|
|
@@ -303,6 +366,7 @@ class IOSBridgeProvider {
|
|
|
303
366
|
async screenshot(args = {}) {
|
|
304
367
|
const ctx = this.context(args);
|
|
305
368
|
const device = await this.requireDevice(args);
|
|
369
|
+
const generatedDefault = !args.outFile;
|
|
306
370
|
const outFile = args.outFile
|
|
307
371
|
? path.resolve(args.outFile)
|
|
308
372
|
: defaultArtifactPath('ios-screenshot', 'png', { artifactDir: args.artifactDir });
|
|
@@ -318,47 +382,123 @@ class IOSBridgeProvider {
|
|
|
318
382
|
];
|
|
319
383
|
if (args.displayUniqueId) command.push('--display-unique-id', String(args.displayUniqueId));
|
|
320
384
|
const raw = await this.devicectlJson(ctx, command);
|
|
385
|
+
const artifact = {
|
|
386
|
+
path: outFile,
|
|
387
|
+
generatedDefault,
|
|
388
|
+
directory: path.dirname(outFile),
|
|
389
|
+
sha256: createHash('sha256').update(await fs.promises.readFile(outFile)).digest('hex'),
|
|
390
|
+
};
|
|
391
|
+
if (generatedDefault) {
|
|
392
|
+
artifact.retention = await pruneGeneratedArtifacts({
|
|
393
|
+
directory: artifact.directory,
|
|
394
|
+
prefix: 'ios-screenshot',
|
|
395
|
+
extension: 'png',
|
|
396
|
+
currentPath: outFile,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
321
399
|
return {
|
|
322
400
|
ok: true,
|
|
323
401
|
device,
|
|
324
402
|
outFile,
|
|
403
|
+
artifact,
|
|
325
404
|
result: raw.result || raw,
|
|
326
405
|
};
|
|
327
406
|
}
|
|
328
407
|
|
|
329
408
|
async runtimeGet(args, endpointPath, options = {}) {
|
|
330
|
-
const endpoint = await this.resolveRuntimeEndpoint(args, options);
|
|
331
|
-
if (!endpoint.ok) return endpoint;
|
|
332
409
|
try {
|
|
333
|
-
|
|
334
|
-
timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
335
|
-
});
|
|
336
|
-
return {
|
|
337
|
-
...(response && typeof response === 'object' ? response : { value: response }),
|
|
338
|
-
ok: response?.ok === false ? false : true,
|
|
339
|
-
endpoint: endpoint.baseUrl,
|
|
340
|
-
device: endpoint.device || null,
|
|
341
|
-
};
|
|
410
|
+
return await this.runtimeRequest('GET', args, endpointPath, null, options);
|
|
342
411
|
} catch (error) {
|
|
343
|
-
if (options.allowUnavailable)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
throw error;
|
|
412
|
+
if (!options.allowUnavailable) throw error;
|
|
413
|
+
return { ok: false, error: iosErrorCode(null, error), message: error.message,
|
|
414
|
+
dispatched: error.dispatched ?? false, ambiguous: error.ambiguous === true };
|
|
347
415
|
}
|
|
348
416
|
}
|
|
349
417
|
|
|
350
|
-
async runtimePost(args, endpointPath, body) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
418
|
+
async runtimePost(args, endpointPath, body, options = {}) {
|
|
419
|
+
return this.runtimeRequest('POST', args, endpointPath, body, options);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async runtimePort(args, options = {}) {
|
|
423
|
+
const endpoint = await this.resolveRuntimeEndpoint(args, options);
|
|
424
|
+
const httpOptions = { timeoutMs: this.context(args).httpTimeoutMs, headers: bindingHeaders(endpoint.runtimeBinding) };
|
|
425
|
+
const request = async (method, endpointPath, payload) => {
|
|
426
|
+
let response;
|
|
427
|
+
try {
|
|
428
|
+
response = await this.httpRequest(method, `${endpoint.baseUrl}${endpointPath}`, payload, httpOptions);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (!error.response) throw error;
|
|
431
|
+
assertRuntimeResponse(error.response, endpoint.runtimeBinding);
|
|
432
|
+
if (error.response.ok !== false || typeof error.response.error !== 'string' || !/^[a-z][a-z0-9_]*$/.test(error.response.error)) {
|
|
433
|
+
throw bindingFailure('invalid_ios_runtime_response', 'The bound runtime HTTP error has no structured rejection reason.');
|
|
434
|
+
}
|
|
435
|
+
// A validated SDK rejection is still a protocol response. Preserve its
|
|
436
|
+
// candidates, receipt and dispatch status instead of replacing it with
|
|
437
|
+
// an exception containing only the error string.
|
|
438
|
+
return error.response;
|
|
439
|
+
}
|
|
440
|
+
assertRuntimeResponse(response, endpoint.runtimeBinding);
|
|
441
|
+
return response;
|
|
442
|
+
};
|
|
443
|
+
return { endpoint, get: endpointPath => request('GET', endpointPath, null), post: (endpointPath, payload) => request('POST', endpointPath, payload) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async runtimeRequest(method, args, endpointPath, body, options = {}) {
|
|
447
|
+
return runExecution({ timeoutMs: args.timeoutMs ?? 30000, mutation: method === 'POST' }, async () => {
|
|
448
|
+
const port = await this.runtimePort(args, options);
|
|
449
|
+
const endpoint = port.endpoint;
|
|
450
|
+
let response;
|
|
451
|
+
if (method === 'POST') {
|
|
452
|
+
const status = await port.get('/v1/status');
|
|
453
|
+
if (status.ok === false) return { ...status, dispatched: false, ambiguous: false };
|
|
454
|
+
const kind = endpointPath === '/v1/h5/action' ? 'h5' : 'flutter';
|
|
455
|
+
const target = { platform: 'ios', deviceId: endpoint.device.udid, bundleId: args.bundleId,
|
|
456
|
+
...Object.fromEntries(['runtimeUrl', 'iosHost', 'iosPort', 'devicectl'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) };
|
|
457
|
+
response = await executeIOSAction({ port, kind, payload: body, status, target, timeoutMs: args.timeoutMs ?? 30000,
|
|
458
|
+
actionId: args.runtimeActionId ?? args.requestId });
|
|
459
|
+
} else { response = await port.get(endpointPath); checkExecution(); }
|
|
460
|
+
return { ...response, endpoint: endpoint.baseUrl, device: endpoint.device, runtimeBinding: endpoint.runtimeBinding };
|
|
355
461
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async executionControl(args) {
|
|
465
|
+
const device = await this.requireDevice(args);
|
|
466
|
+
const lease = this.lease || getProcessDeviceMutationLease();
|
|
467
|
+
const key = iosDeviceKey(device);
|
|
468
|
+
if (args.kind === 'wda') {
|
|
469
|
+
if (args.operation === 'reconcile') return reconcileWDA({ lease, device, args,
|
|
470
|
+
createPort: target => openWdaPort(this, target, device) });
|
|
471
|
+
const port = await openWdaPort(this, args, device);
|
|
472
|
+
if (args.operation === 'status') return { ok: true, device, ownership: lease.status(key), runtime: port.status,
|
|
473
|
+
runtimeBinding: port.runtimeBinding };
|
|
474
|
+
const identity = { actionId: args.actionId, runtimeEpoch: args.runtimeEpoch };
|
|
475
|
+
const cancelled = args.operation === 'cancel' ? await port.request('POST', '/aab/execution/cancel', identity) : undefined;
|
|
476
|
+
if (cancelled && cancelled.ok !== true) return cancelled;
|
|
477
|
+
return lookupCompletion(completionPort(port), 'wda', identity, cancelled);
|
|
478
|
+
}
|
|
479
|
+
if (args.operation === 'reconcile') return reconcileIOS({ lease, device, args,
|
|
480
|
+
createPort: target => this.runtimePort(target, { device }) });
|
|
481
|
+
if (args.operation === 'status') return {
|
|
482
|
+
ok: true, device, ownership: lease.status(key),
|
|
483
|
+
runtime: await this.runtimeGet(args, '/v1/execution/status', { device, allowUnavailable: true }),
|
|
361
484
|
};
|
|
485
|
+
const port = await this.runtimePort(args, { device });
|
|
486
|
+
const identity = { actionId: args.actionId, runtimeEpoch: args.runtimeEpoch };
|
|
487
|
+
if (args.operation === 'cancel') {
|
|
488
|
+
const response = await port.post(`/v1/${args.kind}/cancel`, identity);
|
|
489
|
+
if (response.ok !== true) return response;
|
|
490
|
+
return lookupCompletion(port, args.kind, identity, response);
|
|
491
|
+
}
|
|
492
|
+
return lookupCompletion(port, args.kind, identity);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async h5Control(command, args, context) {
|
|
496
|
+
const tree = await this.runtimeGet(args, withQuery('/v1/h5/dom', args.webViewId === undefined ? {} : { webViewId: args.webViewId }), context);
|
|
497
|
+
if (tree.ok !== true) return tree;
|
|
498
|
+
const selected = h5Target.selectH5Node(tree, args.selector, args.expectedTarget);
|
|
499
|
+
if (!selected.ok) return selected;
|
|
500
|
+
return this.runtimePost(args, '/v1/h5/action', { payload: { action: h5Controls.get(command),
|
|
501
|
+
...selected.targetRef, ...(command === 'ios-h5-input' ? { text: args.text } : {}) } }, context);
|
|
362
502
|
}
|
|
363
503
|
|
|
364
504
|
async flutterTree(args = {}) {
|
|
@@ -367,6 +507,7 @@ class IOSBridgeProvider {
|
|
|
367
507
|
return {
|
|
368
508
|
ok: true,
|
|
369
509
|
endpoint: status.endpoint,
|
|
510
|
+
runtimeBinding: status.runtimeBinding,
|
|
370
511
|
device: status.device || null,
|
|
371
512
|
flutter: status.flutter || null,
|
|
372
513
|
layout: status.flutter?.layout || null,
|
|
@@ -378,8 +519,10 @@ class IOSBridgeProvider {
|
|
|
378
519
|
if (tree.ok === false) return tree;
|
|
379
520
|
const operable = tree.layout?.operable || null;
|
|
380
521
|
return {
|
|
522
|
+
...operable,
|
|
381
523
|
ok: Boolean(operable),
|
|
382
524
|
endpoint: tree.endpoint,
|
|
525
|
+
runtimeBinding: tree.runtimeBinding,
|
|
383
526
|
device: tree.device || null,
|
|
384
527
|
operable,
|
|
385
528
|
nodes: operable?.nodes || [],
|
|
@@ -388,406 +531,261 @@ class IOSBridgeProvider {
|
|
|
388
531
|
};
|
|
389
532
|
}
|
|
390
533
|
|
|
391
|
-
async
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
534
|
+
async flutterControl(command, args, context = {}) {
|
|
535
|
+
const port = await this.runtimePort(args, context);
|
|
536
|
+
const status = await port.get('/v1/status');
|
|
537
|
+
if (status.ok !== true) return { ...status, dispatched: false, ambiguous: false };
|
|
538
|
+
const action = flutterControls.get(command);
|
|
539
|
+
let payload = { action };
|
|
540
|
+
if (!['back', 'hideKeyboard'].includes(action)) {
|
|
541
|
+
const bound = bindFlutterAction(status.flutter?.layout?.operable, {
|
|
542
|
+
action, selector: args.selector,
|
|
543
|
+
...(action === 'inputText' ? { text: args.text } : {}),
|
|
544
|
+
...(action === 'scrollBy' ? { delta: args.delta } : {}),
|
|
545
|
+
});
|
|
546
|
+
if (!bound.ok) return bound;
|
|
547
|
+
payload = bound.payload;
|
|
406
548
|
}
|
|
549
|
+
const endpoint = port.endpoint;
|
|
550
|
+
const target = { platform: 'ios', deviceId: endpoint.device.udid, bundleId: args.bundleId,
|
|
551
|
+
...Object.fromEntries(['runtimeUrl', 'iosHost', 'iosPort', 'devicectl'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) };
|
|
552
|
+
const result = await executeIOSAction({ port, kind: 'flutter', payload, status, target,
|
|
553
|
+
timeoutMs: args.timeoutMs ?? 30000, actionId: args.runtimeActionId ?? args.requestId });
|
|
554
|
+
return { ...result, endpoint: endpoint.baseUrl, device: endpoint.device, runtimeBinding: endpoint.runtimeBinding };
|
|
555
|
+
}
|
|
407
556
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
port,
|
|
415
|
-
portSource,
|
|
416
|
-
hostSource: explicitHost ? 'explicit' : 'device',
|
|
417
|
-
};
|
|
557
|
+
async resolveRuntimeEndpoint(args = {}, options = {}) {
|
|
558
|
+
const target = normalizeExecutionTarget({ platform: 'ios', deviceId: args.deviceId, bundleId: args.bundleId,
|
|
559
|
+
...Object.fromEntries(['runtimeUrl', 'iosHost', 'iosPort'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) });
|
|
560
|
+
const device = options.device ?? await this.requireDevice(args);
|
|
561
|
+
if (![device.identifier, device.udid].includes(target.deviceId)) {
|
|
562
|
+
throw bindingFailure('ios_device_not_found', 'deviceId must match the selected devicectl identifier or UDID.');
|
|
418
563
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const probe = await this.probeRuntimeHosts(hosts, args, device);
|
|
422
|
-
if (probe.ok) return probe;
|
|
564
|
+
if (device.developerModeStatus !== 'enabled' || device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') {
|
|
565
|
+
throw bindingFailure('ios_tunnel_unavailable', 'The selected iPhone must expose a connected developer tunnel. Unlock/trust the device and let Xcode prepare it.');
|
|
423
566
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
error: 'ios_tunnel_unavailable',
|
|
429
|
-
device,
|
|
430
|
-
message: 'No iOS runtime host is available. Pass --runtime-url or --ios-host, or enable Developer Mode/unlock the device so devicectl exposes a tunnel IP.',
|
|
431
|
-
};
|
|
567
|
+
const runtimeBinding = await this.readRuntimePortFile(args, device);
|
|
568
|
+
const host = target.iosHost ?? device.tunnelIPAddress;
|
|
569
|
+
if (!target.runtimeUrl && !host) {
|
|
570
|
+
throw bindingFailure('ios_tunnel_unavailable', 'The selected device has no tunnel IP. Supply runtimeUrl or iosHost for an explicitly forwarded endpoint.');
|
|
432
571
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
async probeRuntimeHosts(hosts, args, device) {
|
|
442
|
-
const timeoutMs = numberArg(args.probeTimeoutMs, defaultProbeTimeoutMs);
|
|
443
|
-
const start = Number(args.portStart || defaultRuntimePort);
|
|
444
|
-
const count = Number(args.portSearchCount || runtimePortSearchCount);
|
|
445
|
-
for (const host of hosts) {
|
|
446
|
-
for (let offset = 0; offset < count; offset += 1) {
|
|
447
|
-
const port = start + offset;
|
|
448
|
-
const baseUrl = `http://${formatHostForUrl(host)}:${port}`;
|
|
449
|
-
try {
|
|
450
|
-
const status = await this.httpRequest('GET', `${baseUrl}/v1/status`, null, { timeoutMs });
|
|
451
|
-
if (status && status.debugBridge) {
|
|
452
|
-
return { ok: true, baseUrl, device, port, portSource: 'runtime-probe', hostSource: 'probe' };
|
|
453
|
-
}
|
|
454
|
-
} catch (_) {
|
|
455
|
-
// Probe failures are expected while scanning.
|
|
456
|
-
}
|
|
457
|
-
}
|
|
572
|
+
const port = target.iosPort ?? runtimeBinding.port;
|
|
573
|
+
const baseUrl = target.runtimeUrl ?? `http://${formatHostForUrl(host)}:${port}`;
|
|
574
|
+
// Validate the constructed URL too; a host cannot smuggle a path or query.
|
|
575
|
+
let url;
|
|
576
|
+
try { url = new URL(baseUrl); }
|
|
577
|
+
catch { throw bindingFailure('invalid_argument', 'The iOS runtime endpoint is not a valid URL.'); }
|
|
578
|
+
if (url.username || url.password || url.search || url.hash || (!target.runtimeUrl && url.pathname !== '/')) {
|
|
579
|
+
throw bindingFailure('invalid_argument', 'iosHost must be a hostname or IP address. runtimeUrl must have no credentials, query or fragment.');
|
|
458
580
|
}
|
|
459
|
-
|
|
581
|
+
checkExecution();
|
|
582
|
+
return { ok: true, baseUrl: stripTrailingSlash(url.href), device, runtimeBinding };
|
|
460
583
|
}
|
|
461
584
|
|
|
462
585
|
async readRuntimePortFile(args, device) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
586
|
+
return descriptorBinding(await this.readContainerDescriptor(args, device, args.bundleId, 'ai_app_bridge_port.json'), args.bundleId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async readContainerDescriptor(args, device, bundleId, filename) {
|
|
590
|
+
const ctx = { ...this.context(args), deviceTimeoutSec: 5 };
|
|
467
591
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ai-app-bridge-ios-port-'));
|
|
468
592
|
try {
|
|
469
593
|
await this.devicectlJson(ctx, [
|
|
470
|
-
'device',
|
|
471
|
-
'
|
|
472
|
-
'
|
|
473
|
-
'--device',
|
|
474
|
-
device.identifier || device.udid,
|
|
475
|
-
'--domain-type',
|
|
476
|
-
'appDataContainer',
|
|
477
|
-
'--domain-identifier',
|
|
478
|
-
requiredString(args.bundleId, 'bundleId'),
|
|
479
|
-
'--source',
|
|
480
|
-
'Documents/ai_app_bridge_port.json',
|
|
481
|
-
'--destination',
|
|
482
|
-
tempDir,
|
|
594
|
+
'device', 'copy', 'from', '--device', device.identifier,
|
|
595
|
+
'--domain-type', 'appDataContainer', '--domain-identifier', bundleId,
|
|
596
|
+
'--source', `Documents/${filename}`, '--destination', path.join(tempDir, filename),
|
|
483
597
|
]);
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
598
|
+
checkExecution();
|
|
599
|
+
const portFile = path.join(tempDir, filename);
|
|
600
|
+
let data;
|
|
601
|
+
try {
|
|
602
|
+
const handle = await fs.promises.open(portFile, 'r');
|
|
603
|
+
try {
|
|
604
|
+
const buffer = Buffer.alloc(4097);
|
|
605
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
606
|
+
if (bytesRead > 4096) throw bindingFailure('invalid_ios_runtime_descriptor', 'Runtime descriptor exceeds 4096 bytes.');
|
|
607
|
+
data = buffer.subarray(0, bytesRead).toString('utf8');
|
|
608
|
+
} finally { await handle.close(); }
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (error.code === 'ENOENT') throw bindingFailure('ios_runtime_descriptor_absent', `The selected App container has no ${filename}. Start the matching runtime before querying it.`);
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
let descriptor;
|
|
614
|
+
try { descriptor = JSON.parse(data); }
|
|
615
|
+
catch { throw bindingFailure('invalid_ios_runtime_descriptor', 'The selected App runtime descriptor is not valid JSON.'); }
|
|
616
|
+
return descriptor;
|
|
491
617
|
} finally {
|
|
492
618
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
493
619
|
}
|
|
494
620
|
}
|
|
495
621
|
|
|
496
622
|
async wdaStatus(args = {}) {
|
|
497
|
-
const url = wdaBaseUrl(args);
|
|
498
623
|
try {
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
});
|
|
502
|
-
return {
|
|
503
|
-
ok: true,
|
|
504
|
-
url,
|
|
505
|
-
value: response?.value || response,
|
|
506
|
-
};
|
|
624
|
+
const port = await openWdaPort(this, args);
|
|
625
|
+
return { ok: true, url: port.baseUrl, device: port.device, runtimeBinding: port.runtimeBinding, value: port.status };
|
|
507
626
|
} catch (error) {
|
|
508
|
-
return {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
627
|
+
return { ok: false, error: iosErrorCode(null, error), message: error.message,
|
|
628
|
+
dispatched: error.dispatched ?? false, ambiguous: error.ambiguous === true };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async wdaSession(args, context = {}) {
|
|
633
|
+
const port = await openWdaPort(this, args, context.device);
|
|
634
|
+
if (args.operation === 'status') return { ok: true, runtimeBinding: port.runtimeBinding, ...(await port.session(false)) };
|
|
635
|
+
if (args.operation === 'create') {
|
|
636
|
+
const state = await port.session(false);
|
|
637
|
+
if (state.session !== null) return { ok: false, error: 'ios_wda_session_busy', dispatched: false, ambiguous: false };
|
|
638
|
+
const foreground = wdaTarget(state.foreground);
|
|
639
|
+
if (foreground.bundleId !== args.bundleId) return { ok: false, error: 'ios_wda_target_changed', dispatched: false, ambiguous: false };
|
|
640
|
+
const result = await executeWDAAction({ port, args, operation: 'session-create', selected: foreground });
|
|
641
|
+
if (result.ok !== true) return { ...result, runtimeBinding: port.runtimeBinding };
|
|
642
|
+
const selected = wdaTarget(result.value, true);
|
|
643
|
+
if (selected.bundleId !== foreground.bundleId || selected.processId !== foreground.processId) {
|
|
644
|
+
throw bindingFailure('ios_wda_target_changed', 'The App process changed while the WDA session was being created.');
|
|
645
|
+
}
|
|
646
|
+
return { ...result, session: selected, runtimeBinding: port.runtimeBinding, device: port.device };
|
|
514
647
|
}
|
|
648
|
+
const selected = await port.session();
|
|
649
|
+
const result = await executeWDAAction({ port, args, operation: 'session-close', selected });
|
|
650
|
+
return { ...result, ...(result.ok ? { closedSession: selected } : {}), runtimeBinding: port.runtimeBinding };
|
|
515
651
|
}
|
|
516
652
|
|
|
517
653
|
async wdaSource(args = {}) {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
const response = await this.wdaRequest(args, 'GET', `/session/${session.sessionId}/source?format=json`);
|
|
521
|
-
return { ok: true, session, source: response?.value || response };
|
|
654
|
+
const port = await openWdaPort(this, args);
|
|
655
|
+
return this.nativeObservation(port);
|
|
522
656
|
}
|
|
523
657
|
|
|
524
|
-
async
|
|
525
|
-
const
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
try {
|
|
531
|
-
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/tap/0`, body);
|
|
532
|
-
return { ok: true, transport: 'wda', action: 'tap', session, x, y, response };
|
|
533
|
-
} catch (firstError) {
|
|
534
|
-
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/actions`, pointerAction(x, y));
|
|
535
|
-
return { ok: true, transport: 'wda-actions', action: 'tap', session, x, y, firstError: firstError.message, response };
|
|
536
|
-
}
|
|
658
|
+
async nativeObservation(port) {
|
|
659
|
+
const session = await port.session();
|
|
660
|
+
const source = await port.request('GET', `/session/${encodeURIComponent(session.sessionId)}/source?format=json`, null, session);
|
|
661
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) throw bindingFailure('invalid_ios_wda_tree', 'WDA must return its JSON UI tree.');
|
|
662
|
+
return { ok: true, session, source, device: port.device, runtimeBinding: port.runtimeBinding,
|
|
663
|
+
nativeTargetSchema: port.status.nativeTargetSchema };
|
|
537
664
|
}
|
|
538
665
|
|
|
539
|
-
async
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
: null;
|
|
555
|
-
const response = await this.wdaRequest(args, 'POST', `${elementPath}/value`, body);
|
|
556
|
-
return {
|
|
557
|
-
ok: true,
|
|
558
|
-
transport: 'wda-element-value',
|
|
559
|
-
action: 'input',
|
|
560
|
-
session,
|
|
561
|
-
textLength: text.length,
|
|
562
|
-
tapped,
|
|
563
|
-
element: elementTarget,
|
|
564
|
-
clicked: Boolean(clickResponse),
|
|
565
|
-
cleared: Boolean(clearResponse),
|
|
566
|
-
response,
|
|
567
|
-
};
|
|
568
|
-
}
|
|
666
|
+
async nativeControl(command, args, context = {}) {
|
|
667
|
+
const port = await openWdaPort(this, args, context.device);
|
|
668
|
+
if (port.status.nativeTargetSchema !== nativeTarget.schema) return {
|
|
669
|
+
ok: false, error: 'ios_native_target_schema_required', dispatched: false, ambiguous: false,
|
|
670
|
+
};
|
|
671
|
+
const observation = await this.nativeObservation(port);
|
|
672
|
+
const selected = nativeTarget.bindNativeTarget(observation, args.selector, args.expectedTarget);
|
|
673
|
+
if (!selected.ok) return selected;
|
|
674
|
+
const operation = nativeControls.get(command);
|
|
675
|
+
const payload = { targetRef: selected.targetRef.element,
|
|
676
|
+
...(operation === 'native-input' ? { text: args.text, clearFirst: true } : {}) };
|
|
677
|
+
const result = await executeWDAAction({ port, args, operation, selected: observation.session, payload });
|
|
678
|
+
return { ...result, transport: 'wda-managed-native', action: operation, session: observation.session,
|
|
679
|
+
resolved: selected.targetRef, matched: 1, runtimeBinding: port.runtimeBinding };
|
|
680
|
+
}
|
|
569
681
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
682
|
+
async wdaTap(args = {}) {
|
|
683
|
+
const port = await openWdaPort(this, args);
|
|
684
|
+
const session = await port.session();
|
|
685
|
+
const x = requiredNumber(args.tapX, 'tapX'), y = requiredNumber(args.tapY, 'tapY');
|
|
686
|
+
const result = await executeWDAAction({ port, args, operation: 'tap', selected: session, payload: { x, y } });
|
|
687
|
+
return { ...result, transport: 'wda', action: 'tap', session, x, y, runtimeBinding: port.runtimeBinding };
|
|
577
688
|
}
|
|
578
689
|
|
|
579
|
-
async
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
return {
|
|
589
|
-
|
|
590
|
-
source: 'accessibility-id',
|
|
591
|
-
accessibilityId: String(args.accessibilityId),
|
|
592
|
-
response,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
if (tapped?.ok === true) {
|
|
596
|
-
try {
|
|
597
|
-
const response = await this.wdaRequest(args, 'GET', `/session/${session.sessionId}/element/active`);
|
|
598
|
-
return {
|
|
599
|
-
elementId: wdaElementIdFromResponse(response),
|
|
600
|
-
source: 'active-element-after-tap',
|
|
601
|
-
response,
|
|
602
|
-
};
|
|
603
|
-
} catch (_) {
|
|
604
|
-
return { elementId: '', source: 'active-element-unavailable' };
|
|
605
|
-
}
|
|
690
|
+
async wdaInput(args = {}) {
|
|
691
|
+
const port = await openWdaPort(this, args);
|
|
692
|
+
const session = await port.session();
|
|
693
|
+
const text = requiredInputString(args.text, 'text');
|
|
694
|
+
const prefix = `/session/${encodeURIComponent(session.sessionId)}`;
|
|
695
|
+
let elementId = args.elementId;
|
|
696
|
+
if (args.accessibilityId !== undefined) {
|
|
697
|
+
const matches = await port.request('POST', `${prefix}/elements`, { using: 'accessibility id', value: args.accessibilityId }, session, false);
|
|
698
|
+
if (!Array.isArray(matches)) throw bindingFailure('invalid_ios_wda_elements', 'WDA must return an array of matching elements.');
|
|
699
|
+
if (matches.length !== 1) return { ok: false, error: matches.length ? 'ios_wda_target_ambiguous' : 'ios_wda_target_not_found', dispatched: false, ambiguous: false };
|
|
700
|
+
elementId = matches[0]?.['element-6066-11e4-a52e-4f735466cecf'];
|
|
606
701
|
}
|
|
607
|
-
|
|
702
|
+
if (typeof elementId !== 'string' || !elementId) throw bindingFailure('invalid_ios_wda_element', 'WDA must return the exact W3C element identity.');
|
|
703
|
+
const result = await executeWDAAction({ port, args, operation: 'input', selected: session,
|
|
704
|
+
payload: { elementId, text, clearFirst: args.clearFirst === true } });
|
|
705
|
+
return { ...result, transport: 'wda-managed-input', action: 'input', session, elementId, textLength: text.length,
|
|
706
|
+
runtimeBinding: port.runtimeBinding };
|
|
608
707
|
}
|
|
609
708
|
|
|
610
709
|
async wdaSwipe(args = {}) {
|
|
611
|
-
const
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
const endY = requiredNumber(args.endY, 'endY');
|
|
615
|
-
const durationMs =
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/actions`, actions);
|
|
621
|
-
return { ok: true, transport: 'wda-actions', action: 'swipe', session, startX, startY, endX, endY, durationMs, response };
|
|
622
|
-
} catch (firstError) {
|
|
623
|
-
const response = await this.wdaRequest(args, 'POST', `/session/${session.sessionId}/wda/dragfromtoforduration`, {
|
|
624
|
-
fromX: startX,
|
|
625
|
-
fromY: startY,
|
|
626
|
-
toX: endX,
|
|
627
|
-
toY: endY,
|
|
628
|
-
duration: Math.max(durationMs / 1000, 0.05),
|
|
629
|
-
});
|
|
630
|
-
return { ok: true, transport: 'wda-drag', action: 'swipe', session, startX, startY, endX, endY, durationMs, firstError: firstError.message, response };
|
|
631
|
-
}
|
|
710
|
+
const port = await openWdaPort(this, args);
|
|
711
|
+
const session = await port.session();
|
|
712
|
+
const startX = requiredNumber(args.startX, 'startX'), startY = requiredNumber(args.startY, 'startY');
|
|
713
|
+
const endX = requiredNumber(args.endX, 'endX'), endY = requiredNumber(args.endY, 'endY');
|
|
714
|
+
const durationMs = args.durationMs ?? 500;
|
|
715
|
+
const result = await executeWDAAction({ port, args, operation: 'swipe', selected: session,
|
|
716
|
+
payload: { startX, startY, endX, endY, durationMs } });
|
|
717
|
+
return { ...result, transport: 'wda-actions', action: 'swipe', session, startX, startY, endX, endY, durationMs,
|
|
718
|
+
runtimeBinding: port.runtimeBinding };
|
|
632
719
|
}
|
|
633
720
|
|
|
634
|
-
async
|
|
635
|
-
if (args.
|
|
636
|
-
|
|
637
|
-
}
|
|
638
|
-
const bundleId = args.bundleId ? String(args.bundleId) : undefined;
|
|
639
|
-
const body = {
|
|
640
|
-
capabilities: {
|
|
641
|
-
alwaysMatch: {
|
|
642
|
-
platformName: 'iOS',
|
|
643
|
-
automationName: 'XCUITest',
|
|
644
|
-
...(bundleId ? { bundleId } : {}),
|
|
645
|
-
},
|
|
646
|
-
firstMatch: [{}],
|
|
647
|
-
},
|
|
648
|
-
desiredCapabilities: {
|
|
649
|
-
platformName: 'iOS',
|
|
650
|
-
automationName: 'XCUITest',
|
|
651
|
-
...(bundleId ? { bundleId } : {}),
|
|
652
|
-
},
|
|
721
|
+
async wdaSetOrientation(args, context = {}) {
|
|
722
|
+
if (!nativeTarget.orientationSchema.enum.includes(args.orientation)) return {
|
|
723
|
+
ok: false, error: 'invalid_ios_wda_orientation', dispatched: false, ambiguous: false,
|
|
653
724
|
};
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
async wdaRequest(args, method, endpointPath, body) {
|
|
672
|
-
return this.httpRequest(method, `${wdaBaseUrl(args)}${endpointPath}`, body, {
|
|
673
|
-
timeoutMs: numberArg(args.httpTimeoutMs, defaultHttpTimeoutMs),
|
|
674
|
-
});
|
|
725
|
+
const port = await openWdaPort(this, args, context.device);
|
|
726
|
+
if (port.status.orientationSchema !== 'aab.ios-orientation/v1') return {
|
|
727
|
+
ok: false, error: 'ios_wda_orientation_schema_required', dispatched: false, ambiguous: false,
|
|
728
|
+
};
|
|
729
|
+
const session = await port.session();
|
|
730
|
+
const resolved = nativeTarget.sessionRef({ session, runtimeBinding: port.runtimeBinding });
|
|
731
|
+
if (args.expectedSession !== undefined && !isDeepStrictEqual(args.expectedSession, resolved)) return {
|
|
732
|
+
ok: false, error: 'reobserve_required', dispatched: false, ambiguous: false,
|
|
733
|
+
};
|
|
734
|
+
const result = await executeWDAAction({ port, args, operation: 'set-orientation', selected: session,
|
|
735
|
+
payload: { orientation: args.orientation } });
|
|
736
|
+
return { ...result, transport: 'wda-managed-orientation', action: 'set-orientation', session,
|
|
737
|
+
resolved, runtimeBinding: port.runtimeBinding };
|
|
675
738
|
}
|
|
676
739
|
|
|
677
740
|
async startWda(args = {}) {
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
message: 'Cannot start WDA because no WebDriverAgent.xcodeproj was found. Pass --wda-project-path or install a package that vendors appium-webdriveragent.',
|
|
684
|
-
};
|
|
741
|
+
const device = await this.requireDevice(args);
|
|
742
|
+
const wdaTestBundleId = args.wdaTestBundleId ?? defaultWdaTestBundleId;
|
|
743
|
+
const wdaRunnerBundleId = `${wdaTestBundleId}.xctrunner`;
|
|
744
|
+
if (args.wdaRunnerBundleId !== undefined && args.wdaRunnerBundleId !== wdaRunnerBundleId) {
|
|
745
|
+
return { ok: false, error: 'ios_wda_runner_bundle_mismatch', message: 'Starting WDA uses the Runner application ID <wdaTestBundleId>.xctrunner.' };
|
|
685
746
|
}
|
|
747
|
+
const existing = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
|
|
748
|
+
if (existing.ok) return { ok: true, reused: true, device, wdaTestBundleId, wdaRunnerBundleId, status: existing };
|
|
686
749
|
const teamId = args.teamId || process.env.DEVELOPMENT_TEAM || process.env.AI_APP_BRIDGE_IOS_TEAM_ID;
|
|
687
|
-
if (!teamId) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
message: 'Cannot sign WebDriverAgentRunner without a DEVELOPMENT_TEAM. Add an Apple account in Xcode and pass --team-id.',
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
const wdaBundleId = stringArg(args.wdaBundleId, process.env.AI_APP_BRIDGE_WDA_BUNDLE_ID || defaultWdaBundleId);
|
|
750
|
+
if (!teamId) return { ok: false, error: 'ios_team_id_required', message: 'Signing WDA requires an explicit teamId or configured DEVELOPMENT_TEAM.' };
|
|
751
|
+
checkExecution();
|
|
752
|
+
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'aab-wda-runtime-'));
|
|
753
|
+
const prepared = prepareWdaProject({ destination: path.join(directory, 'source') });
|
|
695
754
|
const ctx = this.context(args);
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
'-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
});
|
|
716
|
-
fs.closeSync(out);
|
|
717
|
-
let spawnError = null;
|
|
718
|
-
let exited = false;
|
|
719
|
-
child.once('error', (error) => { spawnError = error; });
|
|
720
|
-
child.once('exit', () => { exited = true; });
|
|
721
|
-
child.unref();
|
|
722
|
-
await sleep(1200);
|
|
723
|
-
if (spawnError) {
|
|
724
|
-
return { ok: false, error: 'ios_wda_xcodebuild_spawn_failed', message: spawnError.message, logFile };
|
|
725
|
-
}
|
|
726
|
-
const deadline = Date.now() + numberArg(args.wdaStartupTimeoutMs, 60000);
|
|
727
|
-
while (Date.now() < deadline) {
|
|
728
|
-
const status = await this.bestWdaStatus(args, device, logFile);
|
|
729
|
-
if (status.ok) {
|
|
730
|
-
return {
|
|
731
|
-
ok: true,
|
|
732
|
-
device,
|
|
733
|
-
wdaProjectPath,
|
|
734
|
-
wdaBundleId,
|
|
735
|
-
teamId,
|
|
736
|
-
pid: child.pid,
|
|
737
|
-
logFile,
|
|
738
|
-
status,
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
if (exited) {
|
|
742
|
-
return {
|
|
743
|
-
ok: false,
|
|
744
|
-
error: 'ios_wda_xcodebuild_exited',
|
|
745
|
-
device,
|
|
746
|
-
wdaProjectPath,
|
|
747
|
-
wdaBundleId,
|
|
748
|
-
teamId,
|
|
749
|
-
pid: child.pid,
|
|
750
|
-
logFile,
|
|
751
|
-
message: 'xcodebuild exited before WebDriverAgent became reachable. Check the log file for signing or provisioning errors.',
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
await sleep(1000);
|
|
755
|
-
}
|
|
756
|
-
return {
|
|
757
|
-
ok: false,
|
|
758
|
-
error: 'ios_wda_start_timeout',
|
|
759
|
-
device,
|
|
760
|
-
wdaProjectPath,
|
|
761
|
-
wdaBundleId,
|
|
762
|
-
teamId,
|
|
763
|
-
pid: child.pid,
|
|
764
|
-
logFile,
|
|
765
|
-
message: 'Timed out waiting for WebDriverAgent /status. If Xcode is showing a signing, trust, or device prompt, resolve it and rerun ios-setup.',
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
async bestWdaStatus(args, device, logFile) {
|
|
770
|
-
const urls = [
|
|
771
|
-
args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL || '',
|
|
772
|
-
device?.tunnelIPAddress ? `http://${formatHostForUrl(device.tunnelIPAddress)}:8100` : '',
|
|
773
|
-
parseWdaServerUrlFromLog(logFile),
|
|
774
|
-
'http://127.0.0.1:8100',
|
|
775
|
-
].filter(Boolean);
|
|
776
|
-
const uniqueUrls = [...new Set(urls.map(stripTrailingSlash))];
|
|
777
|
-
let last = null;
|
|
778
|
-
for (const url of uniqueUrls) {
|
|
779
|
-
const status = await this.wdaStatus({ ...args, wdaUrl: url });
|
|
780
|
-
if (status.ok) return status;
|
|
781
|
-
last = status;
|
|
782
|
-
}
|
|
783
|
-
return last || { ok: false, error: 'ios_wda_unreachable', url: 'http://127.0.0.1:8100' };
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
async optionalDevice(args = {}) {
|
|
755
|
+
const xcodeArgs = ['-project', prepared.projectPath, '-scheme', 'WebDriverAgentRunner', '-sdk', 'iphoneos', '-destination', `id=${device.udid}`,
|
|
756
|
+
'-derivedDataPath', path.join(directory, 'build'), `DEVELOPMENT_TEAM=${teamId}`, `PRODUCT_BUNDLE_IDENTIFIER=${wdaTestBundleId}`,
|
|
757
|
+
'ENABLE_DEFAULT_HEADER_SEARCH_PATHS=NO', '-allowProvisioningUpdates'];
|
|
758
|
+
// Compile/sign on the Host before starting any remote test operation.
|
|
759
|
+
// A local compiler failure or cancellation cannot leave a phone task unknown.
|
|
760
|
+
const buildLogFile = path.join(directory, 'xcodebuild-build.log');
|
|
761
|
+
const build = spawnWdaProcess(ctx.xcodebuild, [...xcodeArgs, 'build-for-testing'], buildLogFile, false);
|
|
762
|
+
try {
|
|
763
|
+
while (!build.terminal) { checkExecution(); await executionSleep(100); }
|
|
764
|
+
if (build.spawnError || build.exitCode !== 0) return {
|
|
765
|
+
ok: false, error: build.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_build_failed',
|
|
766
|
+
message: build.spawnError?.message ?? 'WDA compilation/signing failed before device test execution.',
|
|
767
|
+
phase: 'build', exitCode: build.exitCode, logFile: buildLogFile, prepared,
|
|
768
|
+
};
|
|
769
|
+
} finally { await build.stop(); }
|
|
770
|
+
const logFile = path.join(directory, 'xcodebuild.log');
|
|
771
|
+
const runtime = spawnWdaProcess(ctx.xcodebuild, [...xcodeArgs, 'test-without-building'], logFile, true);
|
|
772
|
+
const child = runtime.child;
|
|
773
|
+
let ready = false;
|
|
787
774
|
try {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
775
|
+
while (!runtime.terminal) {
|
|
776
|
+
checkExecution();
|
|
777
|
+
const status = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
|
|
778
|
+
if (status.ok) {
|
|
779
|
+
ready = true; child.unref();
|
|
780
|
+
return { ok: true, device, wdaTestBundleId, wdaRunnerBundleId, pid: child.pid, logFile, buildLogFile, prepared, status };
|
|
781
|
+
}
|
|
782
|
+
await executionSleep(1000);
|
|
783
|
+
}
|
|
784
|
+
return { ok: false, error: runtime.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_xcodebuild_exited',
|
|
785
|
+
message: runtime.spawnError?.message ?? 'xcodebuild closed before the selected Runner published a bound endpoint.',
|
|
786
|
+
phase: 'device-test', exitCode: runtime.exitCode, logFile, buildLogFile, prepared };
|
|
787
|
+
} finally {
|
|
788
|
+
if (!ready) await runtime.stop();
|
|
791
789
|
}
|
|
792
790
|
}
|
|
793
791
|
|
|
@@ -811,7 +809,7 @@ class IOSBridgeProvider {
|
|
|
811
809
|
}
|
|
812
810
|
}
|
|
813
811
|
|
|
814
|
-
async devicectlJson(ctx, args) {
|
|
812
|
+
async devicectlJson(ctx, args, { mutation = false } = {}) {
|
|
815
813
|
const jsonPath = path.join(os.tmpdir(), `ai-app-bridge-devicectl-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
816
814
|
const allArgs = [
|
|
817
815
|
...devicectlPrefix(ctx.devicectl),
|
|
@@ -823,7 +821,17 @@ class IOSBridgeProvider {
|
|
|
823
821
|
];
|
|
824
822
|
try {
|
|
825
823
|
const command = devicectlBinary(ctx.devicectl);
|
|
826
|
-
await execFileText(this.execFile, command, allArgs, { timeoutMs: (ctx.deviceTimeoutSec * 1000) + 5000 });
|
|
824
|
+
try { await execFileText(this.execFile, command, allArgs, { timeoutMs: (ctx.deviceTimeoutSec * 1000) + 5000, mutation }); }
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (mutation) {
|
|
827
|
+
let reply;
|
|
828
|
+
try { reply = JSON.parse(await fs.promises.readFile(jsonPath, 'utf8')); } catch { /* No complete original reply: retain unknown ownership. */ }
|
|
829
|
+
if (reply) error.devicectlReply = reply;
|
|
830
|
+
const outcome = deviceCommandRejection(reply, allArgs.slice(devicectlPrefix(ctx.devicectl).length), error);
|
|
831
|
+
if (outcome) error.deviceOutcome = outcome;
|
|
832
|
+
}
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
827
835
|
return JSON.parse(await fs.promises.readFile(jsonPath, 'utf8'));
|
|
828
836
|
} finally {
|
|
829
837
|
await fs.promises.rm(jsonPath, { force: true });
|
|
@@ -831,6 +839,32 @@ class IOSBridgeProvider {
|
|
|
831
839
|
}
|
|
832
840
|
}
|
|
833
841
|
|
|
842
|
+
function spawnWdaProcess(executable, args, logFile, mutation) {
|
|
843
|
+
checkExecution();
|
|
844
|
+
const out = fs.openSync(logFile, 'a');
|
|
845
|
+
let child;
|
|
846
|
+
try {
|
|
847
|
+
child = spawn(executable, args, { detached: true, stdio: ['ignore', out, out], windowsHide: true, env: wdaBuildEnvironment() });
|
|
848
|
+
if (mutation) child.once('spawn', () => markExecutionDispatched());
|
|
849
|
+
} finally { fs.closeSync(out); }
|
|
850
|
+
const state = { child, terminal: false, spawnError: null, exitCode: null };
|
|
851
|
+
const closed = new Promise(resolve => {
|
|
852
|
+
child.once('error', error => { state.spawnError = error; });
|
|
853
|
+
child.once('close', code => { state.terminal = true; state.exitCode = code; resolve(); });
|
|
854
|
+
});
|
|
855
|
+
state.stop = async () => {
|
|
856
|
+
if (state.terminal) return;
|
|
857
|
+
const signalGroup = signal => {
|
|
858
|
+
if (child.pid === undefined) return;
|
|
859
|
+
try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
860
|
+
};
|
|
861
|
+
signalGroup('SIGTERM');
|
|
862
|
+
const escalation = setTimeout(() => signalGroup('SIGKILL'), 1500);
|
|
863
|
+
try { await closed; } finally { clearTimeout(escalation); }
|
|
864
|
+
};
|
|
865
|
+
return state;
|
|
866
|
+
}
|
|
867
|
+
|
|
834
868
|
function parseDevicectlDevices(payload) {
|
|
835
869
|
if (Array.isArray(payload?.result?.devices)) return payload.result.devices;
|
|
836
870
|
if (Array.isArray(payload?.devices)) return payload.devices;
|
|
@@ -891,92 +925,18 @@ function devicectlPrefix(value) {
|
|
|
891
925
|
return path.basename(String(value || 'xcrun')) === 'xcrun' ? ['devicectl'] : [];
|
|
892
926
|
}
|
|
893
927
|
|
|
894
|
-
function runtimeHostCandidates(explicitHost, device) {
|
|
895
|
-
const hosts = [];
|
|
896
|
-
if (explicitHost) hosts.push(String(explicitHost));
|
|
897
|
-
if (device?.tunnelIPAddress) hosts.push(device.tunnelIPAddress);
|
|
898
|
-
if (Array.isArray(device?.potentialHostnames)) hosts.push(...device.potentialHostnames);
|
|
899
|
-
return [...new Set(hosts.filter(Boolean))];
|
|
900
|
-
}
|
|
901
|
-
|
|
902
928
|
function formatHostForUrl(host) {
|
|
903
929
|
const value = String(host || '').trim();
|
|
904
930
|
if (value.includes(':') && !value.startsWith('[')) return `[${value}]`;
|
|
905
931
|
return value;
|
|
906
932
|
}
|
|
907
933
|
|
|
908
|
-
function wdaBaseUrl(args = {}) {
|
|
909
|
-
return stripTrailingSlash(String(args.wdaUrl || process.env.AI_APP_BRIDGE_WDA_URL || 'http://127.0.0.1:8100'));
|
|
910
|
-
}
|
|
911
|
-
|
|
912
934
|
function stripTrailingSlash(value) {
|
|
913
935
|
return String(value).replace(/\/+$/, '');
|
|
914
936
|
}
|
|
915
937
|
|
|
916
|
-
function wdaSessionIdFromResponse(response) {
|
|
917
|
-
return response?.sessionId
|
|
918
|
-
|| response?.value?.sessionId
|
|
919
|
-
|| response?.value?.session_id
|
|
920
|
-
|| response?.value?.capabilities?.sessionId
|
|
921
|
-
|| '';
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
function wdaElementIdFromResponse(response) {
|
|
925
|
-
return response?.value?.ELEMENT
|
|
926
|
-
|| response?.value?.['element-6066-11e4-a52e-4f735466cecf']
|
|
927
|
-
|| response?.ELEMENT
|
|
928
|
-
|| response?.['element-6066-11e4-a52e-4f735466cecf']
|
|
929
|
-
|| '';
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
function parseWdaServerUrlFromLog(logFile) {
|
|
933
|
-
if (!logFile) return '';
|
|
934
|
-
try {
|
|
935
|
-
const text = fs.readFileSync(logFile, 'utf8');
|
|
936
|
-
const matches = [...text.matchAll(/ServerURLHere->(http:\/\/[^<\s]+)<-ServerURLHere/g)];
|
|
937
|
-
return matches.length ? matches[matches.length - 1][1] : '';
|
|
938
|
-
} catch (_) {
|
|
939
|
-
return '';
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function pointerAction(x, y) {
|
|
944
|
-
return {
|
|
945
|
-
actions: [{
|
|
946
|
-
type: 'pointer',
|
|
947
|
-
id: 'finger1',
|
|
948
|
-
parameters: { pointerType: 'touch' },
|
|
949
|
-
actions: [
|
|
950
|
-
{ type: 'pointerMove', duration: 0, origin: 'viewport', x, y },
|
|
951
|
-
{ type: 'pointerDown', button: 0 },
|
|
952
|
-
{ type: 'pause', duration: 60 },
|
|
953
|
-
{ type: 'pointerUp', button: 0 },
|
|
954
|
-
],
|
|
955
|
-
}],
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
function swipeAction(startX, startY, endX, endY, durationMs) {
|
|
960
|
-
return {
|
|
961
|
-
actions: [{
|
|
962
|
-
type: 'pointer',
|
|
963
|
-
id: 'finger1',
|
|
964
|
-
parameters: { pointerType: 'touch' },
|
|
965
|
-
actions: [
|
|
966
|
-
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: startX, y: startY },
|
|
967
|
-
{ type: 'pointerDown', button: 0 },
|
|
968
|
-
{ type: 'pause', duration: 80 },
|
|
969
|
-
{ type: 'pointerMove', duration: durationMs, origin: 'viewport', x: endX, y: endY },
|
|
970
|
-
{ type: 'pointerUp', button: 0 },
|
|
971
|
-
],
|
|
972
|
-
}],
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
|
|
976
938
|
function parsePayload(args = {}) {
|
|
977
|
-
|
|
978
|
-
if (args.payload) return JSON.parse(String(args.payload));
|
|
979
|
-
return { action: requiredString(args.action, 'action') };
|
|
939
|
+
return args.payload;
|
|
980
940
|
}
|
|
981
941
|
|
|
982
942
|
function captureQuery(args = {}) {
|
|
@@ -984,6 +944,12 @@ function captureQuery(args = {}) {
|
|
|
984
944
|
sinceId: args.sinceId,
|
|
985
945
|
sinceMs: args.sinceMs,
|
|
986
946
|
limit: args.limit,
|
|
947
|
+
view: args.view ?? (args.history === true || args.history === 'true' ? 'connected-history' : undefined),
|
|
948
|
+
runtimeEpoch: args.runtimeEpoch,
|
|
949
|
+
afterActionId: args.afterActionId,
|
|
950
|
+
factCursor: args.factCursor,
|
|
951
|
+
mobileFactId: args.mobileFactId,
|
|
952
|
+
targetKey: args.targetKey,
|
|
987
953
|
};
|
|
988
954
|
}
|
|
989
955
|
|
|
@@ -998,13 +964,14 @@ function withQuery(endpointPath, query) {
|
|
|
998
964
|
function iosSetupSuggestion(device, runtime, wda) {
|
|
999
965
|
if (!device) return 'Connect one iPhone, trust this Mac on the device, then rerun ios-doctor.';
|
|
1000
966
|
if (device.developerModeStatus !== 'enabled') return 'Enable Developer Mode on the iPhone and rerun ios-setup.';
|
|
1001
|
-
if (device.ddiServicesAvailable
|
|
1002
|
-
if (wda?.ok !== true) return 'Start
|
|
1003
|
-
if (runtime?.ok !== true) return 'Launch a debug
|
|
967
|
+
if (device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') return 'Unlock/trust the iPhone and let Xcode finish preparing a connected developer tunnel.';
|
|
968
|
+
if (wda?.ok !== true) return 'Start the prepared Runner with ios-setup --start-wda --team-id, or supply its exact wdaRunnerBundleId. An optional wdaUrl still requires container binding.';
|
|
969
|
+
if (runtime?.ok !== true) return 'Launch a debug App with AiAppBridgeIOS and supply its exact deviceId and bundleId.';
|
|
1004
970
|
return 'Rerun ios-setup after resolving the failing check.';
|
|
1005
971
|
}
|
|
1006
972
|
|
|
1007
973
|
function iosErrorCode(command, error) {
|
|
974
|
+
if (typeof error?.code === 'string' && /^[a-z][a-z0-9_]*$/.test(error.code)) return error.code;
|
|
1008
975
|
const stderr = String(error?.stderr || '');
|
|
1009
976
|
const stdout = String(error?.stdout || '');
|
|
1010
977
|
const message = String(error?.message || error || '');
|
|
@@ -1042,91 +1009,37 @@ function truncateText(value, maxChars) {
|
|
|
1042
1009
|
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}...`;
|
|
1043
1010
|
}
|
|
1044
1011
|
|
|
1045
|
-
function
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
function requestJson(method, rawUrl, body, options = {}) {
|
|
1058
|
-
const url = new URL(rawUrl);
|
|
1059
|
-
const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body), 'utf8');
|
|
1060
|
-
return new Promise((resolve, reject) => {
|
|
1061
|
-
const request = http.request({
|
|
1062
|
-
method,
|
|
1063
|
-
protocol: url.protocol,
|
|
1064
|
-
hostname: url.hostname.replace(/^\[|\]$/g, ''),
|
|
1065
|
-
port: url.port,
|
|
1066
|
-
path: `${url.pathname}${url.search}`,
|
|
1067
|
-
timeout: options.timeoutMs || defaultHttpTimeoutMs,
|
|
1068
|
-
headers: {
|
|
1069
|
-
Accept: 'application/json',
|
|
1070
|
-
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}),
|
|
1071
|
-
},
|
|
1072
|
-
}, (response) => {
|
|
1073
|
-
let text = '';
|
|
1074
|
-
response.setEncoding('utf8');
|
|
1075
|
-
response.on('data', (chunk) => { text += chunk; });
|
|
1076
|
-
response.on('end', () => {
|
|
1077
|
-
let parsed = null;
|
|
1078
|
-
try {
|
|
1079
|
-
parsed = text ? JSON.parse(text) : {};
|
|
1080
|
-
} catch (_) {
|
|
1081
|
-
parsed = { raw: text };
|
|
1082
|
-
}
|
|
1083
|
-
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
1084
|
-
const error = new Error(`HTTP ${response.statusCode}: ${text.slice(0, 500)}`);
|
|
1085
|
-
error.response = parsed;
|
|
1086
|
-
reject(error);
|
|
1087
|
-
return;
|
|
1088
|
-
}
|
|
1089
|
-
resolve(parsed);
|
|
1090
|
-
});
|
|
1091
|
-
});
|
|
1092
|
-
request.on('timeout', () => {
|
|
1093
|
-
request.destroy(new Error(`HTTP timeout: ${rawUrl}`));
|
|
1094
|
-
});
|
|
1095
|
-
request.on('error', reject);
|
|
1096
|
-
if (payload) request.write(payload);
|
|
1097
|
-
request.end();
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
function execFileText(execFileImpl, command, args, options = {}) {
|
|
1102
|
-
return new Promise((resolve, reject) => {
|
|
1103
|
-
execFileImpl(command, args, {
|
|
1104
|
-
encoding: 'utf8',
|
|
1105
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
1106
|
-
timeout: options.timeoutMs || 30000,
|
|
1107
|
-
windowsHide: true,
|
|
1108
|
-
}, (error, stdout, stderr) => {
|
|
1109
|
-
if (error) {
|
|
1110
|
-
error.message = `${error.message}${stderr ? `\n${stderr}` : ''}`;
|
|
1111
|
-
reject(error);
|
|
1112
|
-
return;
|
|
1012
|
+
async function requestJson(method, rawUrl, body, options = {}) {
|
|
1013
|
+
return runExecution({ mutation: options.mutation ?? method !== 'GET' }, async () => {
|
|
1014
|
+
let text;
|
|
1015
|
+
try {
|
|
1016
|
+
text = await httpRequestBounded(rawUrl, { method, payload: body === null ? undefined : body,
|
|
1017
|
+
headers: { Accept: 'application/json', ...options.headers }, timeoutMs: options.timeoutMs ?? defaultHttpTimeoutMs,
|
|
1018
|
+
maxBytes: 8 * 1024 * 1024 });
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
if (error.responseBody) {
|
|
1021
|
+
try { error.response = JSON.parse(error.responseBody); } catch { /* preserve the transport failure */ }
|
|
1113
1022
|
}
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1023
|
+
throw error;
|
|
1024
|
+
}
|
|
1025
|
+
let parsed;
|
|
1026
|
+
try { parsed = JSON.parse(text); }
|
|
1027
|
+
catch { throw bindingFailure('invalid_ios_json_response', 'The iOS endpoint returned invalid JSON.'); }
|
|
1028
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1029
|
+
throw bindingFailure('invalid_ios_json_response', 'The iOS endpoint must return a JSON object.');
|
|
1030
|
+
}
|
|
1031
|
+
return parsed;
|
|
1116
1032
|
});
|
|
1117
1033
|
}
|
|
1118
1034
|
|
|
1119
|
-
function
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
if (
|
|
1125
|
-
|
|
1126
|
-
if (found) return found;
|
|
1127
|
-
}
|
|
1035
|
+
async function execFileText(execFileImpl, command, args, options = {}) {
|
|
1036
|
+
try {
|
|
1037
|
+
return await execFileBounded(command, args, { execFileImpl, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
|
|
1038
|
+
timeoutMs: options.timeoutMs ?? 30000, mutation: options.mutation === true, windowsHide: true });
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
if (error.stderr) error.message += `\n${error.stderr}`;
|
|
1041
|
+
throw error;
|
|
1128
1042
|
}
|
|
1129
|
-
return '';
|
|
1130
1043
|
}
|
|
1131
1044
|
|
|
1132
1045
|
function normalizedObjectEnum(value) {
|
|
@@ -1141,6 +1054,11 @@ function requiredString(value, name) {
|
|
|
1141
1054
|
return value;
|
|
1142
1055
|
}
|
|
1143
1056
|
|
|
1057
|
+
function requiredInputString(value, name) {
|
|
1058
|
+
if (typeof value !== 'string') throw new Error(`${name} is required`);
|
|
1059
|
+
return value;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1144
1062
|
function requiredNumber(value, name) {
|
|
1145
1063
|
const number = Number(value);
|
|
1146
1064
|
if (!Number.isFinite(number)) throw new Error(`${name} is required`);
|
|
@@ -1161,15 +1079,11 @@ function booleanArg(value) {
|
|
|
1161
1079
|
return value === true || value === 'true' || value === '1' || value === 1;
|
|
1162
1080
|
}
|
|
1163
1081
|
|
|
1164
|
-
function sleep(ms) {
|
|
1165
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
1082
|
module.exports = {
|
|
1169
1083
|
IOSBridgeProvider,
|
|
1170
1084
|
formatHostForUrl,
|
|
1171
1085
|
parseDevicectlDevices,
|
|
1172
1086
|
selectDeviceFromList,
|
|
1173
1087
|
shapeDevice,
|
|
1174
|
-
|
|
1088
|
+
requiredInputString,
|
|
1175
1089
|
};
|