@mobileaidev/ai-app-bridge 0.3.5 → 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 +4 -2
- package/bin/command-discovery.js +8 -2
- package/bin/command-registry.js +18 -2
- 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-provider.js +8 -3
- package/bin/ios-runtime-binding.js +1 -1
- package/bin/runtime-directory.js +1 -1
- package/bin/shared-kernel/device-ownership-recovery.js +8 -0
- package/bin/shared-kernel/native-target.js +11 -8
- package/bin/shared-kernel/uia-runtime-port.js +36 -0
- package/bin/ui-observation.js +29 -0
- package/bin/web-provider.js +6 -1
- package/docs/COMMAND_CONTRACT.md +38 -1
- package/docs/OPTIONAL_EXECUTORS.md +182 -0
- package/docs/RELEASE.md +30 -12
- package/package.json +5 -1
- package/runtime/executors/playwright/package-lock.json +45 -0
- package/runtime/executors/playwright/package.json +8 -0
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
|
|
@@ -65,10 +65,12 @@ function parsePermissionState(text, { packageName, permission, userId }, definit
|
|
|
65
65
|
return { packageName, permission, userId, uid: userId * 100000 + Number(appId[0][1]), granted: false, flags: [] };
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
|
|
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]);
|
|
69
70
|
if (!matches.length) throw new CommandError('runtime_permission_not_found', 'The requested permission has no runtime permission record for this package and user.');
|
|
70
71
|
if (!match) throw new CommandError('permission_state_unsupported', 'Android returned an unrecognized runtime permission record.');
|
|
71
|
-
|
|
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+/) : [];
|
|
72
74
|
if (flags.some(value => !/^[A-Z][A-Z0-9_]*$/.test(value)) || new Set(flags).size !== flags.length) {
|
|
73
75
|
throw new CommandError('permission_state_unsupported', 'Android returned unrecognized permission flags.');
|
|
74
76
|
}
|
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,6 +8,12 @@ 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
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'] },
|
|
@@ -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);
|
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 };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('node:net');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const { CommandError } = require('../command-errors');
|
|
6
|
+
const { execFileBounded } = require('../shared-kernel/execution-io');
|
|
7
|
+
const { currentExecution, checkExecution, markExecutionDispatched, executionFailure } = require('../shared-kernel/execution-scope');
|
|
8
|
+
const protocol = 'aab.android-test-executor/v1';
|
|
9
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
10
|
+
|
|
11
|
+
class AndroidExecutorPort {
|
|
12
|
+
constructor(descriptor, { run = execFileBounded, protocolVersion = protocol } = {}) { this.descriptor = descriptor; this.port = null; this.run = run; this.protocolVersion = protocolVersion; }
|
|
13
|
+
forwardEndpoint() { return `localabstract:aab-test-${this.descriptor.sessionId}`; }
|
|
14
|
+
recordDirectory() { return `no_backup/ai-app-bridge-executors/${this.descriptor.sessionId}`; }
|
|
15
|
+
invoke(args, timeoutMs = 10000) {
|
|
16
|
+
return this.run(this.descriptor.adb, ['-s', this.descriptor.serial, ...args], { timeoutMs, maxBuffer: 4 * 1024 * 1024 });
|
|
17
|
+
}
|
|
18
|
+
async connect() {
|
|
19
|
+
const descriptor = this.descriptor;
|
|
20
|
+
const remote = this.forwardEndpoint();
|
|
21
|
+
const inventory = async () => (await this.invoke(['forward', '--list'])).stdout.trim().split(/\r?\n/).filter(Boolean).map(line => {
|
|
22
|
+
const [serial, local, remote] = line.trim().split(/\s+/); return { serial, local, remote };
|
|
23
|
+
});
|
|
24
|
+
const existing = (await inventory()).filter(item => item.serial === descriptor.serial && item.remote === remote);
|
|
25
|
+
if (existing.length > 1) throw new CommandError('executor_forward_ambiguous', 'Multiple forwards target this exact executor.');
|
|
26
|
+
const local = existing.length === 1 ? existing[0].local : `tcp:${(await this.invoke(['forward', 'tcp:0', remote])).stdout.trim()}`;
|
|
27
|
+
if (!/^tcp:[1-9][0-9]{0,4}$/.test(local) || Number(local.slice(4)) > 65535) throw new CommandError('executor_forward_invalid', 'ADB returned an invalid executor forward.');
|
|
28
|
+
const confirmed = (await inventory()).filter(item => item.local === local);
|
|
29
|
+
if (confirmed.length !== 1 || confirmed[0].serial !== descriptor.serial || confirmed[0].remote !== remote)
|
|
30
|
+
throw new CommandError('executor_forward_mismatch', 'Executor forward belongs to another target.');
|
|
31
|
+
this.port = Number(local.slice(4));
|
|
32
|
+
}
|
|
33
|
+
async request(args) {
|
|
34
|
+
if (this.port === null) await this.connect();
|
|
35
|
+
checkExecution();
|
|
36
|
+
const scope = currentExecution();
|
|
37
|
+
const payload = { protocol: this.protocolVersion, token: this.descriptor.token, sessionId: this.descriptor.sessionId,
|
|
38
|
+
...(this.descriptor.runtimeEpoch ? { runtimeEpoch: this.descriptor.runtimeEpoch } : {}), requestId: randomUUID(), ...args };
|
|
39
|
+
const body = JSON.stringify(payload) + '\n';
|
|
40
|
+
if (Buffer.byteLength(body) > 1024 * 1024) throw new CommandError('executor_request_limit', 'Executor request exceeds 1 MiB.', { dispatched: false, ambiguous: false });
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let data = '', bytes = 0, failure, result, timer, dispatched = false;
|
|
43
|
+
const socket = net.createConnection({ host: '127.0.0.1', port: this.port });
|
|
44
|
+
const stop = error => { failure ||= error; socket.destroy(); };
|
|
45
|
+
const abort = () => stop(executionFailure(scope));
|
|
46
|
+
timer = setTimeout(() => stop(new CommandError('executor_transport_timeout', 'Executor reply did not complete within the request deadline.')), args.timeoutMs ?? 30000);
|
|
47
|
+
scope?.signal.addEventListener('abort', abort, { once: true });
|
|
48
|
+
socket.setEncoding('utf8');
|
|
49
|
+
socket.on('connect', () => {
|
|
50
|
+
try { checkExecution(); } catch (error) { stop(error); return; }
|
|
51
|
+
if (['act', 'close'].includes(args.operation)) { dispatched = true; markExecutionDispatched(); }
|
|
52
|
+
socket.write(body);
|
|
53
|
+
});
|
|
54
|
+
socket.on('data', chunk => {
|
|
55
|
+
bytes += Buffer.byteLength(chunk);
|
|
56
|
+
if (bytes > 4 * 1024 * 1024) { stop(new CommandError('executor_response_limit', 'Executor reply exceeds 4 MiB.')); return; }
|
|
57
|
+
data += chunk;
|
|
58
|
+
if (data.endsWith('\n')) {
|
|
59
|
+
try {
|
|
60
|
+
result = JSON.parse(data);
|
|
61
|
+
if (result.protocol !== this.protocolVersion || result.sessionId !== payload.sessionId || (payload.runtimeEpoch && result.runtimeEpoch !== payload.runtimeEpoch))
|
|
62
|
+
throw new CommandError('executor_session_mismatch', 'Executor reply identity does not match the original request.');
|
|
63
|
+
socket.destroy();
|
|
64
|
+
} catch (error) { stop(error); }
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
socket.on('error', error => { failure ||= error; });
|
|
68
|
+
socket.on('close', () => {
|
|
69
|
+
clearTimeout(timer); scope?.signal.removeEventListener('abort', abort);
|
|
70
|
+
if (!result) failure ||= new CommandError('executor_disconnected', 'Executor disconnected without a complete reply.');
|
|
71
|
+
if (failure) { failure.dispatched = dispatched; failure.ambiguous = dispatched; reject(failure); }
|
|
72
|
+
else resolve(result);
|
|
73
|
+
});
|
|
74
|
+
if (scope?.signal.aborted) abort();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async readRecord(name) {
|
|
78
|
+
if (!/^(session|starting|failed|closed)\.json$/.test(name) && !/^receipts\/[a-f0-9]{64}\.json$/.test(name)) throw new Error('Invalid executor record');
|
|
79
|
+
const file = `${this.recordDirectory()}/${name}`;
|
|
80
|
+
const script = `if [ -f ${quote(file)} ]; then cat ${quote(file)}; else printf '%s' null; fi`;
|
|
81
|
+
const result = await this.invoke(['shell', 'run-as', this.descriptor.targetPackage, 'sh', '-c', quote(script)]);
|
|
82
|
+
const record = JSON.parse(result.stdout.trim());
|
|
83
|
+
if (record && (record.protocol !== this.protocolVersion || record.sessionId !== this.descriptor.sessionId
|
|
84
|
+
|| record.targetPackage !== this.descriptor.targetPackage || record.bootId !== this.descriptor.bootId
|
|
85
|
+
|| (this.descriptor.runtimeEpoch && record.runtimeEpoch !== this.descriptor.runtimeEpoch))) throw new CommandError('executor_receipt_mismatch', 'Retained device record belongs to a different session.');
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
async disconnect() {
|
|
89
|
+
if (this.port !== null) { await this.invoke(['forward', '--remove', `tcp:${this.port}`]); this.port = null; }
|
|
90
|
+
}
|
|
91
|
+
async processEnded() {
|
|
92
|
+
if (await this.bootChanged()) return true;
|
|
93
|
+
if (!Number.isInteger(this.descriptor.pid) || this.descriptor.pid < 1) throw new CommandError('executor_process_identity_missing', 'The executor did not publish its process identity.');
|
|
94
|
+
if (!/^[0-9]+$/.test(this.descriptor.processStartTicks)) throw new CommandError('executor_process_identity_missing', 'The executor did not publish its process start identity.');
|
|
95
|
+
const script = `if [ -d /proc/${this.descriptor.pid} ]; then cat /proc/${this.descriptor.pid}/stat; else printf '%s' gone; fi`;
|
|
96
|
+
const result = (await this.invoke(['shell', 'run-as', this.descriptor.targetPackage, 'sh', '-c', quote(script)])).stdout.trim();
|
|
97
|
+
if (result === 'gone') return true;
|
|
98
|
+
const start = result.slice(result.lastIndexOf(') ') + 2).split(/\s+/)[19];
|
|
99
|
+
if (!/^[0-9]+$/.test(start)) throw new CommandError('executor_process_probe_invalid', 'The device did not confirm the executor process state.');
|
|
100
|
+
return start !== this.descriptor.processStartTicks;
|
|
101
|
+
}
|
|
102
|
+
async bootId() {
|
|
103
|
+
const id = (await this.invoke(['shell', 'cat', '/proc/sys/kernel/random/boot_id'])).stdout.trim();
|
|
104
|
+
if (!/^[a-f0-9-]{36}$/.test(id)) throw new CommandError('executor_boot_identity_invalid', 'The device did not publish a valid boot identity.');
|
|
105
|
+
return id;
|
|
106
|
+
}
|
|
107
|
+
async bootChanged() {
|
|
108
|
+
if (typeof this.descriptor.bootId !== 'string' || !/^[a-f0-9-]{36}$/.test(this.descriptor.bootId))
|
|
109
|
+
throw new CommandError('executor_boot_identity_missing', 'The original executor boot identity is unavailable.');
|
|
110
|
+
return await this.bootId() !== this.descriptor.bootId;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { AndroidExecutorPort, protocol, quote };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { defaultDirectory } = require('../shared-kernel/device-ownership-store');
|
|
7
|
+
const { CommandError } = require('../command-errors');
|
|
8
|
+
const { atomicJson, readJson } = require('./managed-runtime');
|
|
9
|
+
const { AndroidExecutorPort } = require('./android-port');
|
|
10
|
+
|
|
11
|
+
const fileFor = serial => path.join(defaultDirectory(), 'automation-sessions', createHash('sha256').update(serial).digest('hex') + '.json');
|
|
12
|
+
function owner(serial) { return readJson(fileFor(serial)); }
|
|
13
|
+
function claim(serial, descriptorFile) {
|
|
14
|
+
const descriptor = readJson(descriptorFile);
|
|
15
|
+
if (!descriptor || descriptor.serial !== serial) throw new CommandError('executor_descriptor_mismatch', 'Automation claim requires the exact device descriptor.');
|
|
16
|
+
const claim = { serial, sessionId: descriptor.sessionId, descriptorFile };
|
|
17
|
+
atomicJson(fileFor(serial), claim);
|
|
18
|
+
return claim;
|
|
19
|
+
}
|
|
20
|
+
function release(serial, sessionId) {
|
|
21
|
+
const current = owner(serial);
|
|
22
|
+
if (!current) return;
|
|
23
|
+
if (current.sessionId !== sessionId) throw new CommandError('executor_automation_owner_changed', 'A different test session owns UiAutomation.');
|
|
24
|
+
fs.unlinkSync(fileFor(serial));
|
|
25
|
+
const fd = fs.openSync(path.dirname(fileFor(serial)), 'r');
|
|
26
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
27
|
+
}
|
|
28
|
+
async function assertAvailable(serial) {
|
|
29
|
+
const current = owner(serial);
|
|
30
|
+
if (!current) return;
|
|
31
|
+
const descriptor = readJson(current.descriptorFile);
|
|
32
|
+
if (!descriptor || descriptor.serial !== serial || descriptor.sessionId !== current.sessionId)
|
|
33
|
+
throw new CommandError('executor_automation_owner_invalid', 'The recorded UiAutomation owner cannot be resolved.');
|
|
34
|
+
const port = new AndroidExecutorPort(descriptor);
|
|
35
|
+
const runner = readJson(path.join(path.dirname(current.descriptorFile), 'runner-result.json'));
|
|
36
|
+
if ((runner?.sessionId === current.sessionId && runner.instrumentFinished)
|
|
37
|
+
|| await port.bootChanged() || (descriptor.pid && await port.processEnded())) { release(serial, current.sessionId); return; }
|
|
38
|
+
throw new CommandError('uia_owned_by_test_executor', 'The test session owns UiAutomation. Use android-executor with engine uiautomator, or close that session first.',
|
|
39
|
+
{ dispatched: false, ambiguous: false, details: { serial, sessionId: current.sessionId, runtimeEpoch: descriptor.runtimeEpoch, packageName: descriptor.packageName } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { owner, claim, release, assertAvailable };
|