@deeeed/metamask-harness 0.41.0 → 0.42.0
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/CHANGELOG.md +24 -0
- package/README.md +7 -0
- package/adapters/manifest.json +25 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
- package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
- package/adapters/mobile/coalesce-metro-log.cjs +24 -0
- package/adapters/mobile/launch-metro.cjs +9 -8
- package/adapters/mobile/metro-log-generation.cjs +106 -0
- package/adapters/mobile/reload-app.mjs +67 -0
- package/adapters/mobile/start-console-forwarder.sh +17 -2
- package/adapters/mobile/start-metro.sh +23 -18
- package/adapters/mobile/stop-metro.sh +15 -7
- package/adapters/shared/open-debug.mjs +172 -2
- package/adapters/shared/reap-checkout-metros.sh +17 -0
- package/dist/adapters/extension/network-observer.js +300 -0
- package/dist/adapters/mobile/metro-env.js +0 -5
- package/dist/adapters/mobile/prepare.js +1 -3
- package/dist/adapters/mobile/runtime-decision.js +6 -30
- package/dist/adapters.js +14 -1
- package/dist/cli-commands.js +6 -3
- package/dist/cli.js +4 -0
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +45 -20
- package/dist/commands/launch/index.js +25 -5
- package/dist/commands/reload.js +80 -0
- package/dist/commands/run.js +49 -22
- package/dist/mm-harness-cli.js +17 -1
- package/dist/network-observation.js +271 -0
- package/docs/NETWORK-CAPTURE.md +98 -0
- package/docs/QA.md +2 -0
- package/docs/RECIPES.md +10 -0
- package/library/actions/mobile/app/network_assert.mjs +14 -0
- package/library/actions/mobile/app/network_capture.mjs +72 -0
- package/library/actions/mobile/platform/bridge.mjs +7 -2
- package/library/actions/shared/app/network-artifact.mjs +10 -0
- package/library/actions/shared/app/network-assert.mjs +154 -0
- package/library/manifests/extension.action-manifest.json +88 -0
- package/library/manifests/mobile.action-manifest.json +107 -0
- package/library/recipes/mobile/perps/performance.recipe.json +11 -11
- package/package.json +1 -1
- package/scripts/completions.sh +2 -1
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* open-debug.mjs — open human-facing debugger UIs for recipe runtimes.
|
|
4
4
|
*
|
|
5
|
-
* Mobile:
|
|
5
|
+
* Mobile: broker-backed React Native DevTools or Metro /open-dev-menu.
|
|
6
6
|
* Extension: Chrome DevTools frontend for an extension page or service worker.
|
|
7
7
|
*/
|
|
8
|
-
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
9
11
|
import http from 'node:http';
|
|
10
12
|
import { createRequire } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const {
|
|
17
|
+
discoverTarget,
|
|
18
|
+
} = require('../mobile/bridge-runtime/lib/target-discovery.cjs');
|
|
19
|
+
const {
|
|
20
|
+
deviceIdFromUrl,
|
|
21
|
+
} = require('../mobile/bridge-runtime/lib/cdp-broker.cjs');
|
|
11
22
|
|
|
12
23
|
function usage() {
|
|
13
24
|
console.error(`Usage:
|
|
@@ -236,6 +247,9 @@ async function tryDevSettingsCdpFallback(numericPort, normalizedAction) {
|
|
|
236
247
|
|
|
237
248
|
async function openMobileDebugger(port, action, shouldOpen) {
|
|
238
249
|
const normalizedAction = action === 'dev-menu' ? 'dev-menu' : 'debug';
|
|
250
|
+
if (normalizedAction === 'debug') {
|
|
251
|
+
return openBrokeredMobileDebugger(port, shouldOpen);
|
|
252
|
+
}
|
|
239
253
|
const urlPath = normalizedAction === 'dev-menu' ? '/open-dev-menu' : '/open-debugger';
|
|
240
254
|
const numericPort = Number.parseInt(String(port), 10);
|
|
241
255
|
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
|
@@ -302,6 +316,162 @@ async function openMobileDebugger(port, action, shouldOpen) {
|
|
|
302
316
|
};
|
|
303
317
|
}
|
|
304
318
|
|
|
319
|
+
function processIsAlive(pid) {
|
|
320
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
321
|
+
try {
|
|
322
|
+
process.kill(pid, 0);
|
|
323
|
+
return true;
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function openBrokeredMobileDebugger(port, shouldOpen) {
|
|
330
|
+
const numericPort = Number.parseInt(String(port), 10);
|
|
331
|
+
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
|
332
|
+
return { ok: false, adapter: 'mobile', action: 'debug', error: 'invalid-port', port };
|
|
333
|
+
}
|
|
334
|
+
const runtimeDir = path.resolve(
|
|
335
|
+
process.cwd(),
|
|
336
|
+
process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
|
|
337
|
+
);
|
|
338
|
+
const descriptorPath = path.join(runtimeDir, 'devtools-proxy.json');
|
|
339
|
+
let descriptor;
|
|
340
|
+
try {
|
|
341
|
+
descriptor = JSON.parse(fs.readFileSync(descriptorPath, 'utf8'));
|
|
342
|
+
} catch {
|
|
343
|
+
descriptor = null;
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
descriptor?.schemaVersion !== 1 ||
|
|
347
|
+
!Number.isInteger(descriptor.port) ||
|
|
348
|
+
descriptor.port <= 0 ||
|
|
349
|
+
!processIsAlive(descriptor.pid)
|
|
350
|
+
) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
adapter: 'mobile',
|
|
354
|
+
action: 'debug',
|
|
355
|
+
error: 'broker-unavailable',
|
|
356
|
+
port: numericPort,
|
|
357
|
+
hint: 'Start the Mobile runtime first: mm-harness launch ios|android',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let target;
|
|
362
|
+
try {
|
|
363
|
+
const selected = await discoverTarget(numericPort, { probe: false });
|
|
364
|
+
const targets = await fetchJson(`http://127.0.0.1:${numericPort}/json/list`);
|
|
365
|
+
const selectedUrl = new URL(selected.wsUrl);
|
|
366
|
+
target = targets.find(
|
|
367
|
+
(candidate) => {
|
|
368
|
+
try {
|
|
369
|
+
const candidateUrl = new URL(candidate.webSocketDebuggerUrl);
|
|
370
|
+
return (
|
|
371
|
+
candidateUrl.pathname === selectedUrl.pathname &&
|
|
372
|
+
candidateUrl.search === selectedUrl.search
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
adapter: 'mobile',
|
|
383
|
+
action: 'debug',
|
|
384
|
+
error: 'target-unavailable',
|
|
385
|
+
port: numericPort,
|
|
386
|
+
hint: 'Wait for the app runtime, then retry: mm-harness debug',
|
|
387
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
adapter: 'mobile',
|
|
394
|
+
action: 'debug',
|
|
395
|
+
error: 'target-unavailable',
|
|
396
|
+
port: numericPort,
|
|
397
|
+
hint: 'Wait for the app runtime, then retry: mm-harness debug',
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
|
|
402
|
+
const proxyPath = `/devtools?device=${encodeURIComponent(deviceId)}`;
|
|
403
|
+
const publicProxyEndpoint = `ws://127.0.0.1:${descriptor.port}${proxyPath}`;
|
|
404
|
+
const params = new URLSearchParams([
|
|
405
|
+
['ws', `127.0.0.1:${descriptor.port}${proxyPath}`],
|
|
406
|
+
['sources.hide_add_folder', 'true'],
|
|
407
|
+
['unstable_enableNetworkPanel', 'true'],
|
|
408
|
+
]);
|
|
409
|
+
if (target.appId) params.set('appId', target.appId);
|
|
410
|
+
const frontendUrl = `http://127.0.0.1:${numericPort}/debugger-frontend/rn_fusebox.html?${params}`;
|
|
411
|
+
if (!shouldOpen) {
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
adapter: 'mobile',
|
|
415
|
+
action: 'debug',
|
|
416
|
+
method: 'broker-proxy',
|
|
417
|
+
endpoint: publicProxyEndpoint,
|
|
418
|
+
port: numericPort,
|
|
419
|
+
opened: false,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const mobileRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
|
425
|
+
const dotslash = mobileRequire('fb-dotslash');
|
|
426
|
+
const shellPackage = mobileRequire.resolve(
|
|
427
|
+
'@react-native/debugger-shell/package.json',
|
|
428
|
+
);
|
|
429
|
+
const shellDescriptor = path.join(
|
|
430
|
+
path.dirname(shellPackage),
|
|
431
|
+
'bin',
|
|
432
|
+
'react-native-devtools',
|
|
433
|
+
);
|
|
434
|
+
const windowKey = createHash('sha256')
|
|
435
|
+
.update([frontendUrl, target.appId || '', deviceId].join('-'))
|
|
436
|
+
.digest('hex');
|
|
437
|
+
await new Promise((resolve, reject) => {
|
|
438
|
+
const child = spawn(
|
|
439
|
+
dotslash,
|
|
440
|
+
[
|
|
441
|
+
shellDescriptor,
|
|
442
|
+
`--frontendUrl=${frontendUrl}`,
|
|
443
|
+
`--windowKey=${windowKey}`,
|
|
444
|
+
],
|
|
445
|
+
{ detached: true, stdio: 'ignore' },
|
|
446
|
+
);
|
|
447
|
+
child.once('spawn', resolve);
|
|
448
|
+
child.once('error', reject);
|
|
449
|
+
child.unref();
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
adapter: 'mobile',
|
|
455
|
+
action: 'debug',
|
|
456
|
+
error: 'devtools-launch-failed',
|
|
457
|
+
endpoint: publicProxyEndpoint,
|
|
458
|
+
port: numericPort,
|
|
459
|
+
hint: 'Install the checkout dependencies, then retry: mm-harness debug',
|
|
460
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
ok: true,
|
|
466
|
+
adapter: 'mobile',
|
|
467
|
+
action: 'debug',
|
|
468
|
+
method: 'broker-proxy',
|
|
469
|
+
endpoint: publicProxyEndpoint,
|
|
470
|
+
port: numericPort,
|
|
471
|
+
opened: true,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
305
475
|
function pickExtensionTarget(targets, targetKind) {
|
|
306
476
|
const extensionTargets = targets.filter((target) => String(target.url || '').startsWith('chrome-extension://'));
|
|
307
477
|
if (targetKind === 'worker') {
|
|
@@ -40,6 +40,23 @@ LIST
|
|
|
40
40
|
return 0
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
reap_checkout_metros_on_port() {
|
|
44
|
+
local repo="$1" port="$2" reaped="" pid args
|
|
45
|
+
[ -n "$repo" ] && [ -n "$port" ] || return 0
|
|
46
|
+
while IFS= read -r pid; do
|
|
47
|
+
[ -n "$pid" ] || continue
|
|
48
|
+
args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
|
|
49
|
+
case " $args " in *" --port $port "*|*" --port=$port "*) ;; *) continue ;; esac
|
|
50
|
+
kill "$pid" 2>/dev/null && reaped="$reaped $pid"
|
|
51
|
+
done <<LIST
|
|
52
|
+
$(_checkout_metro_pids "$repo")
|
|
53
|
+
LIST
|
|
54
|
+
if [ -n "$reaped" ]; then
|
|
55
|
+
printf 'Reaped leaked Metro bundler(s) for checkout port %s:%s\n' "$port" "$reaped" >&2
|
|
56
|
+
fi
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
detect_checkout_metros() {
|
|
44
61
|
local repo="$1" live_port="${2:-}" pid args
|
|
45
62
|
[ -n "$repo" ] || return 0
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import brokerModule from "../../../adapters/mobile/bridge-runtime/lib/cdp-broker.cjs";
|
|
4
|
+
const DEVICE_ID = "extension";
|
|
5
|
+
const COMMAND_TIMEOUT_MS = 1e4;
|
|
6
|
+
const EXTENSION_TARGET_TYPES = /* @__PURE__ */ new Set([
|
|
7
|
+
"background_page",
|
|
8
|
+
"other",
|
|
9
|
+
"page",
|
|
10
|
+
"service_worker"
|
|
11
|
+
]);
|
|
12
|
+
const { brokerSocketPath, createBrokerClient, createCdpBroker } = brokerModule;
|
|
13
|
+
async function createExtensionNetworkObserver(cdpPort, runtimeDir) {
|
|
14
|
+
if (!Number.isInteger(cdpPort) || cdpPort <= 0) {
|
|
15
|
+
throw new Error("Extension network observation requires a valid CDP port.");
|
|
16
|
+
}
|
|
17
|
+
const [version, targets] = await Promise.all([
|
|
18
|
+
fetchJson(`http://127.0.0.1:${cdpPort}/json/version`),
|
|
19
|
+
fetchJson(`http://127.0.0.1:${cdpPort}/json`)
|
|
20
|
+
]);
|
|
21
|
+
const browserWebSocketUrl = String(
|
|
22
|
+
asRecord(version).webSocketDebuggerUrl ?? ""
|
|
23
|
+
);
|
|
24
|
+
const extensionId = extensionIdFromTargets(targets);
|
|
25
|
+
if (!browserWebSocketUrl || !extensionId) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"Extension CDP browser or loaded extension target is unavailable."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const socket = new WebSocket(browserWebSocketUrl);
|
|
31
|
+
await waitForOpen(socket);
|
|
32
|
+
let nextId = 0;
|
|
33
|
+
let closed = false;
|
|
34
|
+
const brokerRef = {};
|
|
35
|
+
const pending = /* @__PURE__ */ new Map();
|
|
36
|
+
const targetSessions = /* @__PURE__ */ new Map();
|
|
37
|
+
const attaching = /* @__PURE__ */ new Set();
|
|
38
|
+
const send = (method, params = {}, sessionId, timeoutMs = COMMAND_TIMEOUT_MS) => new Promise((resolve, reject) => {
|
|
39
|
+
const id = ++nextId;
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
pending.delete(id);
|
|
42
|
+
reject(new Error(`Extension CDP command timed out: ${method}`));
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
pending.set(id, { resolve, reject, timer });
|
|
45
|
+
socket.send(
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
id,
|
|
48
|
+
method,
|
|
49
|
+
params,
|
|
50
|
+
...sessionId ? { sessionId } : {}
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
const relevantTarget = (target) => {
|
|
55
|
+
if (!EXTENSION_TARGET_TYPES.has(String(target.type))) return false;
|
|
56
|
+
try {
|
|
57
|
+
return new URL(String(target.url)).hostname === extensionId;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const refreshBrokerSession = () => {
|
|
63
|
+
if (!brokerRef.current) return;
|
|
64
|
+
brokerRef.current.onSessionClose(DEVICE_ID);
|
|
65
|
+
brokerRef.current.onSessionOpen(DEVICE_ID);
|
|
66
|
+
};
|
|
67
|
+
const attachTarget = async (target) => {
|
|
68
|
+
if (closed || !relevantTarget(target) || targetSessions.has(target.targetId) || attaching.has(target.targetId)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
attaching.add(target.targetId);
|
|
72
|
+
try {
|
|
73
|
+
const result = asRecord(
|
|
74
|
+
await send("Target.attachToTarget", {
|
|
75
|
+
targetId: target.targetId,
|
|
76
|
+
flatten: true
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
const sessionId = String(result.sessionId ?? "");
|
|
80
|
+
if (sessionId) {
|
|
81
|
+
targetSessions.set(target.targetId, sessionId);
|
|
82
|
+
refreshBrokerSession();
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!/already attached/iu.test(String(error))) {
|
|
86
|
+
brokerRef.current?.onSessionClose(DEVICE_ID);
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
attaching.delete(target.targetId);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
socket.on("message", (data) => {
|
|
93
|
+
let message;
|
|
94
|
+
try {
|
|
95
|
+
message = JSON.parse(String(data));
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (message.id && pending.has(message.id)) {
|
|
100
|
+
const command = pending.get(message.id);
|
|
101
|
+
pending.delete(message.id);
|
|
102
|
+
if (!command) return;
|
|
103
|
+
clearTimeout(command.timer);
|
|
104
|
+
if (message.error) {
|
|
105
|
+
command.reject(
|
|
106
|
+
new Error(
|
|
107
|
+
String(message.error.message ?? "Extension CDP command failed")
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
} else {
|
|
111
|
+
command.resolve(message.result ?? {});
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (message.method === "Target.targetCreated") {
|
|
116
|
+
const target = asTargetInfo(message.params?.targetInfo);
|
|
117
|
+
if (target) void attachTarget(target);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (message.method === "Target.attachedToTarget") {
|
|
121
|
+
const target = asTargetInfo(message.params?.targetInfo);
|
|
122
|
+
const sessionId = String(message.params?.sessionId ?? "");
|
|
123
|
+
if (target && sessionId && relevantTarget(target)) {
|
|
124
|
+
targetSessions.set(target.targetId, sessionId);
|
|
125
|
+
refreshBrokerSession();
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (message.method === "Target.detachedFromTarget") {
|
|
130
|
+
const sessionId = String(message.params?.sessionId ?? "");
|
|
131
|
+
for (const [targetId, activeSessionId] of targetSessions) {
|
|
132
|
+
if (activeSessionId !== sessionId) continue;
|
|
133
|
+
targetSessions.delete(targetId);
|
|
134
|
+
refreshBrokerSession();
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (message.sessionId && message.method?.startsWith("Network.")) {
|
|
139
|
+
brokerRef.current?.onCdpEvent(
|
|
140
|
+
DEVICE_ID,
|
|
141
|
+
message.method,
|
|
142
|
+
message.params ?? {}
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
socket.on("close", () => {
|
|
147
|
+
if (!closed) brokerRef.current?.onSessionClose(DEVICE_ID);
|
|
148
|
+
for (const command of pending.values()) {
|
|
149
|
+
clearTimeout(command.timer);
|
|
150
|
+
command.reject(new Error("Extension browser CDP connection closed"));
|
|
151
|
+
}
|
|
152
|
+
pending.clear();
|
|
153
|
+
});
|
|
154
|
+
await send("Target.setDiscoverTargets", { discover: true });
|
|
155
|
+
const targetResult = asRecord(await send("Target.getTargets"));
|
|
156
|
+
const targetInfos = Array.isArray(targetResult.targetInfos) ? targetResult.targetInfos : [];
|
|
157
|
+
await Promise.all(
|
|
158
|
+
targetInfos.map(asTargetInfo).filter((target) => Boolean(target)).map(attachTarget)
|
|
159
|
+
);
|
|
160
|
+
if (targetSessions.size === 0) {
|
|
161
|
+
socket.close();
|
|
162
|
+
throw new Error("Extension CDP exposes no attachable extension targets.");
|
|
163
|
+
}
|
|
164
|
+
const sessions = /* @__PURE__ */ new Map([[DEVICE_ID, { brokerReady: true }]]);
|
|
165
|
+
const socketPath = brokerSocketPath(
|
|
166
|
+
path.join(runtimeDir, "extension-network")
|
|
167
|
+
);
|
|
168
|
+
brokerRef.current = createCdpBroker({
|
|
169
|
+
socketPath,
|
|
170
|
+
sessions,
|
|
171
|
+
async sendCommand(_session, method, params, timeoutMs) {
|
|
172
|
+
const sessionIds = [...new Set(targetSessions.values())];
|
|
173
|
+
if (sessionIds.length === 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"Extension CDP has no active extension target sessions."
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const results = await Promise.allSettled(
|
|
179
|
+
sessionIds.map(
|
|
180
|
+
(sessionId) => send(method, params, sessionId, timeoutMs)
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
const successes = results.filter(
|
|
184
|
+
(result) => result.status === "fulfilled"
|
|
185
|
+
);
|
|
186
|
+
if (successes.length === 0) {
|
|
187
|
+
const firstFailure = results.find(
|
|
188
|
+
(result) => result.status === "rejected"
|
|
189
|
+
);
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Extension CDP command failed on every target: ${String(firstFailure?.reason ?? method)}`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (successes.length !== results.length) {
|
|
195
|
+
results.forEach((result, index) => {
|
|
196
|
+
if (result.status === "fulfilled") return;
|
|
197
|
+
const failedSession = sessionIds[index];
|
|
198
|
+
for (const [targetId, sessionId] of targetSessions) {
|
|
199
|
+
if (sessionId === failedSession) targetSessions.delete(targetId);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
brokerRef.current?.onSessionClose(DEVICE_ID);
|
|
203
|
+
}
|
|
204
|
+
return successes[0].value;
|
|
205
|
+
},
|
|
206
|
+
requestDiscovery() {
|
|
207
|
+
void send("Target.getTargets").then((result) => {
|
|
208
|
+
const infos = asRecord(result).targetInfos;
|
|
209
|
+
if (!Array.isArray(infos)) return;
|
|
210
|
+
for (const value of infos) {
|
|
211
|
+
const target = asTargetInfo(value);
|
|
212
|
+
if (target) void attachTarget(target);
|
|
213
|
+
}
|
|
214
|
+
}).catch(() => brokerRef.current?.onSessionClose(DEVICE_ID));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
brokerRef.current.onSessionOpen(DEVICE_ID);
|
|
218
|
+
await waitForSocket(socketPath);
|
|
219
|
+
const client = await createBrokerClient(
|
|
220
|
+
socketPath,
|
|
221
|
+
DEVICE_ID,
|
|
222
|
+
COMMAND_TIMEOUT_MS
|
|
223
|
+
);
|
|
224
|
+
return {
|
|
225
|
+
start(params) {
|
|
226
|
+
return client.control("capture-start", params, COMMAND_TIMEOUT_MS);
|
|
227
|
+
},
|
|
228
|
+
async end(id) {
|
|
229
|
+
return asRecord(
|
|
230
|
+
await client.control("capture-end", { id }, COMMAND_TIMEOUT_MS)
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
async close() {
|
|
234
|
+
closed = true;
|
|
235
|
+
client.close();
|
|
236
|
+
brokerRef.current?.close();
|
|
237
|
+
socket.close();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function fetchJson(url) {
|
|
242
|
+
const response = await fetch(url);
|
|
243
|
+
if (!response.ok)
|
|
244
|
+
throw new Error(`Extension CDP returned HTTP ${response.status}.`);
|
|
245
|
+
return response.json();
|
|
246
|
+
}
|
|
247
|
+
function extensionIdFromTargets(targets) {
|
|
248
|
+
const values = Array.isArray(targets) ? targets : [];
|
|
249
|
+
for (const target of values) {
|
|
250
|
+
const info = asTargetInfo(target);
|
|
251
|
+
if (!info) continue;
|
|
252
|
+
try {
|
|
253
|
+
const url = new URL(info.url);
|
|
254
|
+
if (url.protocol !== "chrome-extension:") continue;
|
|
255
|
+
const extensionId = url.hostname;
|
|
256
|
+
if (extensionId) return extensionId;
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
function asRecord(value) {
|
|
264
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
265
|
+
}
|
|
266
|
+
function asTargetInfo(value) {
|
|
267
|
+
const target = asRecord(value);
|
|
268
|
+
const targetId = String(target.targetId ?? target.id ?? "");
|
|
269
|
+
const type = String(target.type ?? "");
|
|
270
|
+
const url = String(target.url ?? "");
|
|
271
|
+
return targetId && type && url ? { targetId, type, url } : null;
|
|
272
|
+
}
|
|
273
|
+
function waitForOpen(socket) {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
socket.close();
|
|
277
|
+
reject(new Error("Extension browser CDP connection timed out."));
|
|
278
|
+
}, COMMAND_TIMEOUT_MS);
|
|
279
|
+
socket.once("open", () => {
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
resolve();
|
|
282
|
+
});
|
|
283
|
+
socket.once("error", (error) => {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
reject(error);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
async function waitForSocket(socketPath) {
|
|
290
|
+
const { existsSync } = await import("node:fs");
|
|
291
|
+
const deadline = Date.now() + COMMAND_TIMEOUT_MS;
|
|
292
|
+
while (Date.now() < deadline) {
|
|
293
|
+
if (existsSync(socketPath)) return;
|
|
294
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
295
|
+
}
|
|
296
|
+
throw new Error("Extension network observer broker did not start.");
|
|
297
|
+
}
|
|
298
|
+
export {
|
|
299
|
+
createExtensionNetworkObserver
|
|
300
|
+
};
|
|
@@ -32,10 +32,6 @@ function mobileMetroEnvCheck(target) {
|
|
|
32
32
|
status: marker === null ? "missing" : marker.fingerprint === fingerprint ? "current" : "changed"
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
-
function applyMobileMetroEnvPolicy(target, actions) {
|
|
36
|
-
if (mobileMetroEnvCheck(target).status === "current") return actions;
|
|
37
|
-
return actions.map((action) => action.id === "start-metro" && !action.argv?.includes("--clear") ? { ...action, argv: [...action.argv ?? [], "--clear"] } : action);
|
|
38
|
-
}
|
|
39
35
|
function recordMobileMetroEnvBaseline(target, expectedFingerprint) {
|
|
40
36
|
if (mobileMetroEnvFingerprint(target) !== expectedFingerprint) return false;
|
|
41
37
|
const marker = recipeRuntimePath(target, BASELINE_FILE);
|
|
@@ -69,7 +65,6 @@ function readBaseline(target) {
|
|
|
69
65
|
}
|
|
70
66
|
}
|
|
71
67
|
export {
|
|
72
|
-
applyMobileMetroEnvPolicy,
|
|
73
68
|
mobileMetroEnvCheck,
|
|
74
69
|
mobileMetroEnvFingerprint,
|
|
75
70
|
recordMobileMetroEnvBaseline
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
launchActions
|
|
10
10
|
} from "./runtime-decision.js";
|
|
11
11
|
import {
|
|
12
|
-
applyMobileMetroEnvPolicy,
|
|
13
12
|
mobileMetroEnvCheck,
|
|
14
13
|
recordMobileMetroEnvBaseline
|
|
15
14
|
} from "./metro-env.js";
|
|
@@ -241,9 +240,8 @@ function withAppRestart(actions, restartApp) {
|
|
|
241
240
|
}
|
|
242
241
|
async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode, watcherPort) {
|
|
243
242
|
const resolved = path.resolve(target);
|
|
244
|
-
const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
|
|
245
243
|
let pendingMetroEnvFingerprint;
|
|
246
|
-
for (const action of
|
|
244
|
+
for (const action of requestedActions) {
|
|
247
245
|
if (action.id === "start-metro") {
|
|
248
246
|
pendingMetroEnvFingerprint = mobileMetroEnvCheck(resolved).fingerprint;
|
|
249
247
|
}
|
|
@@ -2,20 +2,18 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import {
|
|
5
|
-
clearDecisionState,
|
|
6
5
|
depsCheck,
|
|
7
6
|
recordDepsBaseline
|
|
8
7
|
} from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
9
8
|
import {
|
|
10
9
|
analyzeBundleLog,
|
|
11
|
-
evaluatePersistentBundleError,
|
|
12
10
|
supersededErrorCapture
|
|
13
11
|
} from "@farmslot/recipe-harness/runtime/log-analysis";
|
|
14
12
|
import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
|
|
15
13
|
import { recipeRuntimePath } from "../../paths.js";
|
|
16
14
|
import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
|
|
17
15
|
import { mobileProductMarkers } from "./deps-markers.js";
|
|
18
|
-
import {
|
|
16
|
+
import { mobileMetroEnvCheck } from "./metro-env.js";
|
|
19
17
|
const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
|
|
20
18
|
const BUNDLE_OK = /Bundled \d+ms|iOS Bundled|Android Bundled|Finished bundling/u;
|
|
21
19
|
const NATIVE_MODULE_STALE = /\[runtime not ready\].*HybridObject "([^"]+)" - It has not yet been registered in the Nitro Modules HybridObjectRegistry/u;
|
|
@@ -132,13 +130,10 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
132
130
|
decision: "launch",
|
|
133
131
|
reasonCode: report.decision === "ready" ? env.status === "missing" ? "metro-env-baseline-missing" : "metro-env-changed" : report.reasonCode,
|
|
134
132
|
reasons: [
|
|
135
|
-
env.status === "missing" ? "Metro environment has no successful bundle baseline; restart
|
|
133
|
+
env.status === "missing" ? "Metro environment has no successful bundle baseline; restart Metro to load the current environment without clearing its transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart Metro without clearing its transform cache. Pass --clear-metro only if stale transforms are observed.",
|
|
136
134
|
...report.reasons
|
|
137
135
|
],
|
|
138
|
-
actions:
|
|
139
|
-
resolved,
|
|
140
|
-
report.decision === "ready" ? launchActions(resolved) : report.actions
|
|
141
|
-
)
|
|
136
|
+
actions: report.decision === "ready" ? launchActions(resolved) : report.actions
|
|
142
137
|
};
|
|
143
138
|
}
|
|
144
139
|
async function computeMobileReadiness(resolved, options, fast) {
|
|
@@ -160,9 +155,6 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
160
155
|
}
|
|
161
156
|
const metroLog = metroLogCheck(resolved, options.metroLog);
|
|
162
157
|
const metro = options.watcherPort ? await probeMetroPackager(options.watcherPort) : { status: "skipped" };
|
|
163
|
-
if (metroLog.status === "ok") {
|
|
164
|
-
clearDecisionState(resolved, "bundle-error-state.json");
|
|
165
|
-
}
|
|
166
158
|
const checks = { deps, metroLog, metro };
|
|
167
159
|
if (deps.status === "missing") {
|
|
168
160
|
return {
|
|
@@ -221,7 +213,7 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
221
213
|
checks,
|
|
222
214
|
actions: [
|
|
223
215
|
{ id: "rebuild-native-dev-client" },
|
|
224
|
-
...launchActions(resolved
|
|
216
|
+
...launchActions(resolved)
|
|
225
217
|
]
|
|
226
218
|
};
|
|
227
219
|
}
|
|
@@ -229,22 +221,6 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
229
221
|
const depsSatisfied = deps.status === "current";
|
|
230
222
|
if (depsSatisfied && (metroLog.reason === "stale-bundle-error" || metroLog.reason === "bundle-error")) {
|
|
231
223
|
const excerpt = metroLog.excerpt ?? "";
|
|
232
|
-
const persistent = evaluatePersistentBundleError(resolved, excerpt);
|
|
233
|
-
if (persistent.blocked) {
|
|
234
|
-
return {
|
|
235
|
-
schemaVersion: 1,
|
|
236
|
-
adapter: "mobile",
|
|
237
|
-
target: resolved,
|
|
238
|
-
decision: "blocked",
|
|
239
|
-
reasonCode: "bundle-error-persistent",
|
|
240
|
-
reasons: [
|
|
241
|
-
"Metro bundle keeps failing with the same error after a cache-cleared relaunch was already suggested; fix the bundle error in app code before retrying recipe up.",
|
|
242
|
-
...excerpt ? [excerpt] : []
|
|
243
|
-
],
|
|
244
|
-
checks,
|
|
245
|
-
actions: []
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
224
|
return {
|
|
249
225
|
schemaVersion: 1,
|
|
250
226
|
adapter: "mobile",
|
|
@@ -252,11 +228,11 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
252
228
|
decision: "launch",
|
|
253
229
|
reasonCode: metroLog.reason === "stale-bundle-error" ? "metro-stale-log" : "bundle-errors-recoverable",
|
|
254
230
|
reasons: [
|
|
255
|
-
metroLog.reason === "stale-bundle-error" ? "Metro log shows prior bundle failures but cited modules are installed; restart Metro
|
|
231
|
+
metroLog.reason === "stale-bundle-error" ? "Metro log shows prior bundle failures but cited modules are installed; restart Metro without clearing the cache. Pass --clear-metro if the failure persists." : "Metro bundle is failing with current deps; restart Metro without clearing the cache. Pass --clear-metro if the failure persists.",
|
|
256
232
|
...excerpt ? [excerpt] : []
|
|
257
233
|
],
|
|
258
234
|
checks,
|
|
259
|
-
actions: launchActions(resolved
|
|
235
|
+
actions: launchActions(resolved)
|
|
260
236
|
};
|
|
261
237
|
}
|
|
262
238
|
return {
|