@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.
Files changed (38) hide show
  1. package/README.md +7 -3
  2. package/bin/android-permissions.js +39 -5
  3. package/bin/command-discovery.js +8 -2
  4. package/bin/command-registry.js +26 -9
  5. package/bin/device-provider.js +11 -2
  6. package/bin/execution-host.js +18 -0
  7. package/bin/executors/android-host.js +201 -0
  8. package/bin/executors/android-port.js +114 -0
  9. package/bin/executors/automation-owner.js +42 -0
  10. package/bin/executors/command-schema.js +102 -0
  11. package/bin/executors/flutter-host.js +123 -0
  12. package/bin/executors/managed-runtime.js +85 -0
  13. package/bin/executors/playwright-host.js +146 -0
  14. package/bin/executors/playwright-worker.js +307 -0
  15. package/bin/executors/receipt-journal.js +56 -0
  16. package/bin/feedback-probe.js +27 -1
  17. package/bin/ios-device-outcome.js +17 -0
  18. package/bin/ios-execution.js +13 -1
  19. package/bin/ios-provider.js +74 -24
  20. package/bin/ios-runtime-binding.js +1 -1
  21. package/bin/ios-wda-startup.js +68 -0
  22. package/bin/runtime-directory.js +1 -1
  23. package/bin/shared-kernel/device-ownership-recovery.js +13 -0
  24. package/bin/shared-kernel/native-target.js +11 -8
  25. package/bin/shared-kernel/uia-protocol.js +1 -1
  26. package/bin/shared-kernel/uia-runtime-port.js +38 -2
  27. package/bin/ui-observation.js +29 -0
  28. package/bin/web-provider.js +6 -1
  29. package/docs/COMMAND_CONTRACT.md +73 -2
  30. package/docs/INTENT_FOREGROUND.md +1 -1
  31. package/docs/OPTIONAL_EXECUTORS.md +182 -0
  32. package/docs/RELEASE.md +36 -14
  33. package/docs/SCRIPT_AUTHORING.md +7 -0
  34. package/package.json +6 -1
  35. package/runtime/executors/playwright/package-lock.json +45 -0
  36. package/runtime/executors/playwright/package.json +8 -0
  37. package/runtime/uia/ai-app-bridge-uia.jar +0 -0
  38. package/runtime/uia/manifest.json +9 -8
