@mobileaidev/ai-app-bridge 0.3.5 → 0.3.7
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 +53 -10
- 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 +35 -12
- package/package.json +5 -1
- package/runtime/executors/playwright/package-lock.json +45 -0
- package/runtime/executors/playwright/package.json +8 -0
|
@@ -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 };
|