@deeeed/metamask-harness 0.84.0 → 0.86.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 +65 -0
  2. package/README.md +5 -3
  3. package/adapters/extension/console-tail.mjs +11 -7
  4. package/adapters/extension/ensure-browser.sh +17 -18
  5. package/adapters/extension/refresh-build.sh +1 -1
  6. package/adapters/extension/start-watch.sh +17 -8
  7. package/adapters/manifest.json +1 -1
  8. package/adapters/mobile/reload-app.mjs +2 -1
  9. package/adapters/mobile/start-console-forwarder.sh +8 -0
  10. package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
  11. package/dist/adapters/console-capture.js +3 -1
  12. package/dist/adapters/extension/actions.js +5 -0
  13. package/dist/adapters/extension/console-capture.js +18 -0
  14. package/dist/adapters/extension/fixtures.js +2 -0
  15. package/dist/adapters/extension/overlay.js +5 -4
  16. package/dist/adapters/extension/runtime.js +2 -0
  17. package/dist/adapters/mobile/launch.js +19 -2
  18. package/dist/adapters/mobile/provision.js +4 -2
  19. package/dist/adapters/mobile/run.js +3 -2
  20. package/dist/adapters/mobile/verify.js +15 -7
  21. package/dist/adapters/web-dapp/actions.js +7 -5
  22. package/dist/adapters.js +5 -3
  23. package/dist/command-contract.js +32 -20
  24. package/dist/commands/check.js +5 -7
  25. package/dist/commands/debug.js +2 -2
  26. package/dist/commands/doctor.js +2 -3
  27. package/dist/commands/domain.js +4 -3
  28. package/dist/commands/fixtures.js +3 -3
  29. package/dist/commands/help.js +6 -5
  30. package/dist/commands/launch/extension.js +9 -7
  31. package/dist/commands/logs.js +2 -2
  32. package/dist/commands/prepare.js +4 -4
  33. package/dist/commands/provision.js +2 -2
  34. package/dist/commands/review.js +8 -10
  35. package/dist/commands/runtime-launch.js +2 -0
  36. package/dist/commands/status-probe.js +8 -1
  37. package/dist/commands/status.js +4 -2
  38. package/dist/commands/task-init.js +2 -3
  39. package/dist/console-classifier.js +3 -2
  40. package/dist/doctor.js +6 -2
  41. package/dist/mm-harness-cli.js +19 -6
  42. package/dist/paths.js +9 -4
  43. package/dist/recipe-security.js +1 -1
  44. package/dist/review/knowledge.js +16 -4
  45. package/dist/runner.js +18 -3
  46. package/docs/CONTRIBUTING.md +11 -0
  47. package/docs/QA.md +1 -0
  48. package/docs/RECIPES.md +18 -2
  49. package/docs/cli-contract.md +8 -6
  50. package/library/actions/core/perps/_controller.mjs +2 -2
  51. package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
  52. package/library/actions/extension/platform/cdp.mjs +72 -2
  53. package/library/actions/extension/ui/navigate.mjs +3 -1
  54. package/library/actions/extension/wallet/select_account.mjs +17 -17
  55. package/library/actions/extension/wallet/state.mjs +75 -21
  56. package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
  57. package/library/actions/mobile/wallet/select_account.mjs +34 -18
  58. package/library/actions/shared/console-assert.mjs +174 -0
  59. package/library/actions/shared/console-collector.mjs +10 -3
  60. package/library/actions/shared/console-findings.mjs +108 -7
  61. package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
  62. package/library/actions/web-dapp/platform/hud.mjs +2 -9
  63. package/library/actions/web-dapp/platform/page.mjs +62 -16
  64. package/library/actions/web-dapp/platform/runtime.mjs +1 -1
  65. package/library/diagnostics/console-allowlist.json +13 -0
  66. package/library/manifests/core.action-manifest.json +6 -6
  67. package/library/manifests/extension.action-manifest.json +37 -1
  68. package/library/manifests/mobile.action-manifest.json +40 -4
  69. package/library/manifests/web-dapp.action-manifest.json +1 -1
  70. package/library/recipes/core/health/check.recipe.json +40 -0
  71. package/library/recipes/extension/health/check.recipe.json +71 -0
  72. package/library/recipes/mobile/health/check.recipe.json +71 -0
  73. package/package.json +9 -9
  74. package/scripts/completions.sh +2 -2
  75. package/site/assets/help-recipes.json +3 -2
