@mobileaidev/ai-app-bridge 0.3.4 → 0.3.6
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 -3
- package/bin/android-permissions.js +39 -5
- package/bin/command-discovery.js +8 -2
- package/bin/command-registry.js +26 -9
- package/bin/device-provider.js +11 -2
- package/bin/execution-host.js +18 -0
- package/bin/executors/android-host.js +201 -0
- package/bin/executors/android-port.js +114 -0
- package/bin/executors/automation-owner.js +42 -0
- package/bin/executors/command-schema.js +102 -0
- package/bin/executors/flutter-host.js +123 -0
- package/bin/executors/managed-runtime.js +85 -0
- package/bin/executors/playwright-host.js +146 -0
- package/bin/executors/playwright-worker.js +307 -0
- package/bin/executors/receipt-journal.js +56 -0
- package/bin/feedback-probe.js +27 -1
- package/bin/ios-device-outcome.js +17 -0
- package/bin/ios-execution.js +13 -1
- package/bin/ios-provider.js +74 -24
- package/bin/ios-runtime-binding.js +1 -1
- package/bin/ios-wda-startup.js +68 -0
- package/bin/runtime-directory.js +1 -1
- package/bin/shared-kernel/device-ownership-recovery.js +13 -0
- package/bin/shared-kernel/native-target.js +11 -8
- package/bin/shared-kernel/uia-protocol.js +1 -1
- package/bin/shared-kernel/uia-runtime-port.js +38 -2
- package/bin/ui-observation.js +29 -0
- package/bin/web-provider.js +6 -1
- package/docs/COMMAND_CONTRACT.md +73 -2
- package/docs/INTENT_FOREGROUND.md +1 -1
- package/docs/OPTIONAL_EXECUTORS.md +182 -0
- package/docs/RELEASE.md +36 -14
- package/docs/SCRIPT_AUTHORING.md +7 -0
- package/package.json +6 -1
- package/runtime/executors/playwright/package-lock.json +45 -0
- package/runtime/executors/playwright/package.json +8 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +9 -8
package/README.md
CHANGED
|
@@ -7,12 +7,16 @@ discovery. Every request checks the mapping before dispatch. Mutating requests
|
|
|
7
7
|
are never replayed after a missing route or uncertain result. For manual cleanup,
|
|
8
8
|
pass the exact serial and returned Host port to `remove-forward`.
|
|
9
9
|
|
|
10
|
-
This
|
|
10
|
+
This source version is `0.3.6`; registry publication is a separate release step.
|
|
11
11
|
The default installation includes the Script/Intent and capture contracts below.
|
|
12
|
-
|
|
12
|
+
Local package verification does not change npm dist-tags.
|
|
13
13
|
The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
|
|
14
14
|
See [the release guide](docs/RELEASE.md) for local packaging and coordinated publication.
|
|
15
15
|
|
|
16
|
+
Optional Android Instrumentation/UI Automator/Espresso/Compose/H5, Flutter integration_test,
|
|
17
|
+
and Playwright executors use the existing `capabilities + run` and Python/JS Script contracts.
|
|
18
|
+
See [integration, dependency profiles and lifecycle](docs/OPTIONAL_EXECUTORS.md).
|
|
19
|
+
|
|
16
20
|
AI App Bridge CLI/MCP supports Android native apps, Android WebView/H5/CDP,
|
|
17
21
|
Flutter apps on Android and iOS, iOS native apps via `AiAppBridgeIOS` plus
|
|
18
22
|
WebDriverAgent/XCUITest, WKWebView, and desktop Web Bridge sessions.
|
|
@@ -56,7 +60,7 @@ domains, commands, and options, then call `run` with the selected command.
|
|
|
56
60
|
|
|
57
61
|
```bash
|
|
58
62
|
# Install the current stable release; see docs/RELEASE.md for packaging.
|
|
59
|
-
npm install -g @mobileaidev/ai-app-bridge@0.3.
|
|
63
|
+
npm install -g @mobileaidev/ai-app-bridge@0.3.6
|
|
60
64
|
|
|
61
65
|
ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
|
|
62
66
|
ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
|
|
@@ -42,7 +42,7 @@ function uniqueBlock(lines, predicate, error) {
|
|
|
42
42
|
return { header: lines[indexes[0]].trim(), lines: block(lines, indexes[0]) };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function parsePermissionState(text, { packageName, permission, userId }) {
|
|
45
|
+
function parsePermissionState(text, { packageName, permission, userId }, definitions) {
|
|
46
46
|
const pkg = uniqueBlock(text.split(/\r?\n/), line => line.startsWith(`Package [${packageName}] (`), 'permission_package_not_found');
|
|
47
47
|
const appId = pkg.lines.map(line => /^\s+(?:appId|userId)=(\d+)\s*$/.exec(line)).filter(Boolean);
|
|
48
48
|
if (appId.length !== 1 || Number(appId[0][1]) >= 100000) throw new CommandError('permission_state_unsupported', 'Android package appId is unavailable or ambiguous.');
|
|
@@ -50,10 +50,27 @@ function parsePermissionState(text, { packageName, permission, userId }) {
|
|
|
50
50
|
if (!/\binstalled=true\b/.test(user.header)) throw new CommandError('permission_package_not_installed', 'The package is not installed for the requested Android user.');
|
|
51
51
|
const runtime = uniqueBlock(user.lines, line => line === 'runtime permissions:', 'runtime_permission_not_found');
|
|
52
52
|
const matches = runtime.lines.filter(line => line.trim().startsWith(`${permission}:`));
|
|
53
|
-
|
|
53
|
+
if (!matches.length && definitions !== undefined) {
|
|
54
|
+
// Android 7 omits untouched runtime permissions. Establish that the app
|
|
55
|
+
// requests a runtime permission and has no install grant before interpreting
|
|
56
|
+
// the absent PackageManager state as denied with no flags.
|
|
57
|
+
const requested = uniqueBlock(pkg.lines, line => line === 'requested permissions:', 'runtime_permission_not_found');
|
|
58
|
+
const targetSdk = pkg.lines.flatMap(line => [...line.matchAll(/\btargetSdk=(\d+)\b/g)]);
|
|
59
|
+
const definition = uniqueBlock(definitions.split(/\r?\n/), line => line.startsWith(`Permission [${permission}] (`), 'runtime_permission_not_found');
|
|
60
|
+
const protection = definition.lines.flatMap(line => [...line.matchAll(/\bprot=([^\s]+)/g)]);
|
|
61
|
+
if (targetSdk.length === 1 && Number(targetSdk[0][1]) >= 23
|
|
62
|
+
&& requested.lines.some(line => line.trim() === permission)
|
|
63
|
+
&& protection.length === 1 && protection[0][1].split('|')[0] === 'dangerous'
|
|
64
|
+
&& !pkg.lines.some(line => line.trim().startsWith(`${permission}:`))) {
|
|
65
|
+
return { packageName, permission, userId, uid: userId * 100000 + Number(appId[0][1]), granted: false, flags: [] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Android 7's Settings.permissionFlagsToString omits the entire suffix when flags == 0.
|
|
69
|
+
const match = matches.length === 1 && /^\s*[^:]+:\s+granted=(true|false)(?:,\s*flags=\[([^\]]*)\])?\s*$/.exec(matches[0]);
|
|
54
70
|
if (!matches.length) throw new CommandError('runtime_permission_not_found', 'The requested permission has no runtime permission record for this package and user.');
|
|
55
71
|
if (!match) throw new CommandError('permission_state_unsupported', 'Android returned an unrecognized runtime permission record.');
|
|
56
|
-
|
|
72
|
+
// Nougat separates flags with spaces; newer dumps use pipes.
|
|
73
|
+
const flags = match[2] !== undefined && match[2].trim() ? match[2].trim().split(/\s*\|\s*|\s+/) : [];
|
|
57
74
|
if (flags.some(value => !/^[A-Z][A-Z0-9_]*$/.test(value)) || new Set(flags).size !== flags.length) {
|
|
58
75
|
throw new CommandError('permission_state_unsupported', 'Android returned unrecognized permission flags.');
|
|
59
76
|
}
|
|
@@ -70,8 +87,15 @@ async function readPermissionState(args, run = execute) {
|
|
|
70
87
|
userId = Number(current);
|
|
71
88
|
}
|
|
72
89
|
const dump = await androidCommand(args, ['shell', 'dumpsys', 'package', args.packageName], run);
|
|
90
|
+
let state;
|
|
91
|
+
try { state = parsePermissionState(dump.stdout, { ...args, userId }); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code !== 'runtime_permission_not_found') throw error;
|
|
94
|
+
const definitions = await androidCommand(args, ['shell', 'dumpsys', 'package', 'permissions'], run);
|
|
95
|
+
state = parsePermissionState(dump.stdout, { ...args, userId }, definitions.stdout);
|
|
96
|
+
}
|
|
73
97
|
return { ok: true, source: 'android-package-manager', serial: args.serial,
|
|
74
|
-
...
|
|
98
|
+
...state, capturedAtMs: Date.now() };
|
|
75
99
|
} catch (error) {
|
|
76
100
|
if (error instanceof CommandError) throw error;
|
|
77
101
|
throw new CommandError('permission_query_failed', 'Android permission state could not be read.', { details: { cause: error.code || null } });
|
|
@@ -125,7 +149,17 @@ function activityIdentity(value) {
|
|
|
125
149
|
// the actual dialog, and PackageManager independently verifies the named permission.
|
|
126
150
|
function parsePermissionRequest(text) {
|
|
127
151
|
const lines = text.split(/\r?\n/);
|
|
128
|
-
|
|
152
|
+
let tops = lines.filter(line => /^\s*topResumedActivity=/.test(line)).map(activityIdentity);
|
|
153
|
+
if (!tops.length) {
|
|
154
|
+
// Before multi-resume, ActivityManager records one focused Activity and
|
|
155
|
+
// each stack's resumed Activity. Both identities must agree.
|
|
156
|
+
tops = lines.filter(line => /^\s*mFocusedActivity:/.test(line)).map(activityIdentity);
|
|
157
|
+
const resumed = lines.filter(line => /^\s*mResumedActivity:/.test(line)).map(activityIdentity);
|
|
158
|
+
if (tops.length !== 1 || !tops[0] || !resumed.some(value => value && value.token === tops[0].token
|
|
159
|
+
&& value.component === tops[0].component && value.userId === tops[0].userId)) {
|
|
160
|
+
throw new CommandError('permission_request_unsupported', 'The focused and resumed Android Activity identities did not agree.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
129
163
|
if (tops.length !== 1 || !tops[0]) throw new CommandError('permission_request_unsupported', 'One top-resumed Android Activity could not be identified.');
|
|
130
164
|
const records = lines.flatMap((line, i) => /^\s*\* Hist\s+#\d+: ActivityRecord\{/.test(line) ? [{ ...activityIdentity(line), lines: block(lines, i) }] : []);
|
|
131
165
|
const requests = records.filter(record => record.lines.some(line => line.trim().startsWith('Intent {') && line.includes(`act=${REQUEST_PERMISSIONS} `)));
|
package/bin/command-discovery.js
CHANGED
|
@@ -80,12 +80,18 @@ function commandInputSchema(command, filters = {}) {
|
|
|
80
80
|
}
|
|
81
81
|
const schema = commandSchema(command);
|
|
82
82
|
if (!Object.keys(filters).length) return schema;
|
|
83
|
+
if (require('./ui-observation').commands.has(command) && Object.keys(filters).every(key => key === 'operation')) {
|
|
84
|
+
const branch = schema.oneOf.find(entry => entry.properties.operation.const === filters.operation);
|
|
85
|
+
if (!branch) throw new CommandError('invalid_argument', 'Unknown UI observation operation.', { field: 'operation' });
|
|
86
|
+
const { oneOf, ...base } = schema;
|
|
87
|
+
return { ...base, properties: { ...base.properties, ...branch.properties }, required: [...base.required, ...(branch.required || [])] };
|
|
88
|
+
}
|
|
83
89
|
const scope = ['platform', 'provider', 'action'].find(key => Object.hasOwn(filters, key));
|
|
84
90
|
if (scope && (command !== 'intent' || filters.operation !== 'decide')) {
|
|
85
91
|
throw new CommandError('invalid_argument', `${scope} is available only with command=intent and operation=decide.`, { field: scope });
|
|
86
92
|
}
|
|
87
|
-
if (!['intent', 'script', 'evidence'].includes(command)) {
|
|
88
|
-
throw new CommandError('invalid_argument', 'Operation schema selection supports intent, script and
|
|
93
|
+
if (!['intent', 'script', 'evidence', 'web-executor', 'android-executor', 'flutter-executor'].includes(command)) {
|
|
94
|
+
throw new CommandError('invalid_argument', 'Operation schema selection supports intent, script, evidence and executor commands.', { field: 'operation' });
|
|
89
95
|
}
|
|
90
96
|
const selected = schema.anyOf.filter(branch => branch.properties.operation.const === filters.operation);
|
|
91
97
|
if (!selected.length) throw new CommandError('invalid_argument', `Unknown ${command} operation: ${filters.operation}.`, { field: 'operation' });
|
package/bin/command-registry.js
CHANGED
|
@@ -8,9 +8,15 @@ const { executionCommandSchema, nativeGestureSchema, nativeSelector, flutterSele
|
|
|
8
8
|
const { flutterActionSchema, webCommandSchema } = require('./shared-kernel/provider-command-contracts');
|
|
9
9
|
|
|
10
10
|
const commandDefinitions = [
|
|
11
|
+
{ command: 'ui-observation', domain: 'core', summary: 'Start a bounded UI observation window (100–5000 ms), inspect it, or stop its lease. Off by default; provider selects native or Flutter. New windows establish a fresh baseline.', targetApp: true, options: ['operation', 'provider', 'durationMs', 'leaseId', 'packageName', 'serial'] },
|
|
12
|
+
{ command: 'ios-ui-observation', domain: 'ios', summary: 'Control bounded iOS native or Flutter UI observation. Off by default; expires locally even if the Host exits.', targetKind: 'ios-app', options: ['operation', 'provider', 'durationMs', 'leaseId', 'deviceId', 'bundleId'] },
|
|
13
|
+
{ command: 'web-ui-observation', domain: 'web', summary: 'Control bounded Web DOM change observation. Off by default; expires locally even if the Host exits.', targetKind: 'web-target', options: ['operation', 'durationMs', 'leaseId', 'sessionId', 'runtimeEpoch', 'targetId'] },
|
|
14
|
+
{ command: 'android-executor', domain: 'advanced', summary: 'Optional AndroidX UI Automator, Espresso and registered WebView/Compose test adapters. Open an installed androidTest build, observe, act on observed nodes, query original receipts and close. Opening instrumentation restarts the target application. Requires app.test in Script.', targetKind: 'android-app', options: ['operation'] },
|
|
15
|
+
{ command: 'flutter-executor', domain: 'advanced', summary: 'Optional Flutter integration_test executor on Android. Open the installed application test entrypoint, observe widgets, run WidgetTester actions, query receipts and close the test process. Requires app.test in Script.', targetKind: 'android-app', options: ['operation'] },
|
|
16
|
+
{ command: 'web-executor', domain: 'web', summary: 'Optional Playwright executor: inspect readiness, prepare pinned browser dependencies, open a browser, observe bound frames, act, wait, query original receipts, or close. Browser input and test operations retain their actual mechanisms. Script requires app.test.', targetKind: 'web-target', options: ['operation'] },
|
|
11
17
|
{ command: 'runtime', domain: 'execution', summary: 'Inspect, start or orderly stop the shared local execution runtime. CLI exit and MCP disconnect leave operations running; stop cancels and drains them.', targetKind: 'host-runtime', options: ['operation'] },
|
|
12
18
|
{ command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile original completion, explicitly cancel a retained install by actionId, or read a retained UIA receipt by serial/runtimeEpoch/actionId. Installation cancellation abandons its original PM session; it does not roll back an installed APK.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
|
|
13
|
-
{ command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API
|
|
19
|
+
{ command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API 25+ UIA node runtime. Start checks the phone process lock; unacknowledged original receipts are retained.', targetKind: 'android-device', options: ['operation', 'serial', 'adb', 'timeoutMs'] },
|
|
14
20
|
{ command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
|
|
15
21
|
{ command: 'tree', domain: 'core', summary: 'Read Android View tree from the in-app bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
|
|
16
22
|
{ command: 'uia-tree', domain: 'core', summary: 'Read UIAutomator XML for the current foreground window.', options: ['serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
|
|
@@ -70,7 +76,7 @@ const commandDefinitions = [
|
|
|
70
76
|
{ command: 'ios-devices', domain: 'ios', summary: 'List iOS devices known to xcrun devicectl.', targetKind: 'ios-device', options: ['deviceId'] },
|
|
71
77
|
{ command: 'ios-install-app', domain: 'ios', summary: 'Install an iOS .app bundle through devicectl.', targetKind: 'ios-app', options: ['deviceId', 'appPath'] },
|
|
72
78
|
{ command: 'ios-launch-app', domain: 'ios', summary: 'Launch an iOS app by bundle identifier through devicectl.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'terminateExisting'] },
|
|
73
|
-
{ command: 'ios-execution', domain: 'ios', summary: 'Read SDK or WDA execution/physical ownership, cancel an original action, read its durable completion, or reconcile unknown ownership without replay.', targetKind: 'ios-app', options: ['operation', 'deviceId', 'bundleId', 'kind', 'actionId', 'runtimeEpoch', 'runtimeUrl', 'iosHost', 'iosPort', 'wdaRunnerBundleId', 'wdaUrl', 'devicectl', 'timeoutMs'] },
|
|
79
|
+
{ command: 'ios-execution', domain: 'ios', summary: 'Read SDK or WDA execution/physical ownership, cancel an original action, read its durable completion, or reconcile unknown ownership without replay.', targetKind: 'ios-app', options: ['operation', 'deviceId', 'bundleId', 'kind', 'actionId', 'runtimeEpoch', 'runtimeUrl', 'iosHost', 'iosPort', 'wdaRunnerBundleId', 'wdaUrl', 'devicectl', 'timeoutMs', 'setupResultPath'] },
|
|
74
80
|
{ command: 'ios-status', domain: 'ios', summary: 'Read AiAppBridgeIOS runtime status.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
|
|
75
81
|
{ command: 'ios-tree', domain: 'ios', summary: 'Read UIKit tree from the AiAppBridgeIOS runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
|
|
76
82
|
{ command: 'ios-logs', domain: 'ios', summary: 'Read in-app iOS log records.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'sinceId', 'sinceMs', 'limit'] },
|
|
@@ -202,7 +208,7 @@ define('orientation', require('./shared-kernel/ios-native-target').orientationSc
|
|
|
202
208
|
define('selector', { type: 'string', minLength: 1 });
|
|
203
209
|
define('spec', { type: 'object', additionalProperties: true });
|
|
204
210
|
define('targetRef', require('./shared-kernel/uia-protocol').targetRefSchema);
|
|
205
|
-
define('adb serial packageName artifactDir deviceId bundleId iosHost runtimeUrl wdaUrl wdaSessionId devicectl xcodebuild host path token appPath apkPath activity component action data initialRoute outFile permission op mode targetText requireText absentText requireActivity resourceId textFilter resourceIdFilter classFilter urlFilter method pageUrlFilter socketName targetId sessionId accessibilityId elementId wdaProjectPath wdaBundleId teamId displayUniqueId tag level grep since factCursor cursor runtimeEpoch afterActionId mobileFactId targetKey requestId deviceLogScope logcatFormat format agentModule pythonPath goal intent operationId recordingDir namespace outputDir archiveDir manifestSha256 name reason provider adapter', { type: 'string', minLength: 1 });
|
|
211
|
+
define('adb serial packageName artifactDir deviceId bundleId iosHost runtimeUrl wdaUrl wdaSessionId devicectl xcodebuild host path token setupResultPath appPath apkPath activity component action data initialRoute outFile permission op mode targetText requireText absentText requireActivity resourceId textFilter resourceIdFilter classFilter urlFilter method pageUrlFilter socketName targetId sessionId accessibilityId elementId wdaProjectPath wdaBundleId teamId displayUniqueId tag level grep since factCursor cursor runtimeEpoch afterActionId mobileFactId targetKey requestId deviceLogScope logcatFormat format agentModule pythonPath goal intent operationId recordingDir namespace outputDir archiveDir manifestSha256 name reason provider adapter', { type: 'string', minLength: 1 });
|
|
206
212
|
define('text value script payload', { type: 'string' });
|
|
207
213
|
define('aaptPath apksignerPath', { type: 'string', minLength: 1 });
|
|
208
214
|
define('full compact visibleOnly clear follow appPid force hideKeyboard noAutoHideKeyboard allowDowngrade clearTask exact skipFlutterLaunch includeResponseBody keepForward noBodies startWda terminateExisting clearFirst refresh includeActions history includeRecordedPayloads grepCaseSensitive includeCatalog', { type: 'boolean' });
|
|
@@ -236,6 +242,9 @@ const commandByName = new Map(commandDefinitions.map(d => [d.command, d]));
|
|
|
236
242
|
const isolatedByName = new Map(isolatedCommandDefinitions.map(d => [d.command, d]));
|
|
237
243
|
|
|
238
244
|
function isMutationCommand(command, args = {}) {
|
|
245
|
+
if (require('./ui-observation').commands.has(command)) return args.operation !== 'status';
|
|
246
|
+
if (['android-executor', 'flutter-executor'].includes(command)) return ['open', 'act', 'close'].includes(args.operation);
|
|
247
|
+
if (command === 'web-executor') return ['prepare', 'open', 'act', 'navigate', 'close'].includes(args.operation);
|
|
239
248
|
if (command === 'web-command' && args.name === 'domSnapshot') return false;
|
|
240
249
|
return mutationCommands.has(command) || (command === 'logcat' && args.clear === true)
|
|
241
250
|
|| (command === 'device-ownership' && args.operation === 'cancel-install')
|
|
@@ -251,6 +260,8 @@ function isAndroidMutation(command, args = {}) {
|
|
|
251
260
|
|
|
252
261
|
function executionTimeoutMs(command, args = {}) {
|
|
253
262
|
if (args.timeoutMs !== undefined) return args.timeoutMs;
|
|
263
|
+
if (['android-executor', 'flutter-executor'].includes(command)) return args.operation === 'open' ? 60000 : 30000;
|
|
264
|
+
if (command === 'web-executor') return args.operation === 'prepare' ? 300000 : 30000;
|
|
254
265
|
if (iosSdkCommands.has(command) || command === 'ios-execution' || require('./ios-wda-port').commands.has(command)) return 30000;
|
|
255
266
|
if (command === 'ios-setup') return 300000;
|
|
256
267
|
if (command === 'ios-install-app') return 120000;
|
|
@@ -262,9 +273,10 @@ function executionTimeoutMs(command, args = {}) {
|
|
|
262
273
|
// These are execution capabilities, independent of MCP/CLI transport. Script
|
|
263
274
|
// permissions gate Bridge calls; trusted local code is not a process sandbox.
|
|
264
275
|
const scriptPermissions = Object.freeze({
|
|
276
|
+
'app.test': ['web-executor', 'android-executor', 'flutter-executor'],
|
|
265
277
|
'app.read': ['status', 'tree', 'uia-tree', 'screenshot', 'flutter-tree', 'flutter-nodes', 'h5-dom', 'flutter-h5-dom', 'keyboard-state', 'permission-state',
|
|
266
278
|
'ios-status', 'ios-tree', 'ios-uia-tree', 'ios-screenshot', 'ios-flutter-tree', 'ios-flutter-nodes', 'ios-h5-dom', 'ios-wda-status', 'web-status', 'web-dom'],
|
|
267
|
-
'capture.read': ['logs', 'network', 'state', 'events', 'logcat', 'webview-console', 'webview-network', 'ios-logs', 'ios-network', 'ios-state', 'ios-events', 'web-logs', 'web-network', 'web-state', 'web-events'],
|
|
279
|
+
'capture.read': ['ui-observation', 'ios-ui-observation', 'web-ui-observation', 'logs', 'network', 'state', 'events', 'logcat', 'webview-console', 'webview-network', 'ios-logs', 'ios-network', 'ios-state', 'ios-events', 'web-logs', 'web-network', 'web-state', 'web-events'],
|
|
268
280
|
'app.interact': ['launch-app', 'launch-activity', 'tap', 'tap-text', 'tap-uia-text', 'tap-uia', 'tap-native', 'input-text', 'swipe', 'native-gesture', 'keyevent', 'wait-text', 'hide-keyboard', 'tap-flutter', 'tap-flutter-text', 'input-flutter-text', 'scroll-flutter', 'h5-click', 'h5-input', 'h5-wait', 'h5-scroll', 'flutter-h5-click', 'flutter-h5-input', 'flutter-h5-wait', 'flutter-h5-scroll',
|
|
269
281
|
'ios-h5-click', 'ios-h5-input', 'ios-h5-scroll', 'ios-launch-app', 'ios-wda-session', 'ios-tap', 'ios-input', 'ios-swipe', 'ios-set-orientation', 'ios-tap-native', 'ios-input-native-text', 'ios-tap-flutter', 'ios-input-flutter-text', 'ios-scroll-flutter', 'ios-flutter-back', 'ios-flutter-hide-keyboard', 'web-click', 'web-input', 'web-key', 'web-scroll', 'web-wait'],
|
|
270
282
|
'app.lifecycle': ['clear-app-data'],
|
|
@@ -294,7 +306,7 @@ function commandContract(command) {
|
|
|
294
306
|
providersByPlatform: { android: ['native', 'uia', 'flutter', 'h5'], ios: ['native', 'h5', 'flutter'], web: ['h5'] } } : {}),
|
|
295
307
|
execution: { kind: role === 'execution' ? 'operation' : isMutationCommand(command) ? 'mutation' : 'query',
|
|
296
308
|
mutation: isMutationCommand(command),
|
|
297
|
-
conditionalMutation: command === 'uia-runtime' ? 'operation != status' : command === 'logcat' ? 'clear=true' : ['webview-network', 'webview-console'].includes(command) ? 'script is supplied' : null,
|
|
309
|
+
conditionalMutation: command === 'web-executor' ? 'operation in prepare,open,act,navigate,close' : ['android-executor', 'flutter-executor'].includes(command) ? 'operation in open,act,close' : command === 'uia-runtime' ? 'operation != status' : command === 'logcat' ? 'clear=true' : ['webview-network', 'webview-console'].includes(command) ? 'script is supplied' : null,
|
|
298
310
|
arbitration: command === 'script' || command === 'intent' ? 'target-platform-physical-device'
|
|
299
311
|
: isAndroidMutation(command) ? 'cross-process-physical-android-device'
|
|
300
312
|
: platform === 'ios' && isMutationCommand(command) ? 'cross-process-physical-ios-device'
|
|
@@ -305,6 +317,10 @@ function commandContract(command) {
|
|
|
305
317
|
function commandSchema(command) {
|
|
306
318
|
const definition = commandByName.get(command) || isolatedByName.get(command);
|
|
307
319
|
if (!definition) throw new CommandError('unknown_command', `Unknown command: ${command}`, { field: 'command' });
|
|
320
|
+
if (require('./ui-observation').commands.has(command)) return require('./ui-observation').schema(command, optionTypes);
|
|
321
|
+
if (command === 'web-executor') return require('./executors/command-schema').webExecutorSchema();
|
|
322
|
+
if (command === 'android-executor') return require('./executors/command-schema').androidExecutorSchema();
|
|
323
|
+
if (command === 'flutter-executor') return require('./executors/command-schema').flutterExecutorSchema();
|
|
308
324
|
if (command === 'runtime') return { type: 'object', additionalProperties: false, required: ['operation'],
|
|
309
325
|
properties: { operation: { enum: ['start', 'status', 'stop'] } } };
|
|
310
326
|
if (definition.domain === 'web') return require('./web/command-schema').webSchema(command);
|
|
@@ -355,14 +371,15 @@ function commandSchema(command) {
|
|
|
355
371
|
actionId: { type: 'string', minLength: 1, maxLength: 256 }, runtimeEpoch: optionTypes.runtimeEpoch,
|
|
356
372
|
wdaRunnerBundleId: optionTypes.wdaRunnerBundleId, wdaUrl: optionTypes.wdaUrl,
|
|
357
373
|
runtimeUrl: optionTypes.runtimeUrl, iosHost: optionTypes.iosHost, iosPort: optionTypes.iosPort,
|
|
358
|
-
devicectl: optionTypes.devicectl, timeoutMs: optionTypes.timeoutMs },
|
|
374
|
+
devicectl: optionTypes.devicectl, timeoutMs: optionTypes.timeoutMs, setupResultPath: optionTypes.setupResultPath },
|
|
359
375
|
required: ['operation', 'deviceId'], oneOf: [
|
|
360
376
|
{ properties: { kind: { enum: ['h5', 'flutter'] }, wdaRunnerBundleId: false, wdaUrl: false },
|
|
361
377
|
anyOf: [
|
|
362
|
-
{ properties: { operation: {
|
|
363
|
-
{ properties: { operation: {
|
|
378
|
+
{ properties: { operation: { const: 'status' }, kind: false, actionId: false, runtimeEpoch: false, setupResultPath: false } },
|
|
379
|
+
{ properties: { operation: { const: 'reconcile' }, kind: false, actionId: false, runtimeEpoch: false } },
|
|
380
|
+
{ properties: { operation: { enum: ['result', 'cancel'] }, setupResultPath: false }, required: ['bundleId', 'kind', 'actionId', 'runtimeEpoch'] },
|
|
364
381
|
] },
|
|
365
|
-
{ properties: { kind: { const: 'wda' }, bundleId: false, runtimeUrl: false, iosHost: false, iosPort: false },
|
|
382
|
+
{ properties: { kind: { const: 'wda' }, bundleId: false, runtimeUrl: false, iosHost: false, iosPort: false, setupResultPath: false },
|
|
366
383
|
required: ['kind', 'wdaRunnerBundleId'], anyOf: [
|
|
367
384
|
{ properties: { operation: { enum: ['status', 'reconcile'] }, actionId: false, runtimeEpoch: false } },
|
|
368
385
|
{ properties: { operation: { enum: ['result', 'cancel'] } }, required: ['actionId', 'runtimeEpoch'] },
|
package/bin/device-provider.js
CHANGED
|
@@ -79,10 +79,18 @@ async function runCommand(command, options, ctx) {
|
|
|
79
79
|
return removeBridgeForward(ctx, adb);
|
|
80
80
|
case 'status':
|
|
81
81
|
return bridgeStatus(ctx, options);
|
|
82
|
+
case 'ui-observation':
|
|
83
|
+
return bridgeRequest(ctx, async () => {
|
|
84
|
+
const path = require('./ui-observation').path(options);
|
|
85
|
+
const result = JSON.parse(await httpPost(bridgeUrl(ctx, path), require('./ui-observation').request(options), ctx.httpTimeoutMs));
|
|
86
|
+
verifyBridgeTargetPackage(ctx, result, path);
|
|
87
|
+
return result;
|
|
88
|
+
});
|
|
82
89
|
case 'tree':
|
|
83
90
|
return bridgeTree(ctx, options);
|
|
84
91
|
case 'flutter-tree': {
|
|
85
|
-
const status = await bridgeGet(ctx, '/v1/
|
|
92
|
+
const status = await bridgeGet(ctx, '/v1/flutter/snapshot');
|
|
93
|
+
if (status.ok === false) return status;
|
|
86
94
|
return status.flutter?.layout || null;
|
|
87
95
|
}
|
|
88
96
|
case 'flutter-nodes':
|
|
@@ -2173,7 +2181,8 @@ async function waitText(ctx, targetText, options = {}, dependencies = {}) {
|
|
|
2173
2181
|
}
|
|
2174
2182
|
|
|
2175
2183
|
async function flutterNodes(ctx) {
|
|
2176
|
-
const status = await bridgeGet(ctx, '/v1/
|
|
2184
|
+
const status = await bridgeGet(ctx, '/v1/flutter/snapshot');
|
|
2185
|
+
if (status.ok === false) return status;
|
|
2177
2186
|
return status.flutter?.layout?.operable || { ok: false, error: 'no_flutter_operable_tree' };
|
|
2178
2187
|
}
|
|
2179
2188
|
|
package/bin/execution-host.js
CHANGED
|
@@ -20,6 +20,9 @@ const {
|
|
|
20
20
|
const iosProvider = new IOSBridgeProvider();
|
|
21
21
|
const webProvider = new WebBridgeProvider();
|
|
22
22
|
const targetExecution = new TargetExecution();
|
|
23
|
+
let browserExecutor = null;
|
|
24
|
+
let androidExecutor = null;
|
|
25
|
+
let flutterExecutor = null;
|
|
23
26
|
|
|
24
27
|
let sharedFactStore = null;
|
|
25
28
|
let sharedFactRecorder = null;
|
|
@@ -47,6 +50,9 @@ function close() {
|
|
|
47
50
|
await require('./script/script-entry').cancelActiveScripts();
|
|
48
51
|
await require('./intent/intent-entry').cancelActiveIntents();
|
|
49
52
|
await webProvider.close();
|
|
53
|
+
await browserExecutor?.close();
|
|
54
|
+
await androidExecutor?.close();
|
|
55
|
+
await flutterExecutor?.close();
|
|
50
56
|
await Promise.allSettled([...activeRuns]);
|
|
51
57
|
try { await sharedObservationCollector?.stop(); }
|
|
52
58
|
finally {
|
|
@@ -579,6 +585,18 @@ function getSharedObservationCollector(factRecorder) {
|
|
|
579
585
|
}
|
|
580
586
|
|
|
581
587
|
async function runRawCommand(command, args = {}) {
|
|
588
|
+
if (command === 'android-executor') {
|
|
589
|
+
androidExecutor ||= new (require('./executors/android-host').AndroidExecutorHost)();
|
|
590
|
+
return androidExecutor.run(args);
|
|
591
|
+
}
|
|
592
|
+
if (command === 'flutter-executor') {
|
|
593
|
+
flutterExecutor ||= new (require('./executors/flutter-host').FlutterExecutorHost)();
|
|
594
|
+
return flutterExecutor.run(args);
|
|
595
|
+
}
|
|
596
|
+
if (command === 'web-executor') {
|
|
597
|
+
browserExecutor ||= new (require('./executors/playwright-host').PlaywrightHost)();
|
|
598
|
+
return browserExecutor.run(args);
|
|
599
|
+
}
|
|
582
600
|
const definition = commandByName.get(command);
|
|
583
601
|
if (definition?.domain === 'ios') return iosProvider.run(command, args);
|
|
584
602
|
if (definition?.domain === 'web') return webProvider.run(command, args);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
const { randomUUID, randomBytes, createHash } = require('node:crypto');
|
|
7
|
+
const { CommandError } = require('../command-errors');
|
|
8
|
+
const { currentExecution, checkExecution, markExecutionDispatched, withoutExecution, runExecution, executionSleep } = require('../shared-kernel/execution-scope');
|
|
9
|
+
const { runDeviceEffect } = require('../shared-kernel/device-mutation-lease');
|
|
10
|
+
const { executorHome, atomicJson, readJson } = require('./managed-runtime');
|
|
11
|
+
const { AndroidExecutorPort, protocol, quote } = require('./android-port');
|
|
12
|
+
|
|
13
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
14
|
+
function settlement(result, pending) {
|
|
15
|
+
if (result?.executionReceipt?.settled === true && result.executionReceipt.sessionId === pending.sessionId
|
|
16
|
+
&& result.executionReceipt.runtimeEpoch === pending.runtimeEpoch && result.executionReceipt.actionId === pending.actionId) {
|
|
17
|
+
return { kind: pending.kind, sessionId: pending.sessionId, runtimeEpoch: pending.runtimeEpoch, actionId: pending.actionId,
|
|
18
|
+
settled: true, dispatched: result.executionReceipt.result?.dispatched === true, receipt: result.executionReceipt };
|
|
19
|
+
}
|
|
20
|
+
if (result?.ok === false && result.dispatched === false && result.ambiguous === false)
|
|
21
|
+
return { kind: pending.kind, sessionId: pending.sessionId, actionId: pending.actionId, settled: true, dispatched: false };
|
|
22
|
+
if (pending.operation === 'open' && result?.ok && result.protocol === pending.protocol && result.sessionId === pending.sessionId)
|
|
23
|
+
return { kind: pending.kind, sessionId: pending.sessionId, runtimeEpoch: result.runtimeEpoch, settled: true, dispatched: true, state: 'ready' };
|
|
24
|
+
if (['open', 'close'].includes(pending.operation) && result?.closed?.settled === true && result.closed.sessionId === pending.sessionId
|
|
25
|
+
&& (pending.runtimeEpoch === null || result.closed.runtimeEpoch === pending.runtimeEpoch))
|
|
26
|
+
return { kind: pending.kind, sessionId: pending.sessionId, runtimeEpoch: pending.runtimeEpoch, settled: true, dispatched: true, state: 'closed' };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class AndroidExecutorHost {
|
|
31
|
+
constructor({ home = executorHome() } = {}) { this.home = home; this.sessions = new Map(); this.children = new Map(); }
|
|
32
|
+
get kind() { return 'android-test-executor'; }
|
|
33
|
+
createPort(descriptor) { return new AndroidExecutorPort(descriptor); }
|
|
34
|
+
async finishClose(port) {
|
|
35
|
+
if (!await port.processEnded()) return false;
|
|
36
|
+
await require('../shared-kernel/uia-runtime-port').createUiaRuntimePort({ adb: port.descriptor.adb, serial: port.descriptor.serial }).releaseInstrumentation(port.descriptor.sessionId);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
directory(sessionId) {
|
|
40
|
+
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) throw new CommandError('executor_session_mismatch', 'Invalid executor session ID.');
|
|
41
|
+
return path.join(this.home, 'sessions', 'android', sessionId);
|
|
42
|
+
}
|
|
43
|
+
load(args) {
|
|
44
|
+
const descriptor = readJson(path.join(this.directory(args.sessionId), 'session.json'));
|
|
45
|
+
if (!descriptor || descriptor.serial !== args.serial || descriptor.packageName !== args.packageName
|
|
46
|
+
|| args.runtimeEpoch !== descriptor.runtimeEpoch) throw new CommandError('executor_session_mismatch', 'Executor target/session/generation does not match.');
|
|
47
|
+
let port = this.sessions.get(descriptor.sessionId);
|
|
48
|
+
if (!port) { port = this.createPort(descriptor); this.sessions.set(descriptor.sessionId, port); }
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
async run(args) {
|
|
52
|
+
const adb = args.adb || process.env.ADB || 'adb';
|
|
53
|
+
if (args.operation === 'status') {
|
|
54
|
+
const port = new AndroidExecutorPort({ adb, serial: args.serial });
|
|
55
|
+
const listing = await port.invoke(['shell', 'pm', 'list', 'instrumentation']);
|
|
56
|
+
const instruments = listing.stdout.trim().split(/\r?\n/).filter(Boolean).map(line => {
|
|
57
|
+
const match = /^instrumentation:(\S+) \(target=(\S+)\)$/.exec(line);
|
|
58
|
+
if (!match) throw new CommandError('executor_instrumentation_inventory_invalid', 'Android returned an unexpected instrumentation entry.');
|
|
59
|
+
return { component: match[1], targetPackage: match[2] };
|
|
60
|
+
});
|
|
61
|
+
return { ok: true, serial: args.serial, instruments,
|
|
62
|
+
prerequisite: 'Use the existing application androidTest source set and install its matching test APK. Opening instrumentation restarts the target application; no separate business app is required.' };
|
|
63
|
+
}
|
|
64
|
+
if (args.operation === 'open') return this.open({ ...args, adb });
|
|
65
|
+
const port = this.load(args), descriptor = port.descriptor;
|
|
66
|
+
const request = { ...args, requestId: randomUUID() };
|
|
67
|
+
delete request.adb; delete request.serial; delete request.packageName; delete request.feedback;
|
|
68
|
+
if (args.operation === 'receipt') {
|
|
69
|
+
const receipt = await port.readRecord(`receipts/${hash(args.actionId)}.json`);
|
|
70
|
+
return { ok: Boolean(receipt), sessionId: descriptor.sessionId, runtimeEpoch: descriptor.runtimeEpoch, receipt };
|
|
71
|
+
}
|
|
72
|
+
if (args.operation === 'observe') return port.request(request);
|
|
73
|
+
const actionId = args.actionId || args.runtimeActionId || randomUUID();
|
|
74
|
+
const pending = { kind: this.kind, protocol: descriptor.protocol, operation: args.operation, sessionId: descriptor.sessionId,
|
|
75
|
+
runtimeEpoch: descriptor.runtimeEpoch, actionId, target: { serial: descriptor.serial, packageName: descriptor.packageName, adb: descriptor.adb },
|
|
76
|
+
descriptorFile: path.join(this.directory(descriptor.sessionId), 'session.json') };
|
|
77
|
+
return runDeviceEffect(pending, async () => {
|
|
78
|
+
if (args.operation === 'close') {
|
|
79
|
+
const response = await port.request(request);
|
|
80
|
+
if (!response.ok) return response;
|
|
81
|
+
while (true) {
|
|
82
|
+
const closed = await port.readRecord('closed.json');
|
|
83
|
+
if (closed?.settled === true && await this.finishClose(port)) {
|
|
84
|
+
await port.disconnect(); this.sessions.delete(descriptor.sessionId); return { ok: true, closed };
|
|
85
|
+
}
|
|
86
|
+
await executionSleep(50);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try { return await port.request({ ...request, actionId }); }
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.dispatched === false) throw error;
|
|
92
|
+
const recovered = await withoutExecution(() => runExecution({ timeoutMs: 5000, mutation: false }, async () => {
|
|
93
|
+
await port.request({ operation: 'cancel', requestId: request.requestId, timeoutMs: 1000 });
|
|
94
|
+
while (true) {
|
|
95
|
+
const response = await port.request({ operation: 'receipt', actionId, timeoutMs: 1000 });
|
|
96
|
+
if (response.receipt?.settled === true) return { ...response.receipt.result, executionReceipt: response.receipt, recovered: true };
|
|
97
|
+
await executionSleep(50);
|
|
98
|
+
}
|
|
99
|
+
})).catch(recoveryError => ({ ok: false, error: 'executor_completion_unknown', message: error.message,
|
|
100
|
+
recoveryError: recoveryError.code, dispatched: true, ambiguous: true, sessionId: descriptor.sessionId, runtimeEpoch: descriptor.runtimeEpoch, actionId }));
|
|
101
|
+
return recovered;
|
|
102
|
+
}
|
|
103
|
+
}, result => settlement(result, pending));
|
|
104
|
+
}
|
|
105
|
+
async open(args) {
|
|
106
|
+
const inventory = await this.run({ operation: 'status', adb: args.adb, serial: args.serial });
|
|
107
|
+
const installed = inventory.instruments.find(item => item.component === args.instrumentation);
|
|
108
|
+
if (!installed) throw new CommandError('executor_not_installed', 'The selected instrumentation component is not installed.', { dispatched: false, ambiguous: false });
|
|
109
|
+
if (installed.targetPackage !== args.packageName) throw new CommandError('executor_target_mismatch', 'The test APK must target this exact application package.', { dispatched: false, ambiguous: false });
|
|
110
|
+
if (!args.activity) throw new CommandError('missing_argument', 'The test executor requires the application Activity to launch.', { field: 'activity', dispatched: false, ambiguous: false });
|
|
111
|
+
const sessionId = randomUUID(), directory = this.directory(sessionId);
|
|
112
|
+
const descriptor = { protocol, sessionId, serial: args.serial, packageName: args.packageName, targetPackage: installed.targetPackage,
|
|
113
|
+
adb: args.adb, token: randomBytes(32).toString('hex'), instrumentation: args.instrumentation, testClass: args.testClass };
|
|
114
|
+
const file = path.join(directory, 'session.json');
|
|
115
|
+
const port = new AndroidExecutorPort(descriptor);
|
|
116
|
+
descriptor.bootId = await port.bootId();
|
|
117
|
+
atomicJson(file, descriptor);
|
|
118
|
+
await port.connect();
|
|
119
|
+
const pending = { kind: this.kind, protocol, operation: 'open', sessionId, runtimeEpoch: null,
|
|
120
|
+
target: { serial: args.serial, packageName: args.packageName, adb: args.adb }, descriptorFile: file };
|
|
121
|
+
const uia = require('../shared-kernel/uia-runtime-port').createUiaRuntimePort({ adb: args.adb, serial: args.serial, timeoutMs: args.timeoutMs ?? 60000 });
|
|
122
|
+
return uia.withInstrumentation(file, () => runDeviceEffect(pending, async () => {
|
|
123
|
+
checkExecution();
|
|
124
|
+
const command = ['am', 'instrument', '-w', '-r', '-e', 'class', args.testClass, '-e', 'bridgeSessionId', sessionId,
|
|
125
|
+
'-e', 'bridgeToken', descriptor.token, '-e', 'bridgeLeaseMs', String(args.leaseMs ?? 600000),
|
|
126
|
+
...(args.activity ? ['-e', 'bridgeActivity', args.activity] : []), args.instrumentation];
|
|
127
|
+
const child = spawn(args.adb, ['-s', args.serial, 'shell', command.map(quote).join(' ')], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
128
|
+
let output = '', exited = false, spawnError;
|
|
129
|
+
child.on('error', error => { spawnError = error; });
|
|
130
|
+
child.stdout.on('data', chunk => { output = (output + chunk).slice(-65536); });
|
|
131
|
+
child.stderr.on('data', chunk => { output = (output + chunk).slice(-65536); });
|
|
132
|
+
child.once('close', code => {
|
|
133
|
+
exited = true; this.children.delete(sessionId);
|
|
134
|
+
atomicJson(path.join(directory, 'runner-result.json'), { sessionId, code, output: output.replaceAll(descriptor.token, '[REDACTED_SECRET]'), instrumentFinished: /INSTRUMENTATION_CODE: -?\d+/.test(output) });
|
|
135
|
+
});
|
|
136
|
+
this.children.set(sessionId, child);
|
|
137
|
+
if (child.pid) markExecutionDispatched();
|
|
138
|
+
this.sessions.set(sessionId, port);
|
|
139
|
+
try {
|
|
140
|
+
while (true) {
|
|
141
|
+
checkExecution();
|
|
142
|
+
if (spawnError) throw Object.assign(spawnError, { dispatched: false, ambiguous: false });
|
|
143
|
+
if (exited) throw new CommandError('executor_runner_ended', 'Instrumentation ended before the executor was ready.', { dispatched: true, ambiguous: true, details: { sessionId, output: output.replaceAll(descriptor.token, '[REDACTED_SECRET]') } });
|
|
144
|
+
let status;
|
|
145
|
+
try { status = await port.request({ operation: 'status', timeoutMs: 1000 }); }
|
|
146
|
+
catch (error) { if (!['ECONNRESET', 'ECONNREFUSED', 'executor_disconnected', 'executor_transport_timeout'].includes(error.code)) throw error; }
|
|
147
|
+
if (status?.ok) {
|
|
148
|
+
if (status.targetPackage !== installed.targetPackage || status.bootId !== descriptor.bootId || status.capabilities?.engine !== 'android-instrumentation')
|
|
149
|
+
throw new CommandError('executor_engine_mismatch', 'The running test does not expose the requested executor.', { dispatched: true, ambiguous: true });
|
|
150
|
+
Object.assign(descriptor, { runtimeEpoch: status.runtimeEpoch, pid: status.pid, processStartTicks: status.processStartTicks, openedAtMs: Date.now() });
|
|
151
|
+
atomicJson(file, descriptor);
|
|
152
|
+
return { ...status, serial: args.serial, packageName: args.packageName, targetPackage: installed.targetPackage,
|
|
153
|
+
lifecycle: 'target-application-instrumented-and-restarted', leaseMs: args.leaseMs ?? 600000 };
|
|
154
|
+
}
|
|
155
|
+
await executionSleep(100);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
error.dispatched = Boolean(child.pid); error.ambiguous = error.dispatched;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}, result => settlement(result, pending)));
|
|
162
|
+
}
|
|
163
|
+
async close() {
|
|
164
|
+
// Runtime teardown only disconnects its ADB clients. Device receipts survive;
|
|
165
|
+
// the executor lease closes the test, and ownership remains unresolved until proof.
|
|
166
|
+
for (const child of this.children.values()) child.kill('SIGTERM');
|
|
167
|
+
this.children.clear();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function recoverAndroidExecutor(pending, createPort = descriptor => new AndroidExecutorPort(descriptor)) {
|
|
172
|
+
const descriptor = readJson(pending.descriptorFile);
|
|
173
|
+
if (!descriptor || descriptor.serial !== pending.target?.serial || descriptor.sessionId !== pending.sessionId)
|
|
174
|
+
return { settled: false, error: 'executor_descriptor_mismatch' };
|
|
175
|
+
const port = createPort(descriptor);
|
|
176
|
+
if (await port.bootChanged()) return { kind: pending.kind, sessionId: pending.sessionId,
|
|
177
|
+
runtimeEpoch: pending.runtimeEpoch, actionId: pending.actionId, settled: true, dispatched: null, state: 'device-rebooted-outcome-unknown' };
|
|
178
|
+
if (pending.operation === 'open') {
|
|
179
|
+
const session = await port.readRecord('session.json');
|
|
180
|
+
if (session) {
|
|
181
|
+
Object.assign(descriptor, { runtimeEpoch: session.runtimeEpoch, pid: session.pid, processStartTicks: session.processStartTicks });
|
|
182
|
+
if (pending.kind === 'flutter-test-executor') descriptor.port = session.port;
|
|
183
|
+
atomicJson(pending.descriptorFile, descriptor);
|
|
184
|
+
return settlement({ ...session, ok: true }, pending);
|
|
185
|
+
}
|
|
186
|
+
const runner = readJson(path.join(path.dirname(pending.descriptorFile), 'runner-result.json'));
|
|
187
|
+
if (runner?.sessionId === pending.sessionId && runner.instrumentFinished) return { kind: pending.kind, sessionId: pending.sessionId, settled: true, dispatched: true, state: 'runner-finished' };
|
|
188
|
+
}
|
|
189
|
+
if (pending.operation === 'act') {
|
|
190
|
+
const receipt = await port.readRecord(`receipts/${hash(pending.actionId)}.json`);
|
|
191
|
+
if (receipt?.settled) return settlement({ executionReceipt: receipt }, pending);
|
|
192
|
+
}
|
|
193
|
+
if (descriptor.pid && await port.processEnded()) return { kind: pending.kind, sessionId: pending.sessionId,
|
|
194
|
+
runtimeEpoch: pending.runtimeEpoch, actionId: pending.actionId, settled: true, dispatched: null, state: 'executor-process-ended-outcome-unknown' };
|
|
195
|
+
const closed = await port.readRecord('closed.json');
|
|
196
|
+
return closed?.settled === true && await port.processEnded() ? { kind: pending.kind, sessionId: pending.sessionId, runtimeEpoch: closed.runtimeEpoch,
|
|
197
|
+
actionId: pending.actionId, settled: true, dispatched: null, state: 'session-closed-outcome-unknown', closed }
|
|
198
|
+
: { settled: false, error: 'executor_original_completion_unavailable', actionId: pending.actionId };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { AndroidExecutorHost, recoverAndroidExecutor, settlement };
|