@deeeed/metamask-harness 0.70.1 → 0.72.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/adapters/extension/console-tail.mjs +38 -6
  3. package/adapters/manifest.json +128 -0
  4. package/adapters/terminal/cleanup.mjs +68 -0
  5. package/adapters/terminal/inject.mjs +74 -0
  6. package/adapters/terminal/launch.mjs +628 -0
  7. package/adapters/terminal/lib/cdp-client.mjs +84 -0
  8. package/adapters/terminal/lib/display.mjs +115 -0
  9. package/adapters/terminal/lib/hud.mjs +148 -0
  10. package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
  11. package/adapters/terminal/lib/origin.mjs +30 -0
  12. package/adapters/terminal/lib/page-script.mjs +246 -0
  13. package/adapters/terminal/lib/processes.mjs +187 -0
  14. package/adapters/terminal/lib/readiness.mjs +274 -0
  15. package/adapters/terminal/lib/strict-wallet.mjs +138 -0
  16. package/adapters/terminal/stop.mjs +37 -0
  17. package/adapters/terminal/verify.mjs +48 -0
  18. package/adapters/terminal/wallet-host.mjs +703 -0
  19. package/dist/adapters/console-capture.js +122 -0
  20. package/dist/adapters/extension/console-capture.js +22 -85
  21. package/dist/adapters/mobile/verify.js +8 -3
  22. package/dist/adapters/surface.js +3 -1
  23. package/dist/adapters/terminal/console-capture.js +46 -0
  24. package/dist/adapters/terminal/stop.js +19 -0
  25. package/dist/adapters/terminal/surface.js +104 -0
  26. package/dist/adapters.js +27 -4
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +12 -4
  29. package/dist/commands/check.js +1 -1
  30. package/dist/commands/config.js +1 -1
  31. package/dist/commands/doctor.js +24 -1
  32. package/dist/commands/domain.js +1 -1
  33. package/dist/commands/farmslot-ready.js +2 -0
  34. package/dist/commands/help.js +2 -2
  35. package/dist/commands/launch/index.js +97 -2
  36. package/dist/commands/manifest.js +3 -3
  37. package/dist/commands/recipe-advice.js +1 -1
  38. package/dist/commands/review.js +1 -1
  39. package/dist/commands/run-engine.js +1 -1
  40. package/dist/commands/shared.js +1 -1
  41. package/dist/doctor.js +3 -0
  42. package/dist/harness.js +17 -11
  43. package/dist/manifest.js +5 -0
  44. package/dist/mm-harness-cli.js +34 -20
  45. package/dist/paths.js +2 -2
  46. package/dist/recipe-security.js +12 -3
  47. package/dist/review/knowledge.js +1 -1
  48. package/dist/run-diagnostics.js +150 -33
  49. package/dist/runner.js +1 -1
  50. package/dist/runtime-context.js +3 -3
  51. package/docs/CONTRIBUTING.md +1 -1
  52. package/docs/cli-contract.md +163 -0
  53. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  54. package/library/actions/shared/console-collector.mjs +230 -0
  55. package/library/actions/shared/console-findings.mjs +511 -0
  56. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  57. package/library/actions/terminal/app/launch.mjs +45 -0
  58. package/library/actions/terminal/perps/_venue.mjs +242 -0
  59. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  60. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  61. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  62. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  63. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  64. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  65. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  66. package/library/actions/terminal/platform/hud.mjs +71 -0
  67. package/library/actions/terminal/platform/page.mjs +247 -0
  68. package/library/actions/terminal/platform/runtime.mjs +200 -0
  69. package/library/actions/terminal/ui/navigate.mjs +31 -0
  70. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  71. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  72. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  73. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  74. package/library/manifests/terminal.action-manifest.json +1786 -0
  75. package/package.json +2 -1
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ // Write console allowlist entries (schemaVersion 3) from a real capture, so
3
+ // entries are copied from observed events instead of written by hand.
4
+ //
5
+ // Usage:
6
+ // node console-allowlist-add.mjs <run artifacts dir | capture log> [options]
7
+ // --allowlist <file> the library's diagnostics/console-allowlist.json
8
+ // (default: from RECIPE_LIBRARY_PATH, first library)
9
+ // --reason <text> why these events are noise (required with --write)
10
+ // --link <url> code or issue the reason points at
11
+ // --temporary the entry goes once a known fix lands
12
+ // --pick <n,n,...> the candidates to write (numbers from the listing)
13
+ // --write append the picked candidates to the allowlist
14
+ // (needs --pick: every entry is a deliberate choice)
15
+ // A run artifacts dir is read through its diagnostics.json: the run's slice of
16
+ // the raw slot capture (diagnostics-app-log.txt is redacted, so its query
17
+ // strings would not match). Without --write it lists the candidates: every
18
+ // warning, error or exception no entry matches yet, one per distinct event.
19
+
20
+ import { closeSync, fstatSync, openSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import {
25
+ ALLOWLIST_RELATIVE_PATH,
26
+ allowlistEntryFor,
27
+ allowlistMatch,
28
+ classifyConsoleRecord,
29
+ consoleRecords,
30
+ libraryRootsFromEnv,
31
+ loadConsoleAllowlist,
32
+ } from './console-findings.mjs';
33
+
34
+ function parseArgs(argv) {
35
+ const options = { input: null, allowlist: null, reason: null, link: null, temporary: false, pick: null, write: false };
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+ if (arg === '--allowlist') options.allowlist = argv[++index];
39
+ else if (arg === '--reason') options.reason = argv[++index];
40
+ else if (arg === '--link') options.link = argv[++index];
41
+ else if (arg === '--temporary') options.temporary = true;
42
+ else if (arg === '--pick') options.pick = String(argv[++index]).split(',').map(Number);
43
+ else if (arg === '--write') options.write = true;
44
+ else if (!options.input && !arg.startsWith('--')) options.input = arg;
45
+ else throw new Error(`unknown argument ${arg}`);
46
+ }
47
+ if (!options.input) throw new Error('usage: console-allowlist-add.mjs <run artifacts dir | capture log> [--allowlist <file>] [--reason <text>] [--link <url>] [--temporary] [--pick <n,...>] [--write]');
48
+ return options;
49
+ }
50
+
51
+ // The capture text a run diagnosed, or a whole log file.
52
+ export function captureText(input) {
53
+ const run = readJsonOr(path.join(input, 'diagnostics.json'), null);
54
+ if (run) {
55
+ const { source } = run;
56
+ const fd = openSync(source.path, 'r');
57
+ try {
58
+ // The same file the run diagnosed: its inode, and long enough.
59
+ const stat = fstatSync(fd);
60
+ if ((source.inode !== undefined && source.inode !== stat.ino) || stat.size < source.endOffset) {
61
+ throw new Error(`${source.path} is not the capture the run diagnosed (it was rotated or replaced since)`);
62
+ }
63
+ return readFileSync(fd).subarray(source.startOffset, source.endOffset).toString('utf8');
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+ return readFileSync(input, 'utf8');
69
+ }
70
+
71
+ // Distinct events no entry matches yet, as entries without a reason.
72
+ export function allowlistCandidates(text, allowlist = { entries: [] }) {
73
+ const candidates = new Map();
74
+ for (const record of consoleRecords(text)) {
75
+ const finding = classifyConsoleRecord(record);
76
+ if (!finding || allowlistMatch(finding, allowlist)) continue;
77
+ const entry = allowlistEntryFor(finding);
78
+ const id = JSON.stringify(entry);
79
+ const known = candidates.get(id);
80
+ if (known) known.count += 1;
81
+ else candidates.set(id, { entry, level: finding.level, count: 1 });
82
+ }
83
+ return [...candidates.values()];
84
+ }
85
+
86
+ function readJsonOr(file, fallback) {
87
+ try {
88
+ return JSON.parse(readFileSync(file, 'utf8'));
89
+ } catch (error) {
90
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return fallback;
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ export function appendEntries(file, entries) {
96
+ const document = readJsonOr(file, { schemaVersion: 3, entries: [] });
97
+ if (document.schemaVersion !== 3) throw new Error(`${file} is schemaVersion ${document.schemaVersion}; convert it to 3 first`);
98
+ document.entries.push(...entries);
99
+ writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
100
+ }
101
+
102
+ function main(argv) {
103
+ const options = parseArgs(argv);
104
+ const roots = options.allowlist ? [] : libraryRootsFromEnv();
105
+ const file = options.allowlist ?? (roots[0] ? path.join(roots[0], ALLOWLIST_RELATIVE_PATH) : null);
106
+ const allowlist = file ? loadConsoleAllowlist([path.dirname(path.dirname(file))]) : { entries: [], problems: [] };
107
+ const candidates = allowlistCandidates(captureText(options.input), allowlist);
108
+ const picked = options.pick ? candidates.filter((_, index) => options.pick.includes(index + 1)) : candidates;
109
+ if (!options.write) {
110
+ candidates.forEach((candidate, index) => {
111
+ process.stdout.write(`${index + 1}. ${candidate.level} ×${candidate.count}\n${JSON.stringify(candidate.entry, null, 2)}\n`);
112
+ });
113
+ if (candidates.length === 0) process.stdout.write('every warning and error in this capture is already allowlisted\n');
114
+ return 0;
115
+ }
116
+ if (!file) throw new Error('--write needs --allowlist <file> (or RECIPE_LIBRARY_PATH)');
117
+ if (!options.reason?.trim()) throw new Error('--write needs --reason');
118
+ if (!options.pick?.length) throw new Error('--write needs --pick <n,...>: choose the candidates to allowlist from the listing');
119
+ const entries = picked.map(({ entry }) => ({
120
+ ...entry,
121
+ reason: options.reason.trim(),
122
+ ...(options.link ? { link: options.link } : {}),
123
+ ...(options.temporary ? { temporary: true } : {}),
124
+ }));
125
+ appendEntries(file, entries);
126
+ process.stdout.write(`appended ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${file}\n`);
127
+ return 0;
128
+ }
129
+
130
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
131
+ try {
132
+ process.exitCode = main(process.argv.slice(2));
133
+ } catch (error) {
134
+ process.stderr.write(`console-allowlist-add: ${error.message}\n`);
135
+ process.exitCode = 2;
136
+ }
137
+ }
@@ -0,0 +1,230 @@
1
+ // The console collector (adapters/extension/console-tail.mjs) behind run
2
+ // diagnostics and assert_no_console_errors: how a collector is recognised as
3
+ // this runtime's own, and the evidence that it is capturing. A process is
4
+ // only ever signalled or trusted when its command line proves it is
5
+ // console-tail.mjs writing this runtime's log with this runtime's owner marker.
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ export const COLLECTOR_SCRIPT_NAME = 'console-tail.mjs';
14
+ // Logged in the app page to prove its console reaches the capture file.
15
+ export const CAPTURE_CONTROL_MARK = '[mm-harness-capture-control]';
16
+
17
+ // Same derivation as ownerMarker('console-collector', …) in
18
+ // adapters/terminal/lib/processes.mjs, so the terminal stop path finds it.
19
+ export function collectorOwnerMarker(runtimeDir) {
20
+ const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
21
+ return `--mm-harness-owner=console-collector-${id}`;
22
+ }
23
+
24
+ export function processCommand(pid) {
25
+ if (!Number.isInteger(pid) || pid <= 0) return null;
26
+ try {
27
+ process.kill(pid, 0);
28
+ } catch {
29
+ return null;
30
+ }
31
+ const result = spawnSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
32
+ const command = result.status === 0 ? result.stdout.trim() : '';
33
+ return command || null;
34
+ }
35
+
36
+ export function readPid(pidFile) {
37
+ try {
38
+ const pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
39
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ // This checkout's collector script: the only script a process may run to be
46
+ // adopted as this runtime's collector.
47
+ export const COLLECTOR_SCRIPT_PATH = fileURLToPath(new URL('../../../adapters/extension/console-tail.mjs', import.meta.url));
48
+
49
+ // A process's command line and the script it runs (its argv[1]), or null.
50
+ // Linux: /proc/<pid>/cmdline, exact. macOS: ps shows the arguments joined
51
+ // with spaces (a path may contain spaces), so argv[1] is what follows
52
+ // argv[0] (ps comm) up to " --cdp-port "; an argv[1] starting with "-" is a
53
+ // node option (node -e …), never a script.
54
+ export function processArgs(pid) {
55
+ const command = processCommand(pid);
56
+ if (!command) return null;
57
+ let script = null;
58
+ let args = null;
59
+ try {
60
+ const argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter((arg, index, all) => index < all.length - 1 || arg);
61
+ script = argv[1] ?? null;
62
+ args = argv.slice(1).join(' ');
63
+ } catch {
64
+ const comm = spawnSync('ps', ['-ww', '-p', String(pid), '-o', 'comm='], { encoding: 'utf8' }).stdout?.trim();
65
+ if (comm && command.startsWith(`${comm} `)) {
66
+ args = command.slice(comm.length + 1);
67
+ const end = args.indexOf(' --cdp-port ');
68
+ script = end > 0 ? args.slice(0, end) : null;
69
+ }
70
+ }
71
+ // args: everything from argv[1] on, joined with spaces.
72
+ return script && !script.startsWith('-') ? { command, script, args } : { command, script: null, args: null };
73
+ }
74
+
75
+ function sameFile(a, b) {
76
+ try {
77
+ return realpathSync(a) === realpathSync(b);
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // The command line (ps joins the arguments with spaces and a path may
84
+ // contain spaces, so it is never split on whitespace): each value is matched
85
+ // together with the argument that follows it, in the order
86
+ // ensureConsoleCollector starts the collector:
87
+ // <node> <dir>/console-tail.mjs --cdp-port <port> --extension-log <log>
88
+ // --dapp-log <log> <owner marker>
89
+ const writesExtensionLog = (command, extensionLog) => command.includes(` --extension-log ${extensionLog} --dapp-log `);
90
+
91
+ // This runtime's collector: running this checkout's console-tail.mjs (argv[1],
92
+ // resolved), writing this extension log, with this runtime's owner marker.
93
+ export function isOwnedCollector(proc, { extensionLog, marker, script = COLLECTOR_SCRIPT_PATH }) {
94
+ return Boolean(proc?.script) && sameFile(proc.script, script) && writesExtensionLog(proc.command, extensionLog) && proc.command.endsWith(` ${marker}`);
95
+ }
96
+
97
+ // A collector started before collectors carried owner markers, writing this
98
+ // runtime's extension log (that path lies in this runtime's directory): its
99
+ // argv[1] is a console-tail.mjs, from whichever harness started it.
100
+ export function isLegacyCollector(proc, { extensionLog }) {
101
+ return Boolean(proc?.script) && path.basename(proc.script) === COLLECTOR_SCRIPT_NAME &&
102
+ writesExtensionLog(proc.command, extensionLog) && !proc.command.includes('--mm-harness-owner=');
103
+ }
104
+
105
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
106
+
107
+ // This runtime's collector started by another install of the harness (the
108
+ // installed mm-harness and an MM_HARNESS_BIN checkout, say): argv[1] is a
109
+ // console-tail.mjs other than this checkout's, and the rest of the arguments
110
+ // are exactly the sequence a harness starts it with, for this runtime's
111
+ // extension log and owner marker. It is retired before a replacement starts;
112
+ // a process failing any of this (node -e, another runtime) is left alone.
113
+ export function isPriorInstallCollector(proc, { extensionLog, marker, script = COLLECTOR_SCRIPT_PATH }) {
114
+ if (!proc?.script || !proc.args || path.basename(proc.script) !== COLLECTOR_SCRIPT_NAME || sameFile(proc.script, script)) return false;
115
+ return new RegExp(`^${escapeRegExp(proc.script)} --cdp-port \\d+ --extension-log ${escapeRegExp(extensionLog)} --dapp-log \\S(?:.*\\S)? ${escapeRegExp(marker)}$`, 'u').test(proc.args);
116
+ }
117
+
118
+ // ...on this CDP port and these logs: the whole argument list, exactly.
119
+ export function collectorMatches(proc, { script = COLLECTOR_SCRIPT_PATH, cdpPort, extensionLog, pageLog, marker }) {
120
+ return isOwnedCollector(proc, { extensionLog, marker, script }) &&
121
+ proc.command.endsWith(` ${proc.script} --cdp-port ${cdpPort} --extension-log ${extensionLog} --dapp-log ${pageLog} ${marker}`);
122
+ }
123
+
124
+ // Is this runtime's collector running (on this CDP port)? { ok, pid, detail }
125
+ export function collectorStatus({ pidFile, extensionLog, marker, cdpPort, script = COLLECTOR_SCRIPT_PATH }) {
126
+ const pid = readPid(pidFile);
127
+ if (!pid) return { ok: false, pid: null, detail: `no console collector pid in ${pidFile}` };
128
+ const proc = processArgs(pid);
129
+ if (!proc) return { ok: false, pid, detail: `console collector pid ${pid} is not running` };
130
+ if (!isOwnedCollector(proc, { extensionLog, marker, script })) return { ok: false, pid, detail: `pid ${pid} is not this runtime's console collector` };
131
+ if (cdpPort && !proc.command.includes(` --cdp-port ${cdpPort} --extension-log `)) {
132
+ return { ok: false, pid, detail: `console collector pid ${pid} watches another CDP port than ${cdpPort}` };
133
+ }
134
+ return { ok: true, pid, detail: `console collector pid ${pid} on CDP ${cdpPort ?? '?'}` };
135
+ }
136
+
137
+ function originOf(url) {
138
+ try {
139
+ return new URL(String(url)).origin;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function fileSize(file) {
146
+ try {
147
+ return statSync(file).size;
148
+ } catch {
149
+ return 0;
150
+ }
151
+ }
152
+
153
+ function evaluateOnce(webSocketUrl, expression, timeoutMs) {
154
+ return new Promise((resolve, reject) => {
155
+ const socket = new WebSocket(webSocketUrl);
156
+ const timer = setTimeout(() => {
157
+ try { socket.close(); } catch { /* closing */ }
158
+ reject(new Error('timed out'));
159
+ }, timeoutMs);
160
+ const done = (error) => {
161
+ clearTimeout(timer);
162
+ try { socket.close(); } catch { /* closing */ }
163
+ if (error) reject(error);
164
+ else resolve();
165
+ };
166
+ socket.addEventListener('open', () => socket.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression } })));
167
+ socket.addEventListener('message', (event) => {
168
+ let message;
169
+ try {
170
+ message = JSON.parse(String(event.data));
171
+ } catch {
172
+ return;
173
+ }
174
+ if (message.id === 1) done(message.error ? new Error('the page refused the evaluation') : null);
175
+ });
176
+ socket.addEventListener('error', () => done(new Error('CDP connection failed')));
177
+ });
178
+ }
179
+
180
+ // The control check: log a marked line in a target the collector should be
181
+ // attached to and wait for it in that target's capture file. Proves the
182
+ // capture works now, for that file. { ok, detail }
183
+ // page an http(s) page on appOrigin -> the page log
184
+ // extension MetaMask's service worker or one of its pages, by MetaMask's
185
+ // extension id (another extension, such as Chrome's own
186
+ // component extensions, proves nothing) -> the extension log
187
+ // Details name only the expected target and log file, never values read from
188
+ // the browser.
189
+ export async function verifyCapture({ cdpPort, log, target: kind = 'page', appOrigin = null, extensionId = null, timeoutMs = 8000 }) {
190
+ const port = Number.parseInt(String(cdpPort), 10);
191
+ if (!(port > 0 && port < 65536)) return { ok: false, detail: `invalid CDP port ${JSON.stringify(cdpPort)}` };
192
+ if (kind === 'extension' && !/^[a-p]{32}$/u.test(String(extensionId ?? ''))) {
193
+ return { ok: false, detail: 'no MetaMask extension id is recorded for this slot (injected signer, or MetaMask was never launched)' };
194
+ }
195
+ const origin = kind === 'page' ? originOf(appOrigin) : null;
196
+ const label = kind === 'page' ? `${origin} page` : 'MetaMask extension';
197
+ let targets;
198
+ try {
199
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(3000) });
200
+ targets = await response.json();
201
+ } catch (error) {
202
+ return { ok: false, detail: `CDP ${port} is not reachable to verify the ${label} console capture (${error.name})` };
203
+ }
204
+ const candidates = (Array.isArray(targets) ? targets : []).filter((target) => target.webSocketDebuggerUrl && (kind === 'page'
205
+ ? target.type === 'page' && originOf(target.url) === origin
206
+ : ['service_worker', 'page'].includes(target.type) && String(target.url).startsWith(`chrome-extension://${extensionId}/`)));
207
+ // The service worker first: it lives as long as the extension.
208
+ const chosen = candidates.find((target) => target.type === 'service_worker') ?? candidates[0];
209
+ if (!chosen) return { ok: false, detail: `no ${label} target is open to verify the console capture` };
210
+ const nonce = randomUUID();
211
+ const start = fileSize(log);
212
+ try {
213
+ await evaluateOnce(chosen.webSocketDebuggerUrl, `console.info(${JSON.stringify(`${CAPTURE_CONTROL_MARK} ${nonce}`)})`, 3000);
214
+ } catch (error) {
215
+ return { ok: false, detail: `could not log the capture control line in the ${label} (${error.message})` };
216
+ }
217
+ const deadline = Date.now() + timeoutMs;
218
+ while (Date.now() < deadline) {
219
+ if (existsSync(log)) {
220
+ const text = readFileSync(log);
221
+ if (text.subarray(Math.min(start, text.length)).includes(nonce)) return { ok: true, detail: `control line from the ${label} reached ${path.basename(log)}` };
222
+ }
223
+ await new Promise((resolve) => setTimeout(resolve, 200));
224
+ }
225
+ return { ok: false, detail: `the capture control line logged in the ${label} did not reach ${log} within ${timeoutMs}ms` };
226
+ }
227
+
228
+ export function verifyPageCapture({ cdpPort, pageLog, appOrigin, timeoutMs }) {
229
+ return verifyCapture({ cdpPort, log: pageLog, target: 'page', appOrigin, timeoutMs });
230
+ }