@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.
@@ -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 };
@@ -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',
@@ -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/status');
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/status');
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'];
@@ -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
- // WindowInspector retains other Activities during transitions, and focus can
6
- // lag the current Activity. Select its window group, including dialogs/popups
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 activity = windows.findLast(item => item?.activityDecor === true && Object.hasOwn(item, 'focusOwnerWindowId'));
25
- const index = windows.findLastIndex(item => !explicitlyHidden(item?.root)
26
- && (!activity || item === activity || !Object.hasOwn(item || {}, 'focusOwnerWindowId')
27
- || item.focusOwnerWindowId !== null && item.focusOwnerWindowId === activity.focusOwnerWindowId));
28
- if (index < 0) return null;
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 };
@@ -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; markExecutionDispatched();
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
  });