@@ -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 };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { object, text, integer } = require('../shared-kernel/argument-schema');
4
+ const identifier = { ...text, maxLength: 1024 };
5
+ const timeoutMs = integer(1, 300000);
6
+ const execution = { timeoutMs, requestId: identifier, feedback: { enum: ['auto', 'off', 'full'] } };
7
+ const webIdentity = { sessionId: identifier, runtimeEpoch: identifier, targetId: identifier };
8
+ const documentIdentity = { frameId: identifier, documentId: identifier };
9
+ const selector = {
10
+ anyOf: [
11
+ object({ by: { const: 'role' }, value: identifier, name: { type: 'string', maxLength: 4096 } }, ['by', 'value', 'name']),
12
+ object({ by: { enum: ['testId', 'text', 'label', 'placeholder', 'css'] }, value: identifier }, ['by', 'value']),
13
+ ],
14
+ };
15
+ const browserAction = {
16
+ anyOf: [
17
+ object({ type: { enum: ['click', 'doubleClick', 'hover', 'scrollIntoView'] }, selector }, ['type', 'selector']),
18
+ object({ type: { enum: ['fill', 'type', 'press'] }, selector, text: { type: 'string', maxLength: 65536 } }, ['type', 'selector', 'text']),
19
+ object({ type: { const: 'check' }, selector, checked: { type: 'boolean' } }, ['type', 'selector', 'checked']),
20
+ object({ type: { const: 'select' }, selector, values: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 100 } }, ['type', 'selector', 'values']),
21
+ object({ type: { const: 'drag' }, selector, destination: selector }, ['type', 'selector', 'destination']),
22
+ object({ type: { const: 'upload' }, selector, files: { type: 'array', items: identifier, maxItems: 100 } }, ['type', 'selector', 'files']),
23
+ ],
24
+ };
25
+
26
+ function operationSchema(definitions) {
27
+ const branches = Object.entries(definitions).map(([operation, definition]) =>
28
+ object({ operation: { const: operation }, ...execution, ...definition.properties }, ['operation', ...definition.required]));
29
+ const properties = Object.assign({}, ...branches.map(branch => branch.properties));
30
+ properties.operation = { enum: Object.keys(definitions) };
31
+ return { type: 'object', additionalProperties: false, properties, required: ['operation'], anyOf: branches };
32
+ }
33
+
34
+ function webExecutorSchema() {
35
+ const bound = (properties = {}, required = []) => ({ properties: { ...webIdentity, ...properties }, required: [...Object.keys(webIdentity), ...required] });
36
+ return operationSchema({
37
+ status: { properties: { browser: { enum: ['chromium', 'firefox', 'webkit'] } }, required: [] },
38
+ prepare: { properties: { browser: { enum: ['chromium', 'firefox', 'webkit'] } }, required: [] },
39
+ open: { properties: { url: identifier, browser: { enum: ['chromium', 'firefox', 'webkit'] }, headless: { type: 'boolean' },
40
+ viewport: object({ width: integer(320, 7680), height: integer(240, 4320) }, ['width', 'height']) }, required: ['url'] },
41
+ observe: bound({ maxControls: integer(1, 1000) }),
42
+ act: bound({ ...documentIdentity, actionId: identifier, action: browserAction,
43
+ dialog: object({ action: { enum: ['accept', 'dismiss'] }, promptText: { type: 'string', maxLength: 4096 } }, ['action']) }, [...Object.keys(documentIdentity), 'action']),
44
+ navigate: bound({ ...documentIdentity, url: identifier, actionId: identifier }, [...Object.keys(documentIdentity), 'url']),
45
+ wait: bound({ ...documentIdentity, selector, state: { enum: ['visible', 'hidden', 'attached', 'detached'] }, text: { type: 'string', maxLength: 65536 } }, [...Object.keys(documentIdentity), 'selector']),
46
+ screenshot: bound({ outFile: identifier, fullPage: { type: 'boolean' } }, ['outFile']),
47
+ events: bound({ afterSequence: integer(0, Number.MAX_SAFE_INTEGER), limit: integer(1, 1000) }),
48
+ receipt: bound({ actionId: identifier }, ['actionId']),
49
+ close: bound(),
50
+ });
51
+ }
52
+
53
+ function androidExecutorSchema() {
54
+ const target = { serial: identifier, packageName: identifier, adb: identifier };
55
+ const session = { sessionId: identifier, runtimeEpoch: identifier };
56
+ const bound = (properties = {}, required = []) => ({ properties: { ...target, ...session, ...properties }, required: ['serial', 'packageName', 'sessionId', 'runtimeEpoch', ...required] });
57
+ const action = { anyOf: [
58
+ object({ type: { enum: ['click', 'longClick', 'scrollTo', 'swipeUp', 'swipeDown', 'swipeLeft', 'swipeRight', 'webClick', 'webClear', 'webScrollIntoView', 'semanticLongClick', 'composeClearText'] }, nodeId: identifier }, ['type', 'nodeId']),
59
+ object({ type: { enum: ['setText', 'typeText', 'replaceText', 'webKeys', 'composeInput', 'composeReplaceText'] }, nodeId: identifier, text: { type: 'string', maxLength: 65536 } }, ['type', 'nodeId', 'text']),
60
+ object({ type: { const: 'composeScrollToIndex' }, nodeId: identifier, index: integer(0, 1000000) }, ['type', 'nodeId', 'index']),
61
+ object({ type: { const: 'scroll' }, nodeId: identifier, direction: { enum: ['up', 'down', 'left', 'right'] }, percent: { type: 'number', exclusiveMinimum: 0, maximum: 10 } }, ['type', 'nodeId', 'direction', 'percent']),
62
+ object({ type: { enum: ['back', 'home', 'closeKeyboard'] } }, ['type']),
63
+ ] };
64
+ return operationSchema({
65
+ status: { properties: target, required: ['serial'] },
66
+ open: { properties: { ...target,
67
+ instrumentation: { ...identifier, pattern: '^[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+$' }, testClass: { ...identifier, pattern: '^[A-Za-z0-9_.$]+$' },
68
+ activity: { ...identifier, pattern: '^[A-Za-z0-9_.$]+$' }, leaseMs: integer(10000, 3600000) }, required: ['serial', 'packageName', 'instrumentation', 'testClass', 'activity'] },
69
+ observe: bound({ engine: identifier,
70
+ composeUnmergedTree: { type: 'boolean' },
71
+ webView: object({ by: { enum: ['resourceId', 'description'] }, value: identifier }, ['by', 'value']),
72
+ framePath: { type: 'array', maxItems: 16, items: { anyOf: [object({ index: integer(0, 1000) }, ['index']), object({ name: identifier }, ['name'])] } }
73
+ }, ['engine']),
74
+ act: bound({ snapshotId: identifier, actionId: identifier, action }, ['snapshotId', 'action']),
75
+ receipt: bound({ actionId: identifier }, ['actionId']),
76
+ close: bound(),
77
+ });
78
+ }
79
+
80
+ function flutterExecutorSchema() {
81
+ const target = { serial: identifier, packageName: identifier, adb: identifier };
82
+ const session = { sessionId: identifier, runtimeEpoch: identifier };
83
+ const bound = (properties = {}, required = []) => ({ properties: { ...target, ...session, ...properties }, required: ['serial', 'packageName', 'sessionId', 'runtimeEpoch', ...required] });
84
+ const action = { anyOf: [
85
+ object({ type: { enum: ['tap', 'longPress', 'ensureVisible'] }, nodeId: identifier }, ['type', 'nodeId']),
86
+ object({ type: { const: 'enterText' }, nodeId: identifier, text: { type: 'string', maxLength: 65536 } }, ['type', 'nodeId', 'text']),
87
+ object({ type: { const: 'drag' }, nodeId: identifier, dx: { type: 'number' }, dy: { type: 'number' } }, ['type', 'nodeId', 'dx', 'dy']),
88
+ object({ type: { const: 'fling' }, nodeId: identifier, dx: { type: 'number' }, dy: { type: 'number' }, speed: { type: 'number', exclusiveMinimum: 0, maximum: 100000 } }, ['type', 'nodeId', 'dx', 'dy', 'speed']),
89
+ object({ type: { const: 'pageBack' } }, ['type']),
90
+ object({ type: { const: 'pump' }, count: integer(1, 120), durationMs: integer(0, 1000) }, ['type', 'count', 'durationMs']),
91
+ ] };
92
+ return operationSchema({
93
+ status: { properties: target, required: ['serial'] },
94
+ open: { properties: { ...target, activity: { ...identifier, pattern: '^[A-Za-z0-9_.$]+$' }, leaseMs: integer(10000, 3600000) }, required: ['serial', 'packageName', 'activity'] },
95
+ observe: bound(),
96
+ act: bound({ snapshotId: identifier, actionId: identifier, action }, ['snapshotId', 'action']),
97
+ receipt: bound({ actionId: identifier }, ['actionId']),
98
+ close: bound(),
99
+ });
100
+ }
101
+
102
+ module.exports = { webExecutorSchema, androidExecutorSchema, flutterExecutorSchema, operationSchema, selector, browserAction };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { randomUUID, randomBytes } = require('node:crypto');
5
+ const { AndroidExecutorHost, recoverAndroidExecutor, settlement } = require('./android-host');
6
+ const { AndroidExecutorPort, quote } = require('./android-port');
7
+ const { atomicJson, readJson } = require('./managed-runtime');
8
+ const { runDeviceEffect } = require('../shared-kernel/device-mutation-lease');
9
+ const { checkExecution, markExecutionDispatched, executionSleep } = require('../shared-kernel/execution-scope');
10
+ const { CommandError } = require('../command-errors');
11
+ const protocol = 'aab.flutter-integration-executor/v1';
12
+
13
+ class FlutterExecutorPort extends AndroidExecutorPort {
14
+ constructor(descriptor) { super(descriptor, { protocolVersion: protocol }); }
15
+ forwardEndpoint() {
16
+ if (!Number.isInteger(this.descriptor.port) || this.descriptor.port < 1 || this.descriptor.port > 65535)
17
+ throw new CommandError('executor_forward_invalid', 'The Flutter test has not published a valid port.');
18
+ return `tcp:${this.descriptor.port}`;
19
+ }
20
+ recordDirectory() { return `no_backup/ai-app-bridge-integration/${this.descriptor.sessionId}`; }
21
+ async terminateClosedProcess() {
22
+ if (await this.processEnded()) return;
23
+ const { pid, bootId, processStartTicks, targetPackage } = this.descriptor;
24
+ // Kill only the exact drained test process, even if the app has since restarted.
25
+ const script = `if [ "$(cat /proc/sys/kernel/random/boot_id)" = ${quote(bootId)} ] && [ -f /proc/${pid}/stat ]; then
26
+ aab_stat=$(cat /proc/${pid}/stat) || exit 1
27
+ aab_fields=\${aab_stat##*) }
28
+ set -- $aab_fields
29
+ shift 19
30
+ if [ "$1" = ${quote(processStartTicks)} ]; then kill -TERM ${pid}; fi
31
+ fi`;
32
+ await this.invoke(['shell', 'run-as', targetPackage, 'sh', '-c', quote(script)]);
33
+ }
34
+ }
35
+
36
+ class FlutterExecutorHost extends AndroidExecutorHost {
37
+ get kind() { return 'flutter-test-executor'; }
38
+ createPort(descriptor) { return new FlutterExecutorPort(descriptor); }
39
+ directory(sessionId) {
40
+ const validated = super.directory(sessionId);
41
+ return path.join(path.dirname(path.dirname(validated)), 'flutter', sessionId);
42
+ }
43
+ async finishClose(port) {
44
+ await port.terminateClosedProcess();
45
+ return port.processEnded();
46
+ }
47
+ async run(args) {
48
+ if (args.operation === 'status') {
49
+ const port = this.createPort({ adb: args.adb || process.env.ADB || 'adb', serial: args.serial });
50
+ const bootId = await port.bootId();
51
+ return { ok: true, serial: args.serial, bootId, engine: 'flutter-integration-test',
52
+ prerequisite: 'Install the application debug build with integration_test/bridge_test.dart as its entrypoint. Add ai_app_bridge_test only to dev_dependencies. Opening restarts the application; closing terminates the drained test process.' };
53
+ }
54
+ return super.run(args);
55
+ }
56
+ async open(args) {
57
+ const sessionId = randomUUID();
58
+ const descriptor = { protocol, sessionId, serial: args.serial, packageName: args.packageName, targetPackage: args.packageName,
59
+ adb: args.adb, token: randomBytes(32).toString('hex') };
60
+ const file = path.join(this.directory(sessionId), 'session.json');
61
+ const port = this.createPort(descriptor);
62
+ descriptor.bootId = await port.bootId();
63
+ atomicJson(file, descriptor);
64
+ const pending = { kind: this.kind, protocol, operation: 'open', sessionId, runtimeEpoch: null,
65
+ target: { serial: args.serial, packageName: args.packageName, adb: args.adb }, descriptorFile: file };
66
+ return runDeviceEffect(pending, async () => {
67
+ await require('./automation-owner').assertAvailable(args.serial);
68
+ const launch = { sessionId, token: descriptor.token, packageName: args.packageName, leaseMs: args.leaseMs ?? 600000 };
69
+ const prepare = `umask 077\nmkdir -p no_backup/ai-app-bridge-integration\nprintf '%s' ${quote(JSON.stringify(launch))} > no_backup/ai-app-bridge-integration/launch.json`;
70
+ checkExecution(); markExecutionDispatched();
71
+ await port.invoke(['shell', 'run-as', args.packageName, 'sh', '-c', quote(prepare)]);
72
+ await port.invoke(['shell', 'am', 'start', '-S', '-W', '-n', `${args.packageName}/${args.activity}`], 30000);
73
+ this.sessions.set(sessionId, port);
74
+ while (true) {
75
+ checkExecution();
76
+ if (!descriptor.pid) {
77
+ const starting = await port.readRecord('starting.json');
78
+ if (starting) {
79
+ Object.assign(descriptor, { runtimeEpoch: starting.runtimeEpoch, pid: starting.pid, processStartTicks: starting.processStartTicks });
80
+ atomicJson(file, descriptor);
81
+ }
82
+ }
83
+ const failed = await port.readRecord('failed.json');
84
+ if (failed) {
85
+ const closed = await port.readRecord('closed.json');
86
+ if (closed?.settled && await this.finishClose(port)) return { ok: false, error: 'executor_initialization_failed', message: failed.error,
87
+ sessionId, runtimeEpoch: descriptor.runtimeEpoch, dispatched: true, ambiguous: true, closed };
88
+ }
89
+ const session = await port.readRecord('session.json');
90
+ if (session) {
91
+ if (session.bootId !== descriptor.bootId || session.targetPackage !== args.packageName || session.capabilities?.engine !== 'flutter-integration-test')
92
+ throw new CommandError('executor_engine_mismatch', 'The running Flutter test does not match the requested target.', { dispatched: true, ambiguous: true });
93
+ Object.assign(descriptor, { runtimeEpoch: session.runtimeEpoch, pid: session.pid, processStartTicks: session.processStartTicks, port: session.port });
94
+ atomicJson(file, descriptor);
95
+ const status = await port.request({ operation: 'status', timeoutMs: 5000 });
96
+ if (!status.ok || status.closing) throw new CommandError('executor_not_ready', 'The Flutter test did not become ready.', { dispatched: true, ambiguous: true });
97
+ return { ...session, ok: true, serial: args.serial, packageName: args.packageName, lifecycle: 'test-entrypoint-application-restarted' };
98
+ }
99
+ await executionSleep(100);
100
+ }
101
+ }, result => settlement(result, pending));
102
+ }
103
+ }
104
+
105
+ async function recoverFlutterExecutor(pending) {
106
+ const descriptor = readJson(pending.descriptorFile);
107
+ if (!descriptor || descriptor.sessionId !== pending.sessionId || descriptor.serial !== pending.target?.serial)
108
+ return { settled: false, error: 'executor_descriptor_mismatch' };
109
+ const port = new FlutterExecutorPort(descriptor);
110
+ if (!await port.bootChanged()) {
111
+ if (!descriptor.pid) {
112
+ const starting = await port.readRecord('starting.json');
113
+ if (starting) {
114
+ Object.assign(descriptor, { runtimeEpoch: starting.runtimeEpoch, pid: starting.pid, processStartTicks: starting.processStartTicks });
115
+ atomicJson(pending.descriptorFile, descriptor);
116
+ }
117
+ }
118
+ const closed = await port.readRecord('closed.json');
119
+ if (closed?.settled && descriptor.pid) await port.terminateClosedProcess();
120
+ }
121
+ return recoverAndroidExecutor(pending, value => new FlutterExecutorPort(value));
122
+ }
123
+ module.exports = { FlutterExecutorHost, FlutterExecutorPort, protocol, recoverFlutterExecutor };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { createHash, randomUUID } = require('node:crypto');
7
+ const { CommandError } = require('../command-errors');
8
+ const { execFileBounded } = require('../shared-kernel/execution-io');
9
+
10
+ const executorHome = () => path.resolve(process.env.AI_APP_BRIDGE_EXECUTOR_HOME || path.join(os.homedir(), '.ai-app-bridge', 'executors'));
11
+
12
+ function atomicJson(file, value) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
14
+ const temporary = `${file}.${randomUUID()}.tmp`;
15
+ const fd = fs.openSync(temporary, 'wx', 0o600);
16
+ try { fs.writeFileSync(fd, JSON.stringify(value) + '\n'); fs.fsyncSync(fd); }
17
+ finally { fs.closeSync(fd); }
18
+ fs.renameSync(temporary, file);
19
+ const parent = fs.openSync(path.dirname(file), 'r');
20
+ try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
21
+ }
22
+
23
+ function readJson(file) {
24
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
25
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
26
+ }
27
+
28
+ function packageLocation(name = 'playwright', home = executorHome()) {
29
+ if (name !== 'playwright') throw new CommandError('executor_unknown', 'Unknown managed executor.');
30
+ const source = path.resolve(__dirname, '../../runtime/executors', name);
31
+ const manifest = JSON.parse(fs.readFileSync(path.join(source, 'package.json'), 'utf8'));
32
+ const lock = fs.readFileSync(path.join(source, 'package-lock.json'));
33
+ const digest = createHash('sha256').update(JSON.stringify(manifest)).update(lock).digest('hex');
34
+ const directory = path.join(home, 'packages', name, `${process.platform}-${process.arch}`, digest);
35
+ return { name, source, directory, digest, version: manifest.dependencies.playwright,
36
+ browsersPath: path.join(directory, 'browsers'), readyFile: path.join(directory, 'prepared.json') };
37
+ }
38
+
39
+ function packageStatus(browser = 'chromium', home = executorHome()) {
40
+ const location = packageLocation('playwright', home);
41
+ const ready = readJson(location.readyFile);
42
+ const installed = readJson(path.join(location.directory, 'node_modules/playwright/package.json'));
43
+ const matching = ready?.digest === location.digest && installed?.version === location.version;
44
+ const executable = matching ? ready.browsers?.[browser] : null;
45
+ return { ok: true, engine: 'playwright', version: location.version, browser,
46
+ available: Boolean(executable && fs.existsSync(executable)),
47
+ reason: executable && fs.existsSync(executable) ? null : 'executor_not_prepared',
48
+ directory: location.directory, dependencyDigest: location.digest,
49
+ ...(executable ? { executablePath: executable } : {}) };
50
+ }
51
+
52
+ async function preparePackage(browser = 'chromium', { home = executorHome(), run = execFileBounded } = {}) {
53
+ if (!['chromium', 'firefox', 'webkit'].includes(browser)) throw new CommandError('invalid_argument', 'Unknown browser.', { field: 'browser' });
54
+ const location = packageLocation('playwright', home);
55
+ fs.mkdirSync(location.directory, { recursive: true, mode: 0o700 });
56
+ const { DatabaseSync } = require('node:sqlite');
57
+ const lock = new DatabaseSync(path.join(location.directory, 'prepare.sqlite'));
58
+ let locked = false;
59
+ const startedAtMs = Date.now();
60
+ try {
61
+ try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); locked = true; }
62
+ catch (error) { if (error.errcode === 5) throw new CommandError('executor_preparing', 'This exact executor package is being prepared by another process.'); throw error; }
63
+ const status = packageStatus(browser, home);
64
+ if (status.available) return { ...status, reused: true, elapsedMs: Date.now() - startedAtMs };
65
+ const old = readJson(location.readyFile);
66
+ const installed = readJson(path.join(location.directory, 'node_modules/playwright/package.json'));
67
+ if (old?.digest !== location.digest || installed?.version !== location.version) {
68
+ for (const name of ['package.json', 'package-lock.json']) fs.copyFileSync(path.join(location.source, name), path.join(location.directory, name));
69
+ await run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'],
70
+ { cwd: location.directory, timeoutMs: 300000, maxBuffer: 4 * 1024 * 1024 });
71
+ }
72
+ const environment = { ...process.env, PLAYWRIGHT_BROWSERS_PATH: location.browsersPath };
73
+ await run(process.execPath, [path.join(location.directory, 'node_modules/playwright/cli.js'), 'install', browser],
74
+ { cwd: location.directory, env: environment, timeoutMs: 300000, maxBuffer: 4 * 1024 * 1024 });
75
+ const probe = await run(process.execPath, ['-e', 'process.stdout.write(require("playwright")[process.argv[1]].executablePath())', browser],
76
+ { cwd: location.directory, env: environment, timeoutMs: 10000 });
77
+ const executable = probe.stdout.trim();
78
+ if (!fs.existsSync(executable)) throw new CommandError('executor_browser_missing', 'Browser installation did not produce its declared executable.');
79
+ atomicJson(location.readyFile, { schemaVersion: 'aab.executor-package/v1', digest: location.digest,
80
+ version: location.version, preparedAtMs: Date.now(), browsers: { ...(old?.digest === location.digest ? old.browsers : {}), [browser]: executable } });
81
+ return { ...packageStatus(browser, home), reused: false, elapsedMs: Date.now() - startedAtMs };
82
+ } finally { if (locked) lock.exec('ROLLBACK'); lock.close(); }
83
+ }
84
+
85
+ module.exports = { executorHome, atomicJson, readJson, packageLocation, packageStatus, preparePackage };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { fork } = require('node:child_process');
6
+ const { randomUUID } = require('node:crypto');
7
+ const { CommandError } = require('../command-errors');
8
+ const { currentExecution, markExecutionDispatched, checkExecution } = require('../shared-kernel/execution-scope');
9
+ const { executorHome, packageLocation, packageStatus, preparePackage, readJson } = require('./managed-runtime');
10
+ const { ReceiptJournal } = require('./receipt-journal');
11
+
12
+ const protocol = 'aab.playwright-worker/v1';
13
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
14
+ const mutation = args => ['open', 'act', 'navigate', 'close'].includes(args.operation);
15
+
16
+ class PlaywrightHost {
17
+ constructor({ home = executorHome() } = {}) { this.home = home; this.sessions = new Map(); this.closed = false; }
18
+
19
+ async run(args) {
20
+ if (this.closed) throw new CommandError('executor_host_closed', 'Browser executor Host is closed.');
21
+ for (const [id, worker] of this.sessions) if (worker.closed) this.sessions.delete(id);
22
+ if (args.operation === 'status') return { ...packageStatus(args.browser || 'chromium', this.home),
23
+ sessions: [...this.sessions.values()].map(worker => ({ ...worker.identity, state: worker.closed ? 'closed' : 'running' })) };
24
+ if (args.operation === 'prepare') return preparePackage(args.browser || 'chromium', { home: this.home });
25
+ if (args.operation === 'open') {
26
+ let url;
27
+ try { url = new URL(args.url); } catch { /* rejected below */ }
28
+ if (!url || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new CommandError('invalid_argument', 'Browser URLs must use HTTP(S) without embedded credentials.', { field: 'url' });
29
+ const status = packageStatus(args.browser || 'chromium', this.home);
30
+ if (!status.available) throw new CommandError('executor_not_prepared', 'Prepare this browser executor before opening a session.', { details: status });
31
+ if (this.sessions.size >= 16) throw new CommandError('executor_session_capacity', 'Close a browser executor session before opening another.');
32
+ const identity = { sessionId: randomUUID(), runtimeEpoch: randomUUID() };
33
+ const directory = path.join(this.home, 'sessions', 'playwright', identity.sessionId);
34
+ const location = packageLocation('playwright', this.home);
35
+ const worker = new WorkerClient(location, directory, identity);
36
+ this.sessions.set(identity.sessionId, worker);
37
+ try {
38
+ const result = await worker.request(args);
39
+ if (!result.ok) { await worker.stop(); this.sessions.delete(identity.sessionId); }
40
+ return result;
41
+ } catch (error) { await worker.stop(); this.sessions.delete(identity.sessionId); throw error; }
42
+ }
43
+ for (const field of ['sessionId', 'runtimeEpoch', 'targetId']) if (!uuid.test(args[field] || '')) throw new CommandError('executor_session_mismatch', 'Executor identity is invalid.', { field });
44
+ if (args.operation === 'receipt') {
45
+ const directory = path.join(this.home, 'sessions', 'playwright', args.sessionId);
46
+ const descriptor = readJson(path.join(directory, 'session.json'));
47
+ if (!descriptor || descriptor.runtimeEpoch !== args.runtimeEpoch) throw new CommandError('executor_session_mismatch', 'No retained executor session matches this identity.');
48
+ const receipt = new ReceiptJournal(path.join(directory, 'receipts'),
49
+ { sessionId: args.sessionId, runtimeEpoch: args.runtimeEpoch, targetId: args.targetId }).receipt(args.actionId);
50
+ return { ok: Boolean(receipt), ...(receipt ? { receipt } : { error: 'executor_receipt_not_found' }) };
51
+ }
52
+ const worker = this.sessions.get(args.sessionId);
53
+ if (!worker || worker.closed || worker.identity.runtimeEpoch !== args.runtimeEpoch) throw new CommandError('executor_session_unavailable', 'This browser session is no longer live. Retained receipts remain queryable.');
54
+ if (args.operation === 'navigate') {
55
+ let url;
56
+ try { url = new URL(args.url); } catch { /* rejected below */ }
57
+ if (!url || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new CommandError('invalid_argument', 'Navigation requires an HTTP(S) URL without credentials.', { field: 'url' });
58
+ }
59
+ const request = { ...args };
60
+ if (['act', 'navigate'].includes(args.operation)) request.actionId = args.actionId || args.runtimeActionId || randomUUID();
61
+ const result = await worker.request(request);
62
+ if (args.operation === 'close' && result.ok) { await worker.stop(); this.sessions.delete(args.sessionId); }
63
+ return result;
64
+ }
65
+
66
+ async close() {
67
+ this.closed = true;
68
+ await Promise.allSettled([...this.sessions.values()].map(worker => worker.stop()));
69
+ this.sessions.clear();
70
+ }
71
+ }
72
+
73
+ class WorkerClient {
74
+ constructor(location, directory, identity) {
75
+ this.identity = identity; this.pending = new Map(); this.closed = false;
76
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
77
+ this.child = fork(path.join(__dirname, 'playwright-worker.js'), [location.directory, directory, identity.sessionId, identity.runtimeEpoch],
78
+ { stdio: ['ignore', 'ignore', 'pipe', 'ipc'], detached: process.platform !== 'win32', serialization: 'json',
79
+ env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: location.browsersPath } });
80
+ this.stderr = '';
81
+ this.child.stderr.on('data', data => { this.stderr = (this.stderr + data.toString()).slice(-8192); });
82
+ this.ready = new Promise((resolve, reject) => { this.resolveReady = resolve; this.rejectReady = reject; });
83
+ this.ready.catch(() => {});
84
+ this.ended = new Promise(resolve => this.child.once('close', () => {
85
+ this.closed = true;
86
+ const error = new CommandError('executor_worker_closed', 'The browser worker closed before completion.', { details: { stderr: this.stderr } });
87
+ this.rejectReady(error);
88
+ for (const pending of this.pending.values()) pending.fail(error);
89
+ this.pending.clear(); resolve();
90
+ }));
91
+ this.child.on('error', error => this.rejectReady(error));
92
+ this.child.on('message', message => {
93
+ if (message?.protocol !== protocol) return;
94
+ if (message.type === 'ready') { this.resolveReady(); return; }
95
+ const pending = this.pending.get(message.id);
96
+ if (pending) { this.pending.delete(message.id); pending.finish(message.result); }
97
+ });
98
+ this.startTimer = setTimeout(() => { this.rejectReady(new CommandError('executor_start_timeout', 'Browser worker did not initialize.')); void this.stop(); }, 15000);
99
+ this.ready.finally(() => clearTimeout(this.startTimer)).catch(() => {});
100
+ }
101
+
102
+ async request(args) {
103
+ await this.ready;
104
+ checkExecution();
105
+ if (this.closed) throw new CommandError('executor_worker_closed', 'Browser worker is closed.');
106
+ const id = randomUUID(), scope = currentExecution();
107
+ return new Promise((resolve, reject) => {
108
+ let timer, killTimer;
109
+ const clean = () => { clearTimeout(timer); clearTimeout(killTimer); scope?.signal.removeEventListener('abort', abort); };
110
+ const abort = () => {
111
+ if (this.child.connected) this.child.send({ protocol, type: 'cancel', id });
112
+ killTimer ||= setTimeout(() => { void this.stop(); }, 5000);
113
+ };
114
+ this.pending.set(id, {
115
+ finish: result => { clean(); resolve(result); },
116
+ fail: error => { clean(); reject(new CommandError(error.code || 'executor_worker_closed', error.message,
117
+ { dispatched: mutation(args), ambiguous: mutation(args), details: { ...error.details, ...this.identity, actionId: args.actionId } })); },
118
+ });
119
+ scope?.signal.addEventListener('abort', abort, { once: true });
120
+ timer = setTimeout(abort, args.timeoutMs ?? 30000);
121
+ if (mutation(args)) markExecutionDispatched();
122
+ this.child.send({ protocol, type: 'request', id, args }, error => {
123
+ if (error) { const pending = this.pending.get(id); this.pending.delete(id); pending?.fail(error); }
124
+ });
125
+ if (scope?.signal.aborted) abort();
126
+ });
127
+ }
128
+
129
+ stop() {
130
+ if (this.stopPromise) return this.stopPromise;
131
+ this.stopPromise = (async () => {
132
+ clearTimeout(this.startTimer);
133
+ if (this.closed) return;
134
+ const kill = signal => {
135
+ try { if (process.platform === 'win32') this.child.kill(signal); else process.kill(-this.child.pid, signal); }
136
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
137
+ };
138
+ kill('SIGTERM');
139
+ const timer = setTimeout(() => kill('SIGKILL'), 1000);
140
+ try { await this.ended; } finally { clearTimeout(timer); }
141
+ })();
142
+ return this.stopPromise;
143
+ }
144
+ }
145
+
146
+ module.exports = { PlaywrightHost };