@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,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { randomUUID, createHash } = require('node:crypto');
|
|
6
|
+
const { createRequire } = require('node:module');
|
|
7
|
+
const { ReceiptJournal } = require('./receipt-journal');
|
|
8
|
+
const { atomicJson } = require('./managed-runtime');
|
|
9
|
+
|
|
10
|
+
const protocol = 'aab.playwright-worker/v1';
|
|
11
|
+
const packageDirectory = process.argv[2];
|
|
12
|
+
const sessionDirectory = process.argv[3];
|
|
13
|
+
const identity = { sessionId: process.argv[4], runtimeEpoch: process.argv[5] };
|
|
14
|
+
const playwright = createRequire(path.join(packageDirectory, 'package.json'))('playwright');
|
|
15
|
+
const pages = new Map();
|
|
16
|
+
const frames = new Map();
|
|
17
|
+
const journals = new Map();
|
|
18
|
+
const events = [];
|
|
19
|
+
let eventSequence = 0;
|
|
20
|
+
let eventBytes = 0;
|
|
21
|
+
let browser;
|
|
22
|
+
let browserContext;
|
|
23
|
+
let closing = false;
|
|
24
|
+
let active;
|
|
25
|
+
let tail = Promise.resolve();
|
|
26
|
+
const requests = new Map();
|
|
27
|
+
|
|
28
|
+
function failure(code, message, details) { return Object.assign(new Error(message), { code, details }); }
|
|
29
|
+
function emitEvent(type, data) {
|
|
30
|
+
const event = { sequence: ++eventSequence, observedAtMs: Date.now(), type, ...data };
|
|
31
|
+
const bytes = Buffer.byteLength(JSON.stringify(event));
|
|
32
|
+
events.push({ event, bytes }); eventBytes += bytes;
|
|
33
|
+
while (events.length > 1000 || eventBytes > 1024 * 1024) eventBytes -= events.shift().bytes;
|
|
34
|
+
}
|
|
35
|
+
function bindFrame(frame, targetId) {
|
|
36
|
+
let entry = frames.get(frame);
|
|
37
|
+
if (!entry) { entry = { frameId: randomUUID(), documentId: randomUUID(), targetId, frame }; frames.set(frame, entry); }
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
function bindPage(page) {
|
|
41
|
+
if (pages.size >= 64) {
|
|
42
|
+
emitEvent('page-capacity', { maximum: 64 });
|
|
43
|
+
void page.close().catch(error => emitEvent('page-close-error', { message: error.message }));
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const targetId = randomUUID();
|
|
47
|
+
pages.set(targetId, page);
|
|
48
|
+
for (const frame of page.frames()) bindFrame(frame, targetId);
|
|
49
|
+
page.on('frameattached', frame => bindFrame(frame, targetId));
|
|
50
|
+
page.on('framenavigated', frame => { bindFrame(frame, targetId).documentId = randomUUID(); });
|
|
51
|
+
page.on('framedetached', frame => frames.delete(frame));
|
|
52
|
+
page.on('close', () => { pages.delete(targetId); emitEvent('page-closed', { targetId }); for (const [frame, entry] of frames) if (entry.targetId === targetId) frames.delete(frame); });
|
|
53
|
+
page.on('console', message => emitEvent('console', { targetId, level: message.type(), text: message.text().slice(0, 8192) }));
|
|
54
|
+
page.on('pageerror', error => emitEvent('page-error', { targetId, message: error.message.slice(0, 8192) }));
|
|
55
|
+
page.on('requestfailed', request => emitEvent('request-failed', { targetId, url: request.url().slice(0, 8192), method: request.method(), failure: request.failure()?.errorText }));
|
|
56
|
+
page.on('response', response => emitEvent('response', { targetId, url: response.url().slice(0, 8192), status: response.status() }));
|
|
57
|
+
page.on('dialog', async dialog => {
|
|
58
|
+
const policy = active?.args.targetId === targetId ? active.args.dialog : null;
|
|
59
|
+
emitEvent('dialog', { targetId, dialogType: dialog.type(), message: dialog.message().slice(0, 8192), disposition: policy?.action || 'dismiss' });
|
|
60
|
+
try { if (policy?.action === 'accept') await dialog.accept(policy.promptText); else await dialog.dismiss(); }
|
|
61
|
+
catch (error) { emitEvent('dialog-error', { targetId, message: error.message.slice(0, 8192) }); }
|
|
62
|
+
});
|
|
63
|
+
emitEvent('page-opened', { targetId });
|
|
64
|
+
return targetId;
|
|
65
|
+
}
|
|
66
|
+
function pageFor(args) {
|
|
67
|
+
if (args.sessionId !== identity.sessionId || args.runtimeEpoch !== identity.runtimeEpoch) throw failure('executor_session_mismatch', 'Executor session identity changed.');
|
|
68
|
+
const page = pages.get(args.targetId);
|
|
69
|
+
if (!page || page.isClosed()) throw failure('executor_target_closed', 'The selected browser page is not open.');
|
|
70
|
+
return page;
|
|
71
|
+
}
|
|
72
|
+
function documentFor(args) {
|
|
73
|
+
pageFor(args);
|
|
74
|
+
const entry = [...frames.values()].find(value => value.frameId === args.frameId && value.targetId === args.targetId);
|
|
75
|
+
if (!entry || entry.frame.isDetached() || entry.documentId !== args.documentId) throw failure('reobserve_required', 'The selected frame/document changed; observe again.');
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
async function locate(frame, selector, documentId) {
|
|
79
|
+
// The isolated selector world remembers the document bound by observe().
|
|
80
|
+
// Locator retries stay inside it; a new document can never acquire an old ID.
|
|
81
|
+
const root = frame.locator(`aabdocument=match:${documentId}`);
|
|
82
|
+
switch (selector.by) {
|
|
83
|
+
case 'role': return root.getByRole(selector.value, { name: selector.name, exact: true });
|
|
84
|
+
case 'testId': return root.getByTestId(selector.value);
|
|
85
|
+
case 'text': return root.getByText(selector.value, { exact: true });
|
|
86
|
+
case 'label': return root.getByLabel(selector.value, { exact: true });
|
|
87
|
+
case 'placeholder': return root.getByPlaceholder(selector.value, { exact: true });
|
|
88
|
+
case 'css': {
|
|
89
|
+
// Accept CSS syntax only. Playwright's selector chains could otherwise
|
|
90
|
+
// escape the observed document through an internal selector engine.
|
|
91
|
+
const valid = await frame.evaluate(value => {
|
|
92
|
+
try { document.querySelectorAll(value); return true; } catch { return false; }
|
|
93
|
+
}, selector.value);
|
|
94
|
+
if (!valid) throw failure('executor_selector_invalid', 'The css selector must use standard CSS syntax.');
|
|
95
|
+
return root.locator(`css=${selector.value}`);
|
|
96
|
+
}
|
|
97
|
+
default: throw failure('executor_selector_unsupported', 'Unknown selector kind.');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function unique(frame, selector, documentId) {
|
|
101
|
+
const locator = await locate(frame, selector, documentId);
|
|
102
|
+
const count = await locator.count();
|
|
103
|
+
if (count !== 1) throw failure(count === 0 ? 'target_not_found' : 'ambiguous_target', 'Executor selectors must match exactly one element.', { matches: count });
|
|
104
|
+
return locator;
|
|
105
|
+
}
|
|
106
|
+
function assertDocument(entry, documentId) {
|
|
107
|
+
if (entry.documentId !== documentId || entry.frame.isDetached()) throw failure('reobserve_required', 'The selected document changed during preparation.');
|
|
108
|
+
}
|
|
109
|
+
function journalFor(targetId) {
|
|
110
|
+
let journal = journals.get(targetId);
|
|
111
|
+
if (!journal) { journal = new ReceiptJournal(path.join(sessionDirectory, 'receipts'), { ...identity, targetId }); journals.set(targetId, journal); }
|
|
112
|
+
return journal;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function observe(args, options) {
|
|
116
|
+
const page = pageFor(args);
|
|
117
|
+
const documents = [];
|
|
118
|
+
const pageFrames = page.frames();
|
|
119
|
+
if (pageFrames.length > 64) throw failure('executor_frame_limit', 'This document exceeds the 64-frame observation limit.');
|
|
120
|
+
for (const frame of pageFrames) {
|
|
121
|
+
const entry = bindFrame(frame, args.targetId);
|
|
122
|
+
const documentId = entry.documentId;
|
|
123
|
+
await frame.locator(`aabdocument=bind:${documentId}`).count();
|
|
124
|
+
const snapshot = await frame.locator('body').ariaSnapshotJSON({ ...options, depth: 30 });
|
|
125
|
+
const controls = await frame.locator('input,textarea,select,button,a[href],[contenteditable="true"],[role],[data-testid]').evaluateAll((elements, limit) => elements.slice(0, limit).map(element => ({
|
|
126
|
+
tag: element.tagName.toLowerCase(), role: element.getAttribute('role'), testId: element.getAttribute('data-testid'),
|
|
127
|
+
id: element.id || null, text: typeof element.innerText === 'string' ? element.innerText.slice(0, 512) : null,
|
|
128
|
+
label: element.getAttribute('aria-label'), placeholder: element.getAttribute('placeholder'),
|
|
129
|
+
value: element.matches('input[type="password"]') ? null : ('value' in element ? String(element.value).slice(0, 4096) : null),
|
|
130
|
+
disabled: 'disabled' in element ? element.disabled : element.getAttribute('aria-disabled') === 'true',
|
|
131
|
+
visible: element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }),
|
|
132
|
+
})), args.maxControls ?? 200);
|
|
133
|
+
assertDocument(entry, documentId);
|
|
134
|
+
documents.push({ frameId: entry.frameId, documentId, parentFrameId: frame.parentFrame() ? bindFrame(frame.parentFrame(), args.targetId).frameId : null,
|
|
135
|
+
url: frame.url(), name: frame.name(), snapshot, controls });
|
|
136
|
+
}
|
|
137
|
+
const result = { ok: true, ...identity, targetId: args.targetId, url: page.url(), title: await page.title(),
|
|
138
|
+
documents, pages: [...pages].filter(([, item]) => !item.isClosed()).map(([targetId, item]) => ({ targetId, url: item.url() })),
|
|
139
|
+
scope: 'browser-dom', engine: 'playwright', observedAtMs: Date.now() };
|
|
140
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 4 * 1024 * 1024) throw failure('executor_observation_too_large', 'Browser observation exceeds 4 MiB.');
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function mutate(args, options) {
|
|
145
|
+
const actionId = args.actionId;
|
|
146
|
+
const journal = journalFor(args.targetId);
|
|
147
|
+
const begun = journal.begin(actionId, { operation: args.operation, targetId: args.targetId, frameId: args.frameId, documentId: args.documentId,
|
|
148
|
+
...(args.action ? { action: args.action } : {}), ...(args.url ? { url: args.url } : {}), ...(args.dialog ? { dialog: args.dialog } : {}) });
|
|
149
|
+
if (!begun.fresh) {
|
|
150
|
+
if (begun.receipt.phase === 'completed') return { ...begun.receipt.result, replayed: true, executionReceipt: begun.receipt };
|
|
151
|
+
throw failure('executor_action_unresolved', 'This action was already started. Query its original receipt; do not replay it.');
|
|
152
|
+
}
|
|
153
|
+
let dispatched = false;
|
|
154
|
+
let result;
|
|
155
|
+
const startedAtMs = Date.now();
|
|
156
|
+
try {
|
|
157
|
+
const entry = documentFor(args);
|
|
158
|
+
if (args.operation === 'navigate') {
|
|
159
|
+
const page = pageFor(args);
|
|
160
|
+
if (entry.frame !== page.mainFrame()) throw failure('executor_navigation_scope', 'Navigation requires the observed main document.');
|
|
161
|
+
dispatched = true;
|
|
162
|
+
await page.goto(args.url, { ...options, waitUntil: 'domcontentloaded' });
|
|
163
|
+
} else {
|
|
164
|
+
const action = args.action;
|
|
165
|
+
const locator = await unique(entry.frame, action.selector, args.documentId);
|
|
166
|
+
const destination = action.type === 'drag' ? await unique(entry.frame, action.destination, args.documentId) : null;
|
|
167
|
+
assertDocument(entry, args.documentId);
|
|
168
|
+
if (options.signal.aborted) throw failure('executor_cancelled', 'Executor action was cancelled before dispatch.');
|
|
169
|
+
dispatched = true;
|
|
170
|
+
switch (action.type) {
|
|
171
|
+
case 'click': await locator.click(options); break;
|
|
172
|
+
case 'doubleClick': await locator.dblclick(options); break;
|
|
173
|
+
case 'hover': await locator.hover(options); break;
|
|
174
|
+
case 'fill': await locator.fill(action.text, options); break;
|
|
175
|
+
case 'type': await locator.pressSequentially(action.text, options); break;
|
|
176
|
+
case 'press': await locator.press(action.text, options); break;
|
|
177
|
+
case 'check': await locator.setChecked(action.checked, options); break;
|
|
178
|
+
case 'select': await locator.selectOption(action.values, options); break;
|
|
179
|
+
case 'scrollIntoView': await locator.scrollIntoViewIfNeeded(options); break;
|
|
180
|
+
case 'drag': await locator.dragTo(destination, options); break;
|
|
181
|
+
case 'upload': await locator.setInputFiles(action.files, options); break;
|
|
182
|
+
default: throw failure('executor_action_unsupported', 'Unknown browser action.');
|
|
183
|
+
}
|
|
184
|
+
if (action.type === 'fill') {
|
|
185
|
+
const value = await locator.evaluate(element => element.isContentEditable ? element.innerText : element.value);
|
|
186
|
+
if (value !== action.text) throw failure('executor_postcondition_failed', 'The editor did not retain the requested value.', { observedValue: value });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
result = { ok: true, ...identity, targetId: args.targetId, actionId, dispatched: true, ambiguous: false,
|
|
190
|
+
mechanism: args.operation === 'navigate' ? 'browser-navigation' : `playwright-${args.action.type}` };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
result = { ok: false, ...identity, targetId: args.targetId, actionId, error: error.code || 'executor_action_failed',
|
|
193
|
+
message: error.message, details: error.details, dispatched, ambiguous: dispatched };
|
|
194
|
+
}
|
|
195
|
+
result.timings = { executionMs: Date.now() - startedAtMs };
|
|
196
|
+
let receipt;
|
|
197
|
+
try { receipt = journal.finish(begun.receipt, result); }
|
|
198
|
+
catch (error) { error.dispatched = dispatched; error.ambiguous = dispatched; throw error; }
|
|
199
|
+
return { ...result, executionReceipt: receipt };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function handle(args, controller) {
|
|
203
|
+
const options = { timeout: args.timeoutMs ?? 30000, signal: controller.signal };
|
|
204
|
+
if (args.operation === 'open') {
|
|
205
|
+
if (browser) throw failure('executor_already_open', 'Worker already owns a browser.');
|
|
206
|
+
await playwright.selectors.register('aabdocument', () => {
|
|
207
|
+
const identities = new WeakMap();
|
|
208
|
+
const query = (root, selector) => {
|
|
209
|
+
const document = root.nodeType === 9 ? root : root.ownerDocument;
|
|
210
|
+
const separator = selector.indexOf(':');
|
|
211
|
+
const operation = selector.slice(0, separator), id = selector.slice(separator + 1);
|
|
212
|
+
if (operation === 'bind') identities.set(document, id);
|
|
213
|
+
return identities.get(document) === id ? document.documentElement : null;
|
|
214
|
+
};
|
|
215
|
+
return { query, queryAll: (root, selector) => { const element = query(root, selector); return element ? [element] : []; } };
|
|
216
|
+
}, { contentScript: true });
|
|
217
|
+
browser = await playwright[args.browser || 'chromium'].launch({ headless: args.headless ?? true, timeout: options.timeout });
|
|
218
|
+
if (controller.signal.aborted) { await browser.close(); throw failure('executor_cancelled', 'Browser preparation was cancelled.'); }
|
|
219
|
+
browserContext = await browser.newContext({ viewport: args.viewport || { width: 1280, height: 900 } });
|
|
220
|
+
browserContext.on('page', bindPage);
|
|
221
|
+
const page = await browserContext.newPage();
|
|
222
|
+
const targetId = [...pages].find(([, item]) => item === page)[0];
|
|
223
|
+
await page.goto(args.url, { ...options, waitUntil: 'domcontentloaded' });
|
|
224
|
+
const descriptor = { schemaVersion: protocol, ...identity, targetId, engine: 'playwright', version: browser.version(),
|
|
225
|
+
browser: args.browser || 'chromium', openedAtMs: Date.now(), state: 'open' };
|
|
226
|
+
atomicJson(path.join(sessionDirectory, 'session.json'), descriptor);
|
|
227
|
+
return { ok: true, ...descriptor, capabilities: ['observe', 'act', 'navigate', 'wait', 'screenshot', 'events', 'receipt', 'close'],
|
|
228
|
+
limitations: ['closed-shadow-root', 'native-operating-system-ui'], dialogDefault: 'dismiss' };
|
|
229
|
+
}
|
|
230
|
+
if (args.operation === 'observe') return observe(args, options);
|
|
231
|
+
if (args.operation === 'act' || args.operation === 'navigate') return mutate(args, options);
|
|
232
|
+
if (args.operation === 'wait') {
|
|
233
|
+
const entry = documentFor(args), locator = await locate(entry.frame, args.selector, args.documentId);
|
|
234
|
+
await locator.waitFor({ ...options, state: args.state || 'visible' });
|
|
235
|
+
if (args.text !== undefined) {
|
|
236
|
+
const deadline = Date.now() + options.timeout;
|
|
237
|
+
while (await locator.textContent(options) !== args.text) {
|
|
238
|
+
assertDocument(entry, args.documentId);
|
|
239
|
+
if (controller.signal.aborted || Date.now() >= deadline) throw failure('executor_wait_timeout', 'Expected text was not observed.');
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
assertDocument(entry, args.documentId);
|
|
244
|
+
return { ok: true, ...identity, targetId: args.targetId, condition: 'matched', observedAtMs: Date.now() };
|
|
245
|
+
}
|
|
246
|
+
if (args.operation === 'screenshot') {
|
|
247
|
+
const page = pageFor(args);
|
|
248
|
+
const bytes = await page.screenshot({ ...options, fullPage: args.fullPage ?? false, type: 'png' });
|
|
249
|
+
fs.mkdirSync(path.dirname(args.outFile), { recursive: true });
|
|
250
|
+
fs.writeFileSync(args.outFile, bytes, { flag: 'wx', mode: 0o600 });
|
|
251
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
252
|
+
return { ok: true, ...identity, targetId: args.targetId, outFile: args.outFile, artifact: { sha256, bytes: bytes.length },
|
|
253
|
+
refs: [{ stream: 'screenshot', screenshotId: args.outFile, sha256 }] };
|
|
254
|
+
}
|
|
255
|
+
if (args.operation === 'events') {
|
|
256
|
+
pageFor(args);
|
|
257
|
+
const after = args.afterSequence ?? 0;
|
|
258
|
+
const selected = events.filter(({ event }) => event.sequence > after).slice(0, args.limit ?? 100);
|
|
259
|
+
return { ok: true, ...identity, items: selected.map(({ event }) => event),
|
|
260
|
+
nextSequence: selected.at(-1)?.event.sequence ?? after, throughSequence: eventSequence,
|
|
261
|
+
gap: events.length > 0 && after < events[0].event.sequence - 1 };
|
|
262
|
+
}
|
|
263
|
+
if (args.operation === 'close') { pageFor(args); await shutdown(); return { ok: true, ...identity, closed: true }; }
|
|
264
|
+
throw failure('executor_operation_unsupported', 'Unknown worker operation.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let shutdownPromise;
|
|
268
|
+
function shutdown() {
|
|
269
|
+
if (shutdownPromise) return shutdownPromise;
|
|
270
|
+
closing = true;
|
|
271
|
+
active?.controller.abort();
|
|
272
|
+
shutdownPromise = (async () => {
|
|
273
|
+
await browser?.close();
|
|
274
|
+
atomicJson(path.join(sessionDirectory, 'closed.json'), { ...identity, closedAtMs: Date.now() });
|
|
275
|
+
})();
|
|
276
|
+
return shutdownPromise;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
process.on('message', message => {
|
|
280
|
+
if (message?.protocol !== protocol) return;
|
|
281
|
+
if (message.type === 'cancel') { requests.get(message.id)?.controller.abort(); return; }
|
|
282
|
+
if (message.type !== 'request') return;
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
const request = { id: message.id, controller, args: message.args };
|
|
285
|
+
requests.set(message.id, request);
|
|
286
|
+
const deadline = Date.now() + (message.args.timeoutMs ?? 30000);
|
|
287
|
+
const timer = setTimeout(() => controller.abort(), message.args.timeoutMs ?? 30000);
|
|
288
|
+
tail = tail.then(async () => {
|
|
289
|
+
active = request;
|
|
290
|
+
try {
|
|
291
|
+
if (closing) throw failure('executor_closed', 'Executor worker is closing.');
|
|
292
|
+
if (controller.signal.aborted || Date.now() >= deadline) throw failure('executor_cancelled', 'Executor request was cancelled while queued.');
|
|
293
|
+
const result = await handle({ ...message.args, timeoutMs: Math.max(1, deadline - Date.now()) }, controller);
|
|
294
|
+
if (process.connected) process.send({ protocol, id: message.id, result });
|
|
295
|
+
if (message.args.operation === 'close') process.disconnect();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const dispatched = error.dispatched ?? (message.args.operation === 'open' && Boolean(browser));
|
|
298
|
+
if (process.connected) process.send({ protocol, id: message.id, result: { ok: false, error: error.code || 'executor_failed', message: error.message,
|
|
299
|
+
details: error.details, dispatched, ambiguous: error.ambiguous ?? dispatched } });
|
|
300
|
+
} finally { clearTimeout(timer); requests.delete(message.id); active = null; }
|
|
301
|
+
}).catch(async error => {
|
|
302
|
+
if (process.connected) process.send({ protocol, id: message.id, result: { ok: false, error: error.code || 'executor_failed', message: error.message } });
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
process.on('disconnect', () => { shutdown().finally(() => process.exit(0)); });
|
|
306
|
+
process.on('SIGTERM', () => { shutdown().finally(() => process.exit(0)); });
|
|
307
|
+
process.send({ protocol, type: 'ready', ...identity });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { CommandError } = require('../command-errors');
|
|
7
|
+
const { atomicJson, readJson } = require('./managed-runtime');
|
|
8
|
+
|
|
9
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
function canonical(value) {
|
|
11
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
12
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class ReceiptJournal {
|
|
17
|
+
constructor(directory, identity, { maxActions = 4096 } = {}) {
|
|
18
|
+
this.directory = directory;
|
|
19
|
+
this.identity = identity;
|
|
20
|
+
this.maxActions = maxActions;
|
|
21
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
receipt(actionId) {
|
|
25
|
+
const value = readJson(path.join(this.directory, `${hash(actionId)}.json`));
|
|
26
|
+
if (value && (value.actionId !== actionId || JSON.stringify(value.identity) !== JSON.stringify(this.identity))) {
|
|
27
|
+
throw new CommandError('executor_receipt_identity_mismatch', 'Receipt does not belong to this executor session.');
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
begin(actionId, request) {
|
|
33
|
+
const digest = hash(JSON.stringify(canonical(request)));
|
|
34
|
+
const existing = this.receipt(actionId);
|
|
35
|
+
if (existing) {
|
|
36
|
+
if (existing.requestDigest !== digest) throw new CommandError('idempotency_conflict', 'actionId was already used with different executor arguments.');
|
|
37
|
+
return { fresh: false, receipt: existing };
|
|
38
|
+
}
|
|
39
|
+
if (fs.readdirSync(this.directory).filter(name => name.endsWith('.json')).length >= this.maxActions) {
|
|
40
|
+
throw new CommandError('executor_receipt_capacity', 'Close this session and open a new one; retained action receipts are full.');
|
|
41
|
+
}
|
|
42
|
+
const receipt = { schemaVersion: 'aab.executor-receipt/v1', identity: this.identity, actionId, requestDigest: digest,
|
|
43
|
+
phase: 'started', preparedAtMs: Date.now(), settled: false, dispatched: null };
|
|
44
|
+
atomicJson(path.join(this.directory, `${hash(actionId)}.json`), receipt);
|
|
45
|
+
return { fresh: true, receipt };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
finish(receipt, result) {
|
|
49
|
+
const completed = { ...receipt, phase: 'completed', completedAtMs: Date.now(), settled: true,
|
|
50
|
+
dispatched: result.dispatched === true, result };
|
|
51
|
+
atomicJson(path.join(this.directory, `${hash(receipt.actionId)}.json`), completed);
|
|
52
|
+
return completed;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { ReceiptJournal, canonical };
|
package/bin/feedback-probe.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
async function runWithFeedbackProbe({
|
|
3
|
+
async function runWithFeedbackProbe(options = {}) {
|
|
4
|
+
const { command, args = {}, runner } = options;
|
|
5
|
+
if (!isFullFeedback(args.feedback) || !isMutationCommand(command)
|
|
6
|
+
|| command === 'launch-app' || command === 'launch-activity') return runFeedback(options);
|
|
7
|
+
const observationCommand = String(command).startsWith('ios-') ? 'ios-ui-observation'
|
|
8
|
+
: String(command).startsWith('web-') ? 'web-ui-observation' : 'ui-observation';
|
|
9
|
+
const targetArgs = pick(args, ['serial', 'adb', 'packageName', 'port', 'deviceId', 'bundleId', 'runtimeUrl', 'iosHost', 'iosPort', 'sessionId', 'runtimeEpoch', 'targetId']);
|
|
10
|
+
if (observationCommand !== 'web-ui-observation') targetArgs.provider = String(command).includes('flutter') ? 'flutter' : 'native';
|
|
11
|
+
const started = await runner(observationCommand, { ...targetArgs, operation: 'start', durationMs: 5000 });
|
|
12
|
+
if (started?.ok !== true || started.active !== true || typeof started.leaseId !== 'string') {
|
|
13
|
+
return { result: { ok: false, error: 'ui_observation_unavailable', dispatched: false, ambiguous: false, details: started ?? null },
|
|
14
|
+
observation: { mode: 'full', inconclusive: true }, evidence: [] };
|
|
15
|
+
}
|
|
16
|
+
let output;
|
|
17
|
+
try {
|
|
18
|
+
output = await runFeedback(options);
|
|
19
|
+
return output;
|
|
20
|
+
} finally {
|
|
21
|
+
let cleanup;
|
|
22
|
+
try { cleanup = await runner(observationCommand, { ...targetArgs, operation: 'stop', leaseId: started.leaseId }); }
|
|
23
|
+
catch (error) { cleanup = { ok: false, error: error.code || 'ui_observation_cleanup_failed' }; }
|
|
24
|
+
if (output?.observation) output.observation.window = { leaseId: started.leaseId, maxDurationMs: 5000, cleanup };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function runFeedback({
|
|
4
29
|
command,
|
|
5
30
|
args = {},
|
|
6
31
|
runner,
|
|
@@ -130,6 +155,7 @@ function isFullFeedback(value) {
|
|
|
130
155
|
function isMutationCommand(command) {
|
|
131
156
|
const normalized = String(command || '').toLowerCase();
|
|
132
157
|
const readOnly = new Set([
|
|
158
|
+
'ui-observation', 'ios-ui-observation', 'web-ui-observation',
|
|
133
159
|
'status', 'tree', 'uia-tree', 'screenshot', 'logs', 'network', 'state', 'events',
|
|
134
160
|
'logcat', 'keyboard-state', 'wait-text', 'flutter-tree', 'flutter-nodes', 'h5-dom',
|
|
135
161
|
'flutter-h5-dom', 'webview-pages', 'webview-network', 'webview-console',
|
package/bin/ios-provider.js
CHANGED
|
@@ -95,6 +95,11 @@ class IOSBridgeProvider {
|
|
|
95
95
|
return await this.launchApp(args, context);
|
|
96
96
|
case 'ios-status':
|
|
97
97
|
return await this.runtimeGet(args, '/v1/status');
|
|
98
|
+
case 'ios-ui-observation': {
|
|
99
|
+
const port = await this.runtimePort(args, context);
|
|
100
|
+
const result = await port.post(require('./ui-observation').path(args), require('./ui-observation').request(args));
|
|
101
|
+
return { ...result, endpoint: port.endpoint.baseUrl, device: port.endpoint.device, runtimeBinding: port.endpoint.runtimeBinding };
|
|
102
|
+
}
|
|
98
103
|
case 'ios-tree':
|
|
99
104
|
return await this.runtimeGet(args, '/v1/view/tree');
|
|
100
105
|
case 'ios-logs':
|
|
@@ -468,7 +473,7 @@ class IOSBridgeProvider {
|
|
|
468
473
|
const endpoint = port.endpoint;
|
|
469
474
|
let response;
|
|
470
475
|
if (method === 'POST') {
|
|
471
|
-
const status = await port.get('/v1/status');
|
|
476
|
+
const status = await port.get(endpointPath === '/v1/flutter/action' ? '/v1/flutter/snapshot' : '/v1/status');
|
|
472
477
|
if (status.ok === false) return { ...status, dispatched: false, ambiguous: false };
|
|
473
478
|
const kind = endpointPath === '/v1/h5/action' ? 'h5' : 'flutter';
|
|
474
479
|
const target = { platform: 'ios', deviceId: endpoint.device.udid, bundleId: args.bundleId,
|
|
@@ -521,7 +526,7 @@ class IOSBridgeProvider {
|
|
|
521
526
|
}
|
|
522
527
|
|
|
523
528
|
async flutterTree(args = {}) {
|
|
524
|
-
const status = await this.runtimeGet(args, '/v1/
|
|
529
|
+
const status = await this.runtimeGet(args, '/v1/flutter/snapshot');
|
|
525
530
|
if (status.ok === false) return status;
|
|
526
531
|
return {
|
|
527
532
|
ok: true,
|
|
@@ -552,7 +557,7 @@ class IOSBridgeProvider {
|
|
|
552
557
|
|
|
553
558
|
async flutterControl(command, args, context = {}) {
|
|
554
559
|
const port = await this.runtimePort(args, context);
|
|
555
|
-
const status = await port.get('/v1/
|
|
560
|
+
const status = await port.get('/v1/flutter/snapshot');
|
|
556
561
|
if (status.ok !== true) return { ...status, dispatched: false, ambiguous: false };
|
|
557
562
|
const action = flutterControls.get(command);
|
|
558
563
|
let payload = { action };
|
|
@@ -4,7 +4,7 @@ const { CommandError } = require('./command-errors');
|
|
|
4
4
|
const { currentExecution } = require('./shared-kernel/execution-scope');
|
|
5
5
|
|
|
6
6
|
const schemaVersion = 'aab.ios-runtime/v1';
|
|
7
|
-
const sdkCommands = new Set(['ios-status', 'ios-tree', 'ios-logs', 'ios-network', 'ios-state', 'ios-events',
|
|
7
|
+
const sdkCommands = new Set(['ios-ui-observation', 'ios-status', 'ios-tree', 'ios-logs', 'ios-network', 'ios-state', 'ios-events',
|
|
8
8
|
'ios-h5-dom', 'ios-h5-eval', 'ios-h5-click', 'ios-h5-input', 'ios-h5-scroll', 'ios-flutter-tree', 'ios-flutter-nodes', 'ios-flutter-action',
|
|
9
9
|
'ios-tap-flutter', 'ios-input-flutter-text', 'ios-scroll-flutter', 'ios-flutter-back', 'ios-flutter-hide-keyboard']);
|
|
10
10
|
const fields = ['schemaVersion', 'bundleId', 'runtimeEpoch', 'processId', 'port'];
|
package/bin/runtime-directory.js
CHANGED
|
@@ -51,7 +51,7 @@ function runtimeLocation() {
|
|
|
51
51
|
|
|
52
52
|
function runtimeIdentity(location = runtimeLocation()) {
|
|
53
53
|
const names = ['AI_APP_BRIDGE_ADB_TIMEOUT_MS', 'AI_APP_BRIDGE_DEVICECTL', 'AI_APP_BRIDGE_FACT_CACHE',
|
|
54
|
-
'AI_APP_BRIDGE_IOS_TEAM_ID', 'AI_APP_BRIDGE_PYTHON', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'DEVELOPMENT_TEAM', 'DEVELOPER_DIR', 'XCODEBUILD'];
|
|
54
|
+
'AI_APP_BRIDGE_IOS_TEAM_ID', 'AI_APP_BRIDGE_PYTHON', 'AI_APP_BRIDGE_EXECUTOR_HOME', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'DEVELOPMENT_TEAM', 'DEVELOPER_DIR', 'XCODEBUILD'];
|
|
55
55
|
const config = { facts: location.facts, profile: location.profile, ownership: canonicalPath(ownershipDirectory()),
|
|
56
56
|
adb: executablePath(process.env.ADB || 'adb') ?? { unavailable: process.env.ADB || 'adb' },
|
|
57
57
|
environment: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])) };
|
|
@@ -19,6 +19,14 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
|
|
|
19
19
|
const recovered = await lease.reconcile(args.serial, async pending => {
|
|
20
20
|
if (args.operation === 'cancel-install' && (pending.kind !== 'android-install' || pending.actionId !== args.actionId))
|
|
21
21
|
return { settled: false, error: 'install_action_mismatch', actionId: pending.actionId };
|
|
22
|
+
if (pending.kind === 'android-test-executor') {
|
|
23
|
+
try { return await require('../executors/android-host').recoverAndroidExecutor(pending); }
|
|
24
|
+
catch (error) { return { settled: false, error: error.code || 'executor_completion_query_failed', message: error.message }; }
|
|
25
|
+
}
|
|
26
|
+
if (pending.kind === 'flutter-test-executor') {
|
|
27
|
+
try { return await require('../executors/flutter-host').recoverFlutterExecutor(pending); }
|
|
28
|
+
catch (error) { return { settled: false, error: error.code || 'executor_completion_query_failed', message: error.message }; }
|
|
29
|
+
}
|
|
22
30
|
if (pending.kind === 'uia-node') {
|
|
23
31
|
if (!uiaProtocol.validIdentity(pending) || pending.target.serial !== args.serial) return { settled: false, error: 'invalid_uia_execution_identity' };
|
|
24
32
|
try {
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { checkExecution } = require('./execution-scope');
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// sharing its focus owner. Older trees keep their window-order semantics.
|
|
5
|
+
// The SDK owns Android window topology and publishes its foreground decision.
|
|
6
|
+
// Focus ownership is an input permission, not an Activity/window relationship.
|
|
8
7
|
|
|
9
8
|
function visible(node) {
|
|
10
9
|
return node && (node.effectiveVisible === true || node.visible === true)
|
|
@@ -21,12 +20,13 @@ function foregroundNativeWindow(rawTree) {
|
|
|
21
20
|
if (!rawTree || typeof rawTree !== 'object') return null;
|
|
22
21
|
const windows = Array.isArray(rawTree.windows) ? rawTree.windows : [];
|
|
23
22
|
if (windows.length) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const id = rawTree.foregroundWindowId;
|
|
24
|
+
if (typeof id !== 'string' || !id.trim()) return null;
|
|
25
|
+
const matches = windows.map((window, index) => window?.windowId === id ? index : -1).filter(index => index >= 0);
|
|
26
|
+
if (matches.length !== 1) return null;
|
|
27
|
+
const [index] = matches;
|
|
29
28
|
const window = windows[index];
|
|
29
|
+
if (explicitlyHidden(window?.root)) return null;
|
|
30
30
|
return { index, root: window?.root, type: window?.type, windowId: window?.windowId,
|
|
31
31
|
bounds: window && Object.hasOwn(window, 'bounds') ? window.bounds : window?.root?.bounds };
|
|
32
32
|
}
|
|
@@ -45,6 +45,9 @@ function explicitlyHidden(node) {
|
|
|
45
45
|
|
|
46
46
|
function selectNativeNode(rawTree, spec, editable) {
|
|
47
47
|
const reject = (error) => ({ ok: false, error, dispatched: false });
|
|
48
|
+
if (rawTree?.windows?.length && (typeof rawTree.foregroundWindowId !== 'string' || !rawTree.foregroundWindowId.trim())) {
|
|
49
|
+
return reject('native_window_metadata_unavailable');
|
|
50
|
+
}
|
|
48
51
|
const window = nativeWindow(rawTree);
|
|
49
52
|
if (!window) return reject('visible_observed_window_required');
|
|
50
53
|
const selector = spec.selector || (typeof spec.text === 'string' ? { text: spec.text } : null);
|
|
@@ -205,6 +205,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
async function ensureLocked(rotate) {
|
|
208
|
+
await require('../executors/automation-owner').assertAvailable(serial);
|
|
208
209
|
const asset = bundle();
|
|
209
210
|
const previous = await readJson(`${root}/runtime.json`);
|
|
210
211
|
let peer = previous === null ? null : descriptor(previous);
|
|
@@ -293,6 +294,40 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
return {
|
|
297
|
+
async withInstrumentation(descriptorFile, start) {
|
|
298
|
+
return withConnectionLock(async () => {
|
|
299
|
+
const automation = require('../executors/automation-owner');
|
|
300
|
+
await automation.assertAvailable(serial);
|
|
301
|
+
const value = await readJson(`${root}/runtime.json`);
|
|
302
|
+
if (value !== null) {
|
|
303
|
+
const peer = descriptor(value);
|
|
304
|
+
if (peer.running) {
|
|
305
|
+
const connection = await connect(peer);
|
|
306
|
+
const status = await statusOf(connection);
|
|
307
|
+
if (status.pending !== 0 || status.acknowledged !== status.count || status.activeActionId !== null)
|
|
308
|
+
throw failure('uia_runtime_pending_actions', 'Settle and acknowledge the original UIA actions before opening instrumentation.');
|
|
309
|
+
await stopRuntime(connection);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const claim = automation.claim(serial, descriptorFile);
|
|
313
|
+
try {
|
|
314
|
+
const result = await start();
|
|
315
|
+
if (result?.ok === false && result.dispatched === false && result.ambiguous === false) automation.release(serial, claim.sessionId);
|
|
316
|
+
return result;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error.dispatched === false && error.ambiguous === false) automation.release(serial, claim.sessionId);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
async releaseInstrumentation(sessionId) {
|
|
324
|
+
return withConnectionLock(async () => {
|
|
325
|
+
const automation = require('../executors/automation-owner');
|
|
326
|
+
const owner = automation.owner(serial);
|
|
327
|
+
if (owner && owner.sessionId !== sessionId) throw failure('executor_automation_owner_changed', 'Another test session owns UiAutomation.');
|
|
328
|
+
await automation.assertAvailable(serial);
|
|
329
|
+
});
|
|
330
|
+
},
|
|
296
331
|
ensure, post,
|
|
297
332
|
async observe() {
|
|
298
333
|
const connection = await withConnectionLock(() => ensureLocked(true));
|
|
@@ -342,6 +377,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
342
377
|
return withConnectionLock(async () => {
|
|
343
378
|
const value = await readJson(`${root}/runtime.json`);
|
|
344
379
|
if (operation === 'start') {
|
|
380
|
+
await require('../executors/automation-owner').assertAvailable(serial);
|
|
345
381
|
const asset = bundle(), destination = await installAsset(asset);
|
|
346
382
|
const ownerRaw = await shell(`CLASSPATH=${quote(destination)} app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} owner-status`);
|
|
347
383
|
let owner;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const commands = new Set(['ui-observation', 'ios-ui-observation', 'web-ui-observation']);
|
|
4
|
+
|
|
5
|
+
function schema(command, optionTypes) {
|
|
6
|
+
const target = command === 'web-ui-observation' ? ['sessionId', 'runtimeEpoch', 'targetId', 'timeoutMs']
|
|
7
|
+
: command === 'ios-ui-observation' ? ['deviceId', 'bundleId', 'runtimeUrl', 'iosHost', 'iosPort', 'timeoutMs', 'devicectl']
|
|
8
|
+
: ['serial', 'packageName', 'port', 'adb', 'timeoutMs'];
|
|
9
|
+
const properties = Object.fromEntries(target.map(name => [name, optionTypes[name]]));
|
|
10
|
+
Object.assign(properties, { operation: { enum: ['start', 'status', 'stop'] },
|
|
11
|
+
durationMs: { type: 'integer', minimum: 100, maximum: 5000 }, leaseId: { type: 'string', minLength: 1, maxLength: 256 } });
|
|
12
|
+
if (command !== 'web-ui-observation') properties.provider = { enum: ['native', 'flutter'], default: 'native' };
|
|
13
|
+
return { type: 'object', additionalProperties: false, properties,
|
|
14
|
+
required: ['operation', ...(command === 'web-ui-observation' ? ['sessionId', 'runtimeEpoch'] : command === 'ios-ui-observation' ? ['deviceId', 'bundleId'] : ['packageName'])],
|
|
15
|
+
oneOf: [
|
|
16
|
+
{ properties: { operation: { const: 'start' }, leaseId: false }, required: ['durationMs'] },
|
|
17
|
+
{ properties: { operation: { const: 'stop' }, durationMs: false }, required: ['leaseId'] },
|
|
18
|
+
{ properties: { operation: { const: 'status' }, durationMs: false, leaseId: false } },
|
|
19
|
+
] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function request(args) {
|
|
23
|
+
return { operation: args.operation,
|
|
24
|
+
...(args.operation === 'start' ? { durationMs: args.durationMs } : {}),
|
|
25
|
+
...(args.operation === 'stop' ? { leaseId: args.leaseId } : {}) };
|
|
26
|
+
}
|
|
27
|
+
function path(args) { return args.provider === 'flutter' ? '/v1/flutter/observation' : '/v1/ui/observation'; }
|
|
28
|
+
|
|
29
|
+
module.exports = { commands, schema, request, path };
|
package/bin/web-provider.js
CHANGED
|
@@ -43,6 +43,9 @@ class WebBridgeProvider {
|
|
|
43
43
|
return { ok: true, session: this.sessionSummary(session), ownership: this.owner().status(sessionKey(args.sessionId)) };
|
|
44
44
|
}
|
|
45
45
|
case 'web-execution': return await this.executionControl(args);
|
|
46
|
+
case 'web-ui-observation': return await this.request(this.connected(args), 'read', {
|
|
47
|
+
name: 'uiObservation', args: require('./ui-observation').request(args),
|
|
48
|
+
}, args.timeoutMs ?? 5000);
|
|
46
49
|
case 'web-dom': return args.history === true ? await this.captureResponse(args, 'dom') : await this.dom(args);
|
|
47
50
|
case 'web-logs': return await this.captureResponse(args, 'logs');
|
|
48
51
|
case 'web-network': return await this.captureResponse(args, 'network');
|
|
@@ -256,7 +259,9 @@ class WebBridgeProvider {
|
|
|
256
259
|
this.pendingCommands.set(requestId, { socket, binding, type, payload, fail, resolve: result => end(null, result) });
|
|
257
260
|
scope?.signal.addEventListener('abort', abort, { once: true });
|
|
258
261
|
if (scope?.signal.aborted) { abort(); return; }
|
|
259
|
-
sent = true;
|
|
262
|
+
sent = true;
|
|
263
|
+
// Acquiring evidence must not mark the enclosing UI action as dispatched.
|
|
264
|
+
if (!(type === 'read' && payload.name === 'uiObservation')) markExecutionDispatched();
|
|
260
265
|
try { socket.send(wire, error => { if (error && this.pendingCommands.has(requestId)) fail('web_command_transport_lost'); }); }
|
|
261
266
|
catch { fail('web_command_transport_lost'); }
|
|
262
267
|
});
|