@@ -0,0 +1,174 @@
1
+ // What every adapter's metamask.app.assert_no_console_errors shares: reading
2
+ // a capture file from a launch mark, the launch window by the events' own
3
+ // timestamps, the allowlist roots, judging the records and the two failures
4
+ // (unallowlisted errors, capture not proven). Each adapter's action proves its
5
+ // own capture and window, then calls these.
6
+
7
+ import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import {
11
+ allowlistMatch,
12
+ classifyConsoleRecord,
13
+ consoleRecords,
14
+ libraryRootsFromEnv,
15
+ loadConsoleAllowlist,
16
+ recordStamp,
17
+ withBundledLibraryRoot,
18
+ } from './console-findings.mjs';
19
+
20
+ // The capture from a recorded offset. A file now shorter than the offset, or
21
+ // a different file, was replaced since: read it whole and say so, rather than
22
+ // reading nothing.
23
+ export function readFrom(file, mark) {
24
+ let fd;
25
+ try {
26
+ fd = openSync(file, 'r');
27
+ } catch (error) {
28
+ if (error?.code === 'ENOENT') return { text: '', reset: false };
29
+ throw error;
30
+ }
31
+ try {
32
+ // One open file: its size and inode are those of the content read.
33
+ const stat = fstatSync(fd);
34
+ const offset = Number(mark.offset ?? 0);
35
+ const reset = offset > stat.size || (mark.inode != null && mark.inode !== stat.ino);
36
+ return { text: readFileSync(fd).subarray(reset ? 0 : offset).toString('utf8'), reset };
37
+ } finally {
38
+ closeSync(fd);
39
+ }
40
+ }
41
+
42
+ // The mark a launch records before it can restart the app or browser, for
43
+ // since "launch": { launchedAtMs, ... }. Without one the window is unknown, so the capture is
44
+ // unproven.
45
+ export function readLaunchMark(file, unavailable) {
46
+ const name = path.basename(file);
47
+ let text;
48
+ try {
49
+ text = readFileSync(file, 'utf8');
50
+ } catch (error) {
51
+ if (error?.code === 'ENOENT') throw unavailable(`no launch mark (${name}) for since: "launch"; relaunch with mm-harness launch, or set since: "start-of-log"`);
52
+ throw error;
53
+ }
54
+ let mark;
55
+ try {
56
+ mark = JSON.parse(text);
57
+ } catch (error) {
58
+ throw unavailable(`unreadable launch mark ${name} (${error.message}); relaunch with mm-harness launch`);
59
+ }
60
+ if (!Number.isFinite(mark?.launchedAtMs)) throw unavailable(`launch mark ${name} has no launch time; relaunch with mm-harness launch`);
61
+ return mark;
62
+ }
63
+
64
+ const DAY_MS = 24 * 60 * 60 * 1000;
65
+
66
+ // How far a stamp may still show ahead of `now` once the device clock offset
67
+ // is applied (bridge latency, clock drift during the check).
68
+ const SKEW_MS = 5 * 60 * 1000;
69
+
70
+ function clockMs(clock) {
71
+ const [hours, minutes, seconds] = clock.split(':').map(Number);
72
+ return Math.round(((hours * 60 + minutes) * 60 + seconds) * 1000);
73
+ }
74
+
75
+ // The instant a time of day stands for when it is known to be close to `ref`:
76
+ // the one within 12 hours of it.
77
+ export function nearestInstant(clock, ref) {
78
+ const instant = ref - (ref % DAY_MS) + clockMs(clock);
79
+ if (instant - ref > DAY_MS / 2) return instant - DAY_MS;
80
+ if (ref - instant > DAY_MS / 2) return instant + DAY_MS;
81
+ return instant;
82
+ }
83
+
84
+ // The instant a record's UTC time of day stands for: the latest one not after
85
+ // `now` (plus skew), since a logged event cannot be in the future. Captures
86
+ // stamp only the time of day, so a stamp from before UTC midnight, or from an
87
+ // app or browser console that outlived the launch by up to a day, reads as the
88
+ // earlier day and falls before the launch. An event a whole day or more older
89
+ // whose time of day falls between the launch and now still reads as inside
90
+ // the window: it fails the check, never passes it.
91
+ export function stampInstant(clock, now) {
92
+ const cutoff = now + SKEW_MS;
93
+ const instant = cutoff - (cutoff % DAY_MS) + clockMs(clock);
94
+ return instant > cutoff ? instant - DAY_MS : instant;
95
+ }
96
+
97
+ // Which records a window keeps: with sinceMs, none stamped (by the event's own
98
+ // time) before it, the replay of an app or browser console that outlived the
99
+ // launch; with device, no forwarder record from another device. Stamps come
100
+ // from the device's clock, whose offset from the host's (device minus host)
101
+ // is known only within [offset.min, offset.max]: the launch moves onto it by
102
+ // the least offset and `now` by the greatest, so neither bound can drop a
103
+ // fresh record. A record without a stamp stays.
104
+ export function inWindow({ sinceMs = null, device = null, now = Date.now(), offset = { min: 0, max: 0 } }) {
105
+ return (record) => {
106
+ const stamp = recordStamp(record);
107
+ if (!stamp) return true;
108
+ if (device !== null && stamp.device !== null && stamp.device !== device) return false;
109
+ return sinceMs === null || stampInstant(stamp.clock, now + offset.max) >= sinceMs + offset.min;
110
+ };
111
+ }
112
+
113
+ // The action's own verdict (findings, or an unproven capture), not a crash.
114
+ // The runner reports its message alone, so a run's trace shows the list rather
115
+ // than a stack; anything else still surfaces with its stack.
116
+ function verdict(message) {
117
+ return Object.assign(new Error(message), { verdict: true });
118
+ }
119
+
120
+ export function captureUnavailable(detail, subject = 'The app console') {
121
+ return verdict(`console capture unavailable: ${detail}. ${subject} was not proven to be captured, so its absence of errors proves nothing.`);
122
+ }
123
+
124
+ // Run an action entrypoint; a verdict fails it with its message only.
125
+ export async function reportVerdict(run) {
126
+ try {
127
+ await run();
128
+ } catch (error) {
129
+ if (!error?.verdict) throw error;
130
+ process.stderr.write(`${error.message}\n`);
131
+ process.exitCode = 1;
132
+ }
133
+ }
134
+
135
+ export function consoleLevels(node) {
136
+ return new Set(Array.isArray(node.levels) && node.levels.length ? node.levels.map(String) : ['error', 'exception']);
137
+ }
138
+
139
+ // The bundled library's allowlist, then RECIPE_LIBRARY_PATH's, then the
140
+ // node's allowlist_root.
141
+ export function nodeAllowlist(node, env) {
142
+ const extra = typeof node.allowlist_root === 'string' ? [node.allowlist_root] : [];
143
+ return loadConsoleAllowlist(withBundledLibraryRoot([...libraryRootsFromEnv(env), ...extra]));
144
+ }
145
+
146
+ // Findings in the captured texts' records the window keeps, at the requested
147
+ // levels (and accepted sources), grouped by signature and split by the
148
+ // allowlist.
149
+ export function judgeConsole(texts, { levels, allowlist, accept = () => true, keep = () => true }) {
150
+ const failing = new Map();
151
+ const allowlisted = new Map();
152
+ for (const text of texts) {
153
+ for (const record of consoleRecords(text)) {
154
+ if (!keep(record)) continue;
155
+ const finding = classifyConsoleRecord(record);
156
+ if (!finding || !levels.has(finding.level) || !accept(finding)) continue;
157
+ const match = allowlistMatch(finding, allowlist);
158
+ const bucket = match ? allowlisted : failing;
159
+ const key = `${finding.level}|${finding.source}|${finding.signature}`;
160
+ const entry = bucket.get(key) ?? { level: finding.level, source: finding.source, signature: finding.signature, example: finding.message, firstFrame: finding.firstFrame, count: 0, ...(match ? { reason: match.reason } : {}) };
161
+ entry.count += 1;
162
+ bucket.set(key, entry);
163
+ }
164
+ }
165
+ return { findings: [...failing.values()], allowlisted: [...allowlisted.values()] };
166
+ }
167
+
168
+ export function throwOnFindings(findings, allowlistProblems = []) {
169
+ if (findings.length === 0) return;
170
+ const list = findings.map((item) => `${item.level} [${item.source}] ${item.example} ×${item.count}`).join('\n ');
171
+ // An entry the allowlist rejected may be the one meant to cover a finding.
172
+ const problems = allowlistProblems.length ? `\nallowlist entries not applied:\n ${allowlistProblems.join('\n ')}` : '';
173
+ throw verdict(`console errors not on the allowlist:\n ${list}${problems}`);
174
+ }
@@ -142,7 +142,7 @@ function originOf(url) {
142
142
  }
