@mobileaidev/ai-app-bridge 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +7 -3
  2. package/bin/android-permissions.js +39 -5
  3. package/bin/command-discovery.js +8 -2
  4. package/bin/command-registry.js +26 -9
  5. package/bin/device-provider.js +11 -2
  6. package/bin/execution-host.js +18 -0
  7. package/bin/executors/android-host.js +201 -0
  8. package/bin/executors/android-port.js +114 -0
  9. package/bin/executors/automation-owner.js +42 -0
  10. package/bin/executors/command-schema.js +102 -0
  11. package/bin/executors/flutter-host.js +123 -0
  12. package/bin/executors/managed-runtime.js +85 -0
  13. package/bin/executors/playwright-host.js +146 -0
  14. package/bin/executors/playwright-worker.js +307 -0
  15. package/bin/executors/receipt-journal.js +56 -0
  16. package/bin/feedback-probe.js +27 -1
  17. package/bin/ios-device-outcome.js +17 -0
  18. package/bin/ios-execution.js +13 -1
  19. package/bin/ios-provider.js +74 -24
  20. package/bin/ios-runtime-binding.js +1 -1
  21. package/bin/ios-wda-startup.js +68 -0
  22. package/bin/runtime-directory.js +1 -1
  23. package/bin/shared-kernel/device-ownership-recovery.js +13 -0
  24. package/bin/shared-kernel/native-target.js +11 -8
  25. package/bin/shared-kernel/uia-protocol.js +1 -1
  26. package/bin/shared-kernel/uia-runtime-port.js +38 -2
  27. package/bin/ui-observation.js +29 -0
  28. package/bin/web-provider.js +6 -1
  29. package/docs/COMMAND_CONTRACT.md +73 -2
  30. package/docs/INTENT_FOREGROUND.md +1 -1
  31. package/docs/OPTIONAL_EXECUTORS.md +182 -0
  32. package/docs/RELEASE.md +36 -14
  33. package/docs/SCRIPT_AUTHORING.md +7 -0
  34. package/package.json +6 -1
  35. package/runtime/executors/playwright/package-lock.json +45 -0
  36. package/runtime/executors/playwright/package.json +8 -0
  37. package/runtime/uia/ai-app-bridge-uia.jar +0 -0
  38. package/runtime/uia/manifest.json +9 -8
