@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.
- package/README.md +7 -3
- package/bin/android-permissions.js +39 -5
- package/bin/command-discovery.js +8 -2
- package/bin/command-registry.js +26 -9
- package/bin/device-provider.js +11 -2
- 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-device-outcome.js +17 -0
- package/bin/ios-execution.js +13 -1
- package/bin/ios-provider.js +74 -24
- package/bin/ios-runtime-binding.js +1 -1
- package/bin/ios-wda-startup.js +68 -0
- package/bin/runtime-directory.js +1 -1
- package/bin/shared-kernel/device-ownership-recovery.js +13 -0
- package/bin/shared-kernel/native-target.js +11 -8
- package/bin/shared-kernel/uia-protocol.js +1 -1
- package/bin/shared-kernel/uia-runtime-port.js +38 -2
- package/bin/ui-observation.js +29 -0
- package/bin/web-provider.js +6 -1
- package/docs/COMMAND_CONTRACT.md +73 -2
- package/docs/INTENT_FOREGROUND.md +1 -1
- package/docs/OPTIONAL_EXECUTORS.md +182 -0
- package/docs/RELEASE.md +36 -14
- package/docs/SCRIPT_AUTHORING.md +7 -0
- package/package.json +6 -1
- package/runtime/executors/playwright/package-lock.json +45 -0
- package/runtime/executors/playwright/package.json +8 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- 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 };
|
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',
|
|
@@ -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;
|
package/bin/ios-execution.js
CHANGED
|
@@ -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;
|
package/bin/ios-provider.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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',
|
|
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
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
|
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.';
|