143
143
  }
144
144
 
145
- function fileSize(file) {
145
+ export function fileSize(file) {
146
146
  try {
147
147
  return statSync(file).size;
148
148
  } catch {
@@ -214,15 +214,22 @@ export async function verifyCapture({ cdpPort, log, target: kind = 'page', appOr
214
214
  } catch (error) {
215
215
  return { ok: false, detail: `could not log the capture control line in the ${label} (${error.message})` };
216
216
  }
217
+ if (await controlLineReached(log, start, nonce, timeoutMs)) return { ok: true, detail: `control line from the ${label} reached ${path.basename(log)}` };
218
+ return { ok: false, detail: `the capture control line logged in the ${label} did not reach ${log} within ${timeoutMs}ms` };
219
+ }
220
+
221
+ // Does the control line carrying `nonce` reach `log`, past byte `start`,
222
+ // within timeoutMs?
223
+ export async function controlLineReached(log, start, nonce, timeoutMs) {
217
224
  const deadline = Date.now() + timeoutMs;
218
225
  while (Date.now() < deadline) {
219
226
  if (existsSync(log)) {
220
227
  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)}` };
228
+ if (text.subarray(Math.min(start, text.length)).includes(nonce)) return true;
222
229
  }
223
230
  await new Promise((resolve) => setTimeout(resolve, 200));
224
231
  }
225
- return { ok: false, detail: `the capture control line logged in the ${label} did not reach ${log} within ${timeoutMs}ms` };
232
+ return false;
226
233
  }
227
234
 
228
235
  export function verifyPageCapture({ cdpPort, pageLog, appOrigin, timeoutMs }) {
@@ -4,16 +4,18 @@
4
4
  // allowlist generator, and the recipe library's checks/console-guard.mjs
5
5
  // (which imports this file), so every caller judges an event the same way.
6
6
  //
7
- // Captures: console-tail.mjs logs (a "HH:MM:SS [label] [browser:<source>]
7
+ // Captures: console-tail.mjs logs (a "HH:MM:SS.mmm [label] [browser:<source>]
8
8
  // LEVEL text (at [fn ]location)" record, the browser tag only on browser
9
9
  // Log entries, plus "│ "-prefixed continuation lines), Metro or
10
- // webpack logs (one line per record), and JSONL captures from a CDP watcher
10
+ // webpack logs and the Mobile console forwarder's app-console.log (one line
11
+ // per record), and JSONL captures from a CDP watcher
11
12
  // ({ kind: "console" | "exception" | "log", level, text, source | url }).
12
13
  //
13
- // Allowlist files: <recipe library>/diagnostics/console-allowlist.json
14
+ // Allowlist files: <recipe library>/diagnostics/console-allowlist.json; this
15
+ // package's own library (BUNDLED_LIBRARY_ROOT) is always read first.
14
16
  // { "schemaVersion": 3,
15
17
  // "entries": [{ "signature": "<sig of the first line>", "continuation": "<sig of the rest>",
16
- // "first_frame": "<sig of the location>", "source": "page|extension|browser|any",
18
+ // "first_frame": "<sig of the location>", "source": "page|extension|browser|app|any",
17
19
  // "reason": "<why it is noise>", "temporary": true }] }
18
20
  // An entry matches one event (a first line and every line that continues it)
19
21
  // by string equality of each part, no patterns:
@@ -38,12 +40,13 @@
38
40
 
39
41
  import { existsSync, readFileSync } from 'node:fs';
40
42
  import path from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
41
44
 
42
45
  export const ALLOWLIST_RELATIVE_PATH = path.join('diagnostics', 'console-allowlist.json');
43
46
  // Requests the harness itself makes (the launch probe of the mainnet block).
44
47
  export const HARNESS_PROBE_MARK = 'mm-harness-probe=1';
45
48
  const HARNESS_PROBE_PARAM = 'mm-harness-probe';
46
- const SOURCES = new Set(['page', 'extension', 'browser', 'any']);
49
+ const SOURCES = new Set(['page', 'extension', 'browser', 'app', 'any']);
47
50
  const SEVERITY = ['warning', 'error', 'exception'];
48
51
 
49
52
  // ---- severity -------------------------------------------------------------
@@ -212,6 +215,10 @@ function frameLocation(where) {
212
215
  }
213
216
 
214
217
  const RECORD_START = /^\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+\[[^\]]+\]/u;
218
+ // The adapter-rn console forwarder's " LEVEL HH:MM:SS.mmm [console:<device>] "
219
+ // head (Mobile app-console.log): its time and device name are the forwarder's,
220
+ // never part of the message.
221
+ const FORWARDER_HEAD = /^[A-Z]+\s{2,}(\d{2}:\d{2}:\d{2}\.\d{3}) \[console:([^\]]+)\]\s+/u;
215
222
  // "HH:MM:SS [label] [browser:<Log source>] LEVEL ": every field before the
216
223
  // message comes from the collector; the browser tag only ever here.
217
224
  const RECORD_HEAD = /^\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+\[([^\]]+)\]\s+(?:\[browser:([a-z-]+)\]\s+)?(?:([A-Z]+)\s{2,})?/u;
@@ -253,6 +260,18 @@ export function consoleRecords(text) {
253
260
  return records;
254
261
  }
255
262
 
263
+ // The time a record's head carries (the UTC time of day, HH:MM:SS[.mmm], of
264
+ // the event's own timestamp, as console-tail and the Mobile forwarder write
265
+ // it) and, for a forwarder record, its device; null for a record without
266
+ // either head.
267
+ export function recordStamp(record) {
268
+ const trimmed = String(record.line ?? '').trim();
269
+ const forwarded = FORWARDER_HEAD.exec(trimmed);
270
+ if (forwarded) return { clock: forwarded[1], device: forwarded[2] };
271
+ // A console-tail record starts with its time (RECORD_START).
272
+ return record.start ? { clock: /^[\d:.]+/u.exec(trimmed)[0], device: null } : null;
273
+ }
274
+
256
275
  function labelSource(label) {
257
276
  if (label.startsWith('dapp:')) return 'page';
258
277
  if (label.startsWith('extension:') || label === 'sw' || label.startsWith('page:')) return 'extension';
@@ -278,8 +297,10 @@ export function recordEvent(record) {
278
297
  // No recorded level: Metro/webpack output, or an unframed line.
279
298
  level = worst(lineLevel(trimmed), record.unframed ? 'error' : null);
280
299
  source = 'app';
300
+ const forwarded = FORWARDER_HEAD.exec(trimmed);
281
301
  const marker = /\b(?:EXCEPTION|ERROR|WARN(?:ING)?)\s{2,}/u.exec(trimmed);
282
- message = (marker ? trimmed.slice(marker.index + marker[0].length) : trimmed).trim();
302
+ if (forwarded) message = trimmed.slice(forwarded[0].length).trim();
303
+ else message = (marker ? trimmed.slice(marker.index + marker[0].length) : trimmed).trim();
283
304
  raw = message;
284
305
  }
285
306
  const suffix = LOCATION_SUFFIX.exec(message);
@@ -403,7 +424,7 @@ const ALLOWLIST_SCHEMA_VERSION = 3;
403
424
  export function entryProblem(entry) {
404
425
  if (!entry || typeof entry.signature !== 'string' || !entry.signature || typeof entry.continuation !== 'string' ||
405
426
  !SOURCES.has(entry.source ?? 'any') || typeof entry.reason !== 'string' || !entry.reason.trim()) {
406
- return 'needs signature, continuation ("" for none), source (page|extension|browser|any) and reason';
427
+ return 'needs signature, continuation ("" for none), source (page|extension|browser|app|any) and reason';
407
428
  }
408
429
  if (entry.first_frame !== undefined && typeof entry.first_frame !== 'string') return 'first_frame must be a string';
409
430
  if (signature(entry.signature) !== entry.signature) return `signature is not normalised (expected ${JSON.stringify(signature(entry.signature))})`;
@@ -464,8 +485,78 @@ export function loadConsoleAllowlist(libraryRoots = []) {
464
485
  }
465
486
 
466
487
  // The first entry equal to the event (by source and key), or null.
488
+ // Known noise that one exact allowlist entry cannot cover: logged in volume
489
+ // with a varying name, or with a stack that names build-specific chunk files.
490
+ // Matched by pattern on the event's signature; each rule says why it is not a
491
+ // finding, like an allowlist entry, and applies before every allowlist. Like
492
+ // an entry, a rule is only for an error reproduced on the app's base build.
493
+ export const BUILTIN_CONSOLE_RULES = Object.freeze([
494
+ {
495
+ id: 'ses-lockdown-intrinsics',
496
+ source: 'app',
497
+ pattern: /^(?:SES Removing unpermitted intrinsics|Removing intrinsics\.\S+|Tolerating undeletable intrinsics\.\S+ === undefined)$/u,
498
+ reason: "SES lockdown in MetaMask Mobile's Hermes build reports, at error level on every cold start, each unpermitted intrinsic it removes and each it cannot delete (seen on unchanged main, metamask-mobile b513a4b).",
499
+ link: 'https://github.com/endojs/endo/blob/master/packages/ses/docs/lockdown.md',
500
+ },
501
+ {
502
+ // Its stack names build-specific chunk files, so an exact entry would
503
+ // match one build only: the rule matches the message and ignores the stack.
504
+ id: 'extension-bearer-token-while-locked',
505
+ source: 'extension',
506
+ pattern: /^Error: getBearerToken - unable to proceed, wallet is locked$/u,
507
+ anyContinuation: true,
508
+ reason: 'Background services ask AuthenticationController.getBearerToken for a token while the Extension starts locked. Reproduced on unchanged main (metamask-extension a6765b12, macpro-mme-5, same wallet fixture and selected account as the feature slot).',
509
+ link: 'https://github.com/MetaMask/metamask-extension/issues/46688',
510
+ },
511
+ {
512
+ // Its stack names build-specific chunks; the rule pins the react-query
513
+ // hydrate frame it throws in and the messenger dispatch that calls it.
514
+ id: 'extension-money-query-mutations',
515
+ source: 'extension',
516
+ pattern: /^TypeError: Cannot read properties of undefined \(reading 'mutations'\)$/u,
517
+ continuationPattern: /^at hydrate \([^\n]*\)(?:\n[^\n]*)*?\nat SafeEventEmitter\.routeMessengerEventNotification \(/u,
518
+ reason: "The Money query adapter's messenger event notification hydrates a query cache entry that is not there and reads `mutations` on undefined. Reproduced on unchanged main (metamask-extension a6765b12, macpro-mme-5); app bug tracked in TAT-4063.",
519
+ },
520
+ {
521
+ // The message carries the Metro bundle URL and line, which change with
522
+ // every JS build; the rule pins the message and the failing function, in
523
+ // the Hermes form (?anon_<n>_name) the dev build logs. Another naming form
524
+ // does not match, so it fails rather than passes.
525
+ id: 'mobile-notifications-onchain-triggers',
526
+ source: 'app',
527
+ pattern: /^Failed to create On Chain triggers \{ message: "Failed to read wallet-activity subscriptions", stack: "Error: Failed to read wallet-activity subscriptions\\n {4}at \?anon_\d+_createOnChainTriggers \(/u,
528
+ reason: 'Mobile notifications fail on startup: createOnChainTriggers cannot read the wallet-activity subscriptions. Reproduced on unchanged main (metamask-mobile b513a4b, macpro-mm-4, same wallet fixture and selected account as the feature slot).',
529
+ },
530
+ {
531
+ id: 'mobile-notifications-enable',
532
+ source: 'app',
533
+ pattern: /^Unable to enable notifications \{ message: "Failed to create On Chain triggers", stack: "Error: Failed to create On Chain triggers\\n {4}at \?anon_\d+_createOnChainTriggers \(/u,
534
+ reason: 'Mobile notifications fail on startup: enabling them reports the createOnChainTriggers failure above. Reproduced on unchanged main (metamask-mobile b513a4b, macpro-mm-4).',
535
+ },
536
+ ].map((rule, index) => Object.freeze({ ...rule, file: 'builtin', index })));
537
+
538
+ // A rule's match carries the event's key like an allowlist entry does, one
539
+ // object per rule and key, so matches of the same event count together.
540
+ const builtinMatches = new Map();
541
+
542
+ function builtinRule(event) {
543
+ const { key } = event;
544
+ const rule = BUILTIN_CONSOLE_RULES.find((candidate) =>
545
+ candidate.source === event.source && candidate.pattern.test(key.signature) &&
546
+ (candidate.continuationPattern ? candidate.continuationPattern.test(key.continuation)
547
+ : candidate.anyContinuation || key.continuation === ''));
548
+ if (!rule) return null;
549
+ const id = `${rule.index}\n${key.signature}\n${key.continuation}\n${key.first_frame}`;
550
+ if (!builtinMatches.has(id)) {
551
+ builtinMatches.set(id, Object.freeze({ ...rule, signature: key.signature, continuation: key.continuation, first_frame: key.first_frame }));
552
+ }
553
+ return builtinMatches.get(id);
554
+ }
555
+
467
556
  export function matchEntry(event, entries) {
468
557
  const { key } = event;
558
+ const rule = builtinRule(event);
559
+ if (rule) return rule;
469
560
  return entries.find((entry) =>
470
561
  (entry.source === 'any' || entry.source === event.source) &&
471
562
  entry.signature === key.signature &&
@@ -502,6 +593,16 @@ export function entryFor(event) {
502
593
  }
503
594
  export const allowlistEntryFor = entryFor;
504
595
 
596
+ // This package's recipe library. Its diagnostics/console-allowlist.json holds
597
+ // known MetaMask noise and applies ahead of every other library's.
598
+ export const BUNDLED_LIBRARY_ROOT = path.resolve(fileURLToPath(new URL('../../', import.meta.url)));
599
+
600
+ // The allowlist roots to load: the bundled library first, then `roots`, each
601
+ // directory once.
602
+ export function withBundledLibraryRoot(roots = []) {
603
+ return [...new Set([BUNDLED_LIBRARY_ROOT, ...roots].map((root) => path.resolve(root)))];
604
+ }
605
+
505
606
  // RECIPE_LIBRARY_PATH: "name=/path:name=/path" (or bare paths).
506
607
  export function libraryRootsFromEnv(env = process.env) {
507
608
  return String(env.RECIPE_LIBRARY_PATH ?? '')
@@ -9,52 +9,21 @@
9
9
  // or MetaMask for the extension source) must reach that file; otherwise the
10
10
  // action fails as unavailable.
11
11
 
12
- import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs';
13
12
  import path from 'node:path';
14
13
 
15
- import {
16
- allowlistMatch,
17
- classifyConsoleRecord,
18
- consoleRecords,
19
- libraryRootsFromEnv,
20
- loadConsoleAllowlist,
21
- } from '../../shared/console-findings.mjs';
14
+ import { captureUnavailable, consoleLevels, judgeConsole, nodeAllowlist, readFrom, throwOnFindings } from '../../shared/console-assert.mjs';
22
15
  import { collectorOwnerMarker, collectorStatus, verifyCapture } from '../../shared/console-collector.mjs';
23
16
  import { isDirectRun, readBrowserState, runAdapter, webDappPolicy, webDappRuntimeDir } from '../platform/runtime.mjs';
24
17
 
25
18
  const LOGS = Object.freeze({ page: 'app-console.log', extension: 'extension-console.log' });
26
19
 
27
- // The capture from a recorded offset. A file now shorter than the offset, or
28
- // a different file, was replaced since: read it whole and say so, rather than
29
- // reading nothing.
30
- function readFrom(file, mark) {
31
- let fd;
32
- try {
33
- fd = openSync(file, 'r');
34
- } catch (error) {
35
- if (error?.code === 'ENOENT') return { text: '', reset: false };
36
- throw error;
37
- }
38
- try {
39
- // One open file: its size and inode are those of the content read.
40
- const stat = fstatSync(fd);
41
- const offset = Number(mark.offset ?? 0);
42
- const reset = offset > stat.size || (mark.inode != null && mark.inode !== stat.ino);
43
- return { text: readFileSync(fd).subarray(reset ? 0 : offset).toString('utf8'), reset };
44
- } finally {
45
- closeSync(fd);
46
- }
47
- }
48
-
49
- function unavailable(detail) {
50
- return new Error(`console capture unavailable: ${detail}. The page console was not proven to be captured, so its absence of errors proves nothing.`);
51
- }
20
+ const unavailable = (detail) => captureUnavailable(detail, 'The page console');
52
21
 
53
22
  export async function assertNoConsoleErrors(input, env = process.env, deps = {}) {
54
23
  const node = input.node ?? {};
55
24
  const projectRoot = input.context?.projectRoot;
56
25
  const sources = Array.isArray(node.sources) && node.sources.length ? node.sources.map(String) : ['page', 'browser'];
57
- const levels = new Set(Array.isArray(node.levels) && node.levels.length ? node.levels.map(String) : ['error', 'exception']);
26
+ const levels = consoleLevels(node);
58
27
  const runtime = webDappRuntimeDir(projectRoot);
59
28
  const state = await readBrowserState(projectRoot);
60
29
  if (!state?.cdpPort || !state.appOrigin) throw unavailable('no slot browser state (browser.json)');
@@ -77,31 +46,21 @@ export async function assertNoConsoleErrors(input, env = process.env, deps = {})
77
46
  control[file] = result.detail;
78
47
  }
79
48
  const offsets = node.since === 'start-of-log' ? {} : state?.consoleOffsets ?? {};
80
- const roots = [...libraryRootsFromEnv(env), ...(typeof node.allowlist_root === 'string' ? [node.allowlist_root] : [])];
81
- const allowlist = loadConsoleAllowlist(roots);
82
- const failing = new Map();
83
- const allowlisted = new Map();
49
+ const allowlist = nodeAllowlist(node, env);
50
+ const texts = [];
84
51
  const offsetResets = [];
85
52
  for (const file of files) {
86
53
  const { text, reset } = readFrom(path.join(runtime, LOGS[file]), { offset: offsets[file] ?? 0, inode: offsets.inodes?.[file] });
87
54
  if (reset) offsetResets.push(file);
88
- for (const record of consoleRecords(text)) {
89
- const finding = classifyConsoleRecord(record);
90
- if (!finding || !levels.has(finding.level) || !sources.includes(finding.source)) continue;
91
- const match = allowlistMatch(finding, allowlist);
92
- const bucket = match ? allowlisted : failing;
93
- const key = `${finding.level}|${finding.source}|${finding.signature}`;
94
- const entry = bucket.get(key) ?? { level: finding.level, source: finding.source, signature: finding.signature, example: finding.message, firstFrame: finding.firstFrame, count: 0, ...(match ? { reason: match.reason } : {}) };
95
- entry.count += 1;
96
- bucket.set(key, entry);
97
- }
55
+ texts.push(text);
98
56
  }
57
+ const { findings, allowlisted } = judgeConsole(texts, { levels, allowlist, accept: (finding) => sources.includes(finding.source) });
99
58
  const output = {
100
59
  action: input.action,
101
60
  sources,
102
61
  levels: [...levels],
103
- findings: [...failing.values()],
104
- allowlisted: [...allowlisted.values()],
62
+ findings,
63
+ allowlisted,
105
64
  allowlistProblems: allowlist.problems,
106
65
  capture: { collector: capture.detail, control },
107
66
  // The recorded launch offset was past the end of a replaced capture
@@ -109,10 +68,7 @@ export async function assertNoConsoleErrors(input, env = process.env, deps = {})
109
68
  offsetResets,
110
69
  proofPath: `${webDappPolicy().adapterId}-console-capture`,
111
70
  };
112
- if (failing.size > 0) {
113
- const list = [...failing.values()].map((item) => `${item.level} [${item.source}] ${item.example} ×${item.count}`).join('\n ');
114
- throw new Error(`console errors not on the allowlist:\n ${list}`);
115
- }
71
+ throwOnFindings(findings, allowlist.problems);
116
72
  return output;
117
73
  }
118
74
 
@@ -9,13 +9,6 @@ import { clearHudState, hudRenderExpression, nextHudState, readHudState, writeHu
9
9
  import { appOrigin, cdpPort, readBrowserState, webDappRuntimeDir } from './runtime.mjs';
10
10
  import { importRecipeHarnessRuntimeCdp } from '../../harness-exports.mjs';
11
11
 
12
- // --hud show draws in any mode; --hud auto (the default) only for a visible
13
- // browser. --hud hide never reaches this handler.
14
- export function hudVisible(policy, browserState) {
15
- if (policy === 'show') return true;
16
- return browserState?.browser?.headless !== true;
17
- }
18
-
19
12
  // Every CDP step is bounded: a busy tab must never stall the recipe.
20
13
  export function withDeadline(promise, timeoutMs, label) {
21
14
  let timer;
@@ -47,13 +40,13 @@ async function drawOnAppTabs(input, expression, timeoutMs = 3000) {
47
40
  return results;
48
41
  }
49
42
 
50
- export async function showAppHud(input, { hudPolicy = 'auto' } = {}) {
43
+ // The HUD draws in a headless browser too; --hud hide never reaches this handler.
44
+ export async function showAppHud(input) {
51
45
  const node = input.node ?? {};
52
46
  const projectRoot = input.context?.projectRoot;
53
47
  const nodeId = input.context?.nodeId ?? '';
54
48
  const browserState = projectRoot ? await readBrowserState(projectRoot) : null;
55
49
  if (!browserState) return { hud: false, skipped: true, reason: 'no slot browser yet' };
56
- if (!hudVisible(hudPolicy, browserState)) return { hud: false, skipped: true, reason: 'headless browser (--hud show draws anyway)' };
57
50
  const runtimeDir = webDappRuntimeDir(projectRoot);
58
51
  let state = null;
59
52
  if (node.clear === true) {
@@ -9,8 +9,8 @@
9
9
  import { isAppUrl } from '@farmslot/adapter-web/origin';
10
10
  import { cdpListenerPids } from '@farmslot/adapter-web/web-dapp';
11
11
 
12
- import { HUD_ELEMENT, resolveSlowMo } from './adapter-web-lib.mjs';
13
- import { appOrigin, cdpPort, readBrowserState } from './runtime.mjs';
12
+ import { readHudState, resolveSlowMo } from './adapter-web-lib.mjs';
13
+ import { appOrigin, cdpPort, readBrowserState, webDappRuntimeDir } from './runtime.mjs';
14
14
  import { importRecipeHarnessRuntimeCdp } from '../../harness-exports.mjs';
15
15
 
16
16
  const { CdpSession, CdpWebPage, listCdpTargets, sleep } = await importRecipeHarnessRuntimeCdp();
@@ -72,32 +72,78 @@ export async function selectAppTarget(input) {
72
72
  // MetaMask pages run under LavaMoat scuttling, so main-world helpers (timers in
73
73
  // wait loops) are unavailable there; evaluate in an isolated world that shares
74
74
  // the DOM instead.
75
+ //
76
+ // A wallet screenshot carries the running recipe's HUD too: hud.json's current
77
+ // step is drawn on the MetaMask page for the capture only, then removed. A
78
+ // finished or absent state (--hud hide) draws nothing.
75
79
  class WalletSurfacePage extends CdpWebPage {
76
80
  evaluate(expression) {
77
81
  return this.evaluateInIsolatedWorld(expression, 'web-dapp-wallet-surface');
78
82
  }
79
- }
80
83
 
81
- // Number inputs ignore select(), so the generic setInput would append to a
82
- // prefilled value (the order form prefills the mid price). Clear the field with
83
- // the editor's selectAll command, as a user's Cmd+A would, type over the
84
- // selection and require the field to read back exactly that value.
85
- // Screenshots are evidence of the app, so the recipe HUD is hidden while one
86
- // is captured and shown again afterwards.
87
- export function hudVisibilityExpression(visible) {
88
- return `(() => { const host = document.documentElement.querySelector(':scope > ${HUD_ELEMENT}'); if (!host) return false; host.style.visibility = ${JSON.stringify(visible ? '' : 'hidden')}; return true; })()`;
89
- }
90
-
91
- class AppPage extends CdpWebPage {
92
84
  async screenshot(context, relPath, metadata = {}) {
93
- const hidden = await this.evaluate(hudVisibilityExpression(false)).catch(() => false);
85
+ const state = context?.projectRoot ? readHudState(webDappRuntimeDir(context.projectRoot)) : null;
86
+ const drawn = state?.current && !state.finished
87
+ ? await this.evaluate(walletHudExpression(state)).then((result) => result?.hud === true, () => false)
88
+ : false;
94
89
  try {
95
90
  return await super.screenshot(context, relPath, metadata);
96
91
  } finally {
97
- if (hidden) await this.evaluate(hudVisibilityExpression(true)).catch(() => {});
92
+ if (drawn) await this.evaluate(walletHudExpression(null)).catch(() => {});
98
93
  }
99
94
  }
95
+ }
100
96
 
97
+ // The wallet-page HUD: a bar docked to the bottom edge that reserves its own
98
+ // height (a spacer after the content and the body's bottom padding), as the
99
+ // Extension's HUD does on MetaMask pages, so the request and its footer
100
+ // buttons move up instead of being covered. Inert, aria-hidden, pointer-events
101
+ // none. null removes it and restores the body padding.
102
+ export const WALLET_HUD_ID = 'mm-harness-wallet-hud';
103
+
104
+ export function walletHudExpression(state) {
105
+ const progress = state?.current?.progress;
106
+ const view = state ? {
107
+ badge: ['RUN', progress ? `${progress.current}/${progress.total}` : ''].filter(Boolean).join(' '),
108
+ intent: String(state.current?.intent ?? ''),
109
+ } : null;
110
+ return `(() => {
111
+ const view = ${JSON.stringify(view)};
112
+ const id = ${JSON.stringify(WALLET_HUD_ID)};
113
+ const old = document.getElementById(id);
114
+ if (old) {
115
+ document.body.style.paddingBottom = old.dataset.previousPadding || '';
116
+ old.remove();
117
+ document.getElementById(id + '-space')?.remove();
118
+ }
119
+ if (!view) return { hud: false, cleared: Boolean(old) };
120
+ const bar = document.createElement('div');
121
+ bar.id = id;
122
+ bar.setAttribute('aria-hidden', 'true');
123
+ bar.inert = true;
124
+ bar.dataset.previousPadding = document.body.style.paddingBottom;
125
+ Object.assign(bar.style, { position: 'fixed', left: '0', right: '0', bottom: '0', zIndex: '2147483647', pointerEvents: 'none', boxSizing: 'border-box', padding: '5px 8px', background: 'rgba(8, 10, 14, 0.9)', color: '#fff', font: '600 11px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', borderTop: '1px solid rgba(255,255,255,0.14)' });
126
+ const badge = document.createElement('span');
127
+ Object.assign(badge.style, { color: '#8ab4ff', fontSize: '9px', fontWeight: '800', marginRight: '6px' });
128
+ badge.textContent = view.badge;
129
+ bar.append(badge, view.intent);
130
+ document.body.append(bar);
131
+ const height = Math.ceil(bar.getBoundingClientRect().height);
132
+ const space = document.createElement('div');
133
+ space.id = id + '-space';
134
+ space.setAttribute('aria-hidden', 'true');
135
+ Object.assign(space.style, { display: 'block', height: height + 'px', pointerEvents: 'none' });
136
+ document.body.insertBefore(space, bar);
137
+ document.body.style.paddingBottom = height + 'px';
138
+ return { hud: true, docked: true, height };
139
+ })()`;
140
+ }
141
+
142
+ // Number inputs ignore select(), so the generic setInput would append to a
143
+ // prefilled value (the order form prefills the mid price). Clear the field with
144
+ // the editor's selectAll command, as a user's Cmd+A would, type over the
145
+ // selection and require the field to read back exactly that value.
146
+ class AppPage extends CdpWebPage {
101
147
  // A tab whose first load is still pending (a slow dev-server compile) has no
102
148
  // committed history entry yet; navigating away from it is still fine.
103
149
  async currentNavigationUrl() {
@@ -50,7 +50,7 @@ export async function runAdapter(callback) {
50
50
  const output = await callback(input);
51
51
  await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
52
52
  } catch (error) {
53
- process.stderr.write(`[${webDappAdapterId()}] ${input.action} failed: ${error?.stack ?? error}\n`);
53
+ process.stderr.write(`[${webDappAdapterId()}] ${input.action} failed: ${error?.verdict ? error.message : error?.stack ?? error}\n`);
54
54
  process.exitCode = 1;
55
55
  }
56
56
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "entries": [
4
+ {
5
+ "signature": "Warning: Using UNSAFE_componentWillMount in strict mode is not recommended and may indicate bugs in your code. See https://reactjs.org/link/unsafe-component-lifecycles for details.",
6
+ "continuation": "* Move code with side effects to componentDidMount, and set initial state in the constructor.\nPlease update the following components: %s UnlockPageBase",
7
+ "first_frame": "chrome-extension://<extension>/vendor-node_modules_ra.js",
8
+ "source": "extension",
9
+ "reason": "React warns once per load that the Extension unlock page (UnlockPageBase) still uses UNSAFE_componentWillMount. Reproduced on unchanged main (metamask-extension a6765b12, macpro-mme-5, same wallet fixture and selected account as the feature slot).",
10
+ "link": "https://github.com/MetaMask/metamask-extension/blob/main/ui/pages/unlock-page/unlock-page.component.tsx"
11
+ }
12
+ ]
13
+ }