@@ -0,0 +1,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',
@@ -23,6 +23,23 @@ function deviceCommandRejection(reply, args, error) {
23
23
  function originalDeviceRejection(reply, args) {
24
24
  const command = args.slice(0, 3).join(' ');
25
25
  if (!matchesInvocation(reply, args) || reply.info.outcome !== 'failed') return null;
26
+ // CoreDevice rejects this launch while acquiring its prerequisites, before
27
+ // contacting the application service. Match the original structured failure,
28
+ // not stderr text or a general tunnel/timeout error.
29
+ const usage = reply.error;
30
+ const requested = usage?.userInfo?.RequestedDeviceStates?.array;
31
+ const available = usage?.userInfo?.CurrentlyAssertableStates?.array;
32
+ const prerequisites = ['com.apple.coredevice.remoteServiceDiscoveryTrustedConnectivityAvailable',
33
+ 'com.apple.coredevice.coreDeviceServicesLoaded', 'com.apple.coredevice.powerAssertionTaken'];
34
+ if (command === 'device process launch' && !reply.result
35
+ && usage?.domain === 'com.apple.dt.CoreDeviceError' && usage.code === 4016
36
+ && Array.isArray(available) && available.length === 0
37
+ && Array.isArray(requested) && requested.length === prerequisites.length
38
+ && prerequisites.every(state => requested.some(value => value?.string === state))) {
39
+ return { ok: false, error: 'ios_device_unavailable',
40
+ message: 'CoreDevice rejected the launch before dispatch because the device connection and services were unavailable. Restore the device connection before launching again.',
41
+ settled: true, dispatched: false, ambiguous: false, deviceOutcome: reply };
42
+ }
26
43
  const errors = [];
27
44
  function visit(value, depth) {
28
45
  if (!value || typeof value !== 'object' || depth > 16 || errors.length > 32) return;
@@ -4,6 +4,7 @@ const { randomUUID } = require('node:crypto');
4
4
  const { CommandError } = require('./command-errors');
5
5
  const fs = require('node:fs');
6
6
  const { originalDeviceOutcome, deviceCommandProof } = require('./ios-device-outcome');
7
+ const { initializationProof, recoverLegacySetup } = require('./ios-wda-startup');
7
8
  const managed = require('./shared-kernel/managed-sdk-execution');
8
9
  const { runDeviceEffect } = require('./shared-kernel/device-mutation-lease');
9
10
  const { checkExecution, runExecution } = require('./shared-kernel/execution-scope');
@@ -75,12 +76,23 @@ async function executeIOSAction({ port, kind, payload, status, target, timeoutMs
75
76
  }, result => result.executionReceipt);
76
77
  }
77
78
 
78
- async function reconcileIOS({ lease, device, args, createPort }) {
79
+ async function reconcileIOS({ lease, device, args, createPort, readWdaTestSummary }) {
80
+ const original = args.setupResultPath ? lease.status(iosDeviceKey(device)).ownership : null;
79
81
  const result = await lease.reconcile(iosDeviceKey(device), async pending => {
80
82
  if (pending.target?.deviceId !== device.udid
81
83
  || args.bundleId && pending.target?.bundleId && pending.target.bundleId !== args.bundleId) {
82
84
  return { settled: false, error: 'ios_original_completion_identity_required' };
83
85
  }
86
+ if (pending.kind === 'ios-wda-start' || pending.kind === 'ios-command' && pending.command === 'ios-setup') {
87
+ try {
88
+ const proof = pending.kind === 'ios-wda-start'
89
+ ? initializationProof(await readWdaTestSummary(pending.invocation.resultBundlePath), pending.invocation, device.udid)
90
+ : original?.pending?.id === pending.id && await recoverLegacySetup({ pending, owner: original.owner,
91
+ resultPath: args.setupResultPath, device, readSummary: readWdaTestSummary });
92
+ return proof || { settled: false, error: 'ios_original_wda_startup_outcome_unresolved' };
93
+ } catch (error) { return { settled: false, error: 'ios_wda_startup_completion_unavailable', cause: error.code || 'invalid_result' }; }
94
+ }
95
+ if (args.setupResultPath) return { settled: false, error: 'ios_original_completion_identity_required' };
84
96
  if (pending.kind === 'ios-command') {
85
97
  const invocation = pending.invocation;
86
98
  const index = Array.isArray(invocation?.arguments) ? invocation.arguments.indexOf('--json-output') : -1;
@@ -17,6 +17,7 @@ const { openWdaPort, target: wdaTarget } = require('./ios-wda-port');
17
17
  const { prepareWdaProject, wdaBuildEnvironment } = require('./ios-wda-project');
18
18
  const { executeWDAAction, reconcileWDA, completionPort } = require('./ios-wda-execution');
19
19
  const { deviceCommandRejection, deviceCommandProof } = require('./ios-device-outcome');
20
+ const { initializationProof } = require('./ios-wda-startup');
20
21
  const { bindFlutterAction } = require('./shared-kernel/flutter-target');
21
22
  const nativeTarget = require('./shared-kernel/ios-native-target');
22
23
  const h5Target = require('./shared-kernel/ios-h5-target');
@@ -94,6 +95,11 @@ class IOSBridgeProvider {
94
95
  return await this.launchApp(args, context);
95
96
  case 'ios-status':
96
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
+ }
97
103
  case 'ios-tree':
98
104
  return await this.runtimeGet(args, '/v1/view/tree');
99
105
  case 'ios-logs':
@@ -152,6 +158,22 @@ class IOSBridgeProvider {
152
158
  const ctx = this.context(args);
153
159
  const raw = await this.devicectlJson(ctx, ['list', 'devices']);
154
160
  const devices = parseDevicectlDevices(raw).map(shapeDevice);
161
+ const selected = selectDeviceFromList(devices, args).device;
162
+ if (selected && (selected.tunnelState !== 'connected' || selected.ddiServicesAvailable !== true)) {
163
+ // list devices can describe a sleeping tunnel. A targeted, read-only
164
+ // details request asks CoreDevice to establish its lazy connection.
165
+ try {
166
+ const reply = await this.devicectlJson(ctx, ['device', 'info', 'details', '--device', selected.identifier]);
167
+ const current = shapeDevice(reply?.result);
168
+ if (current.identifier !== selected.identifier || !current.udid || current.udid !== selected.udid) {
169
+ throw bindingFailure('ios_device_identity_mismatch', 'Device details did not match the selected device identifier and UDID.');
170
+ }
171
+ devices[devices.indexOf(selected)] = { ...current, connectionProbe: { ok: true, source: 'devicectl.device.info.details' } };
172
+ } catch (error) {
173
+ selected.connectionProbe = { ok: false, source: 'devicectl.device.info.details',
174
+ error: error.code || 'ios_device_probe_failed', message: error.message };
175
+ }
176
+ }
155
177
  return {
156
178
  ok: true,
157
179
  devices,
@@ -273,7 +295,9 @@ class IOSBridgeProvider {
273
295
  if (start.ok === true) {
274
296
  wda = start.status || await this.wdaStatus(args);
275
297
  } else {
276
- return { ok: false, error: start.error, message: start.message, device, steps };
298
+ return { ok: false, error: start.error, message: start.message, device, steps,
299
+ ...(start.executionReceipt ? { settled: start.settled, dispatched: start.dispatched,
300
+ ambiguous: start.ambiguous, executionReceipt: start.executionReceipt } : {}) };
277
301
  }
278
302
  }
279
303
  steps.push({ name: 'wda', ok: wda.ok === true, url: wda.url || null, error: wda.error || null });
@@ -449,7 +473,7 @@ class IOSBridgeProvider {
449
473
  const endpoint = port.endpoint;
450
474
  let response;
451
475
  if (method === 'POST') {
452
- const status = await port.get('/v1/status');
476
+ const status = await port.get(endpointPath === '/v1/flutter/action' ? '/v1/flutter/snapshot' : '/v1/status');
453
477
  if (status.ok === false) return { ...status, dispatched: false, ambiguous: false };
454
478
  const kind = endpointPath === '/v1/h5/action' ? 'h5' : 'flutter';
455
479
  const target = { platform: 'ios', deviceId: endpoint.device.udid, bundleId: args.bundleId,
@@ -477,7 +501,7 @@ class IOSBridgeProvider {
477
501
  return lookupCompletion(completionPort(port), 'wda', identity, cancelled);
478
502
  }
479
503
  if (args.operation === 'reconcile') return reconcileIOS({ lease, device, args,
480
- createPort: target => this.runtimePort(target, { device }) });
504
+ createPort: target => this.runtimePort(target, { device }), readWdaTestSummary: file => this.readWdaTestSummary(file) });
481
505
  if (args.operation === 'status') return {
482
506
  ok: true, device, ownership: lease.status(key),
483
507
  runtime: args.bundleId ? await this.runtimeGet(args, '/v1/execution/status', { device, allowUnavailable: true }) : null,
@@ -502,7 +526,7 @@ class IOSBridgeProvider {
502
526
  }
503
527
 
504
528
  async flutterTree(args = {}) {
505
- const status = await this.runtimeGet(args, '/v1/status');
529
+ const status = await this.runtimeGet(args, '/v1/flutter/snapshot');
506
530
  if (status.ok === false) return status;
507
531
  return {
508
532
  ok: true,
@@ -533,7 +557,7 @@ class IOSBridgeProvider {
533
557
 
534
558
  async flutterControl(command, args, context = {}) {
535
559
  const port = await this.runtimePort(args, context);
536
- const status = await port.get('/v1/status');
560
+ const status = await port.get('/v1/flutter/snapshot');
537
561
  if (status.ok !== true) return { ...status, dispatched: false, ambiguous: false };
538
562
  const action = flutterControls.get(command);
539
563
  let payload = { action };
@@ -562,7 +586,9 @@ class IOSBridgeProvider {
562
586
  throw bindingFailure('ios_device_not_found', 'deviceId must match the selected devicectl identifier or UDID.');
563
587
  }
564
588
  if (device.developerModeStatus !== 'enabled' || device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') {
565
- throw bindingFailure('ios_tunnel_unavailable', 'The selected iPhone must expose a connected developer tunnel. Unlock/trust the device and let Xcode prepare it.');
589
+ throw bindingFailure('ios_tunnel_unavailable', device.connectionProbe?.ok === false
590
+ ? `The selected iPhone did not become ready after a targeted device details request: ${device.connectionProbe.message}`
591
+ : 'The selected iPhone does not expose ready developer services after the device details request. Inspect ios-doctor for the observed device state.');
566
592
  }
567
593
  const runtimeBinding = await this.readRuntimePortFile(args, device);
568
594
  const host = target.iosHost ?? device.tunnelIPAddress;
@@ -768,25 +794,47 @@ class IOSBridgeProvider {
768
794
  };
769
795
  } finally { await build.stop(); }
770
796
  const logFile = path.join(directory, 'xcodebuild.log');
771
- const runtime = spawnWdaProcess(ctx.xcodebuild, [...xcodeArgs, 'test-without-building'], logFile, true);
772
- const child = runtime.child;
773
- let ready = false;
774
- try {
775
- while (!runtime.terminal) {
776
- checkExecution();
777
- const status = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
778
- if (status.ok) {
779
- ready = true; child.unref();
780
- return { ok: true, device, wdaTestBundleId, wdaRunnerBundleId, pid: child.pid, logFile, buildLogFile, prepared, status };
797
+ const resultBundlePath = path.join(directory, 'result.xcresult');
798
+ const invocation = { arguments: [...xcodeArgs, '-resultBundlePath', resultBundlePath, 'test-without-building'],
799
+ resultBundlePath, startedAtMs: Date.now() };
800
+ return runDeviceEffect({ kind: 'ios-wda-start', command: 'ios-setup', invocation,
801
+ target: { deviceId: device.udid, bundleId: args.bundleId ?? null } }, async () => {
802
+ const runtime = spawnWdaProcess(ctx.xcodebuild, invocation.arguments, logFile, true);
803
+ const child = runtime.child;
804
+ let ready = false;
805
+ try {
806
+ while (!runtime.terminal) {
807
+ checkExecution();
808
+ const status = await this.wdaStatus({ ...args, deviceId: device.udid, wdaRunnerBundleId });
809
+ if (status.ok) {
810
+ ready = true; child.unref();
811
+ return { ok: true, device, wdaTestBundleId, wdaRunnerBundleId, pid: child.pid, logFile, buildLogFile, prepared, status,
812
+ executionReceipt: { kind: 'ios-wda-start', settled: true, dispatched: true, ambiguous: false, invocation,
813
+ runtimeBinding: status.runtimeBinding } };
814
+ }
815
+ await executionSleep(1000);
816
+ }
817
+ let proof;
818
+ if (!runtime.spawnError && runtime.exitCode === 65) {
819
+ try { proof = initializationProof(await this.readWdaTestSummary(resultBundlePath), invocation, device.udid); }
820
+ catch { /* An absent or unreadable original XCTest result remains unresolved. */ }
781
821
  }
782
- await executionSleep(1000);
822
+ return { ok: false, error: runtime.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_xcodebuild_exited',
823
+ message: runtime.spawnError?.message ?? 'xcodebuild closed before the selected Runner published a bound endpoint.',
824
+ phase: 'device-test', exitCode: runtime.exitCode, logFile, buildLogFile, prepared, resultBundlePath,
825
+ ...(runtime.spawnError ? { dispatched: false, ambiguous: false,
826
+ executionReceipt: { kind: 'ios-wda-start', settled: true, dispatched: false, ambiguous: false, invocation } } : {}),
827
+ ...(proof ? { ...proof.outcome, executionReceipt: proof } : {}) };
828
+ } finally {
829
+ if (!ready) await runtime.stop();
783
830
  }
784
- return { ok: false, error: runtime.spawnError ? 'ios_wda_xcodebuild_spawn_failed' : 'ios_wda_xcodebuild_exited',
785
- message: runtime.spawnError?.message ?? 'xcodebuild closed before the selected Runner published a bound endpoint.',
786
- phase: 'device-test', exitCode: runtime.exitCode, logFile, buildLogFile, prepared };
787
- } finally {
788
- if (!ready) await runtime.stop();
789
- }
831
+ }, result => result.executionReceipt);
832
+ }
833
+
834
+ async readWdaTestSummary(resultBundlePath) {
835
+ const reply = await execFileText(this.execFile, 'xcrun', ['xcresulttool', 'get', 'test-results', 'summary',
836
+ '--path', resultBundlePath, '--format', 'json'], { timeoutMs: 10000 });
837
+ return JSON.parse(reply.stdout);
790
838
  }
791
839
 
792
840
  async requireDevice(args = {}) {
@@ -987,7 +1035,9 @@ function withQuery(endpointPath, query) {
987
1035
  function iosSetupSuggestion(device, runtime, wda) {
988
1036
  if (!device) return 'Connect one iPhone, trust this Mac on the device, then rerun ios-doctor.';
989
1037
  if (device.developerModeStatus !== 'enabled') return 'Enable Developer Mode on the iPhone and rerun ios-setup.';
990
- if (device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') return 'Unlock/trust the iPhone and let Xcode finish preparing a connected developer tunnel.';
1038
+ if (device.ddiServicesAvailable !== true || device.tunnelState !== 'connected') return device.connectionProbe?.ok === false
1039
+ ? `The device details probe failed: ${device.connectionProbe.message}`
1040
+ : 'The device details probe did not establish ready developer services. Check the selected device connection and Xcode preparation state.';
991
1041
  if (wda?.ok !== true) return 'Start the prepared Runner with ios-setup --start-wda --team-id, or supply its exact wdaRunnerBundleId. An optional wdaUrl still requires container binding.';
992
1042
  if (runtime?.ok !== true) return 'Launch a debug App with AiAppBridgeIOS and supply its exact deviceId and bundleId.';
993
1043
  return 'Rerun ios-setup after resolving the failing check.';