@deeeed/metamask-harness 0.83.0 → 0.85.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 +62 -0
- package/README.md +8 -1
- package/adapters/extension/console-tail.mjs +11 -7
- package/adapters/extension/ensure-browser.sh +17 -18
- package/adapters/manifest.json +14 -110
- package/adapters/mobile/start-console-forwarder.sh +8 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
- package/adapters/shared/session-lock.mjs +43 -0
- package/adapters/web-dapp/metamask-signer.mjs +212 -0
- package/dist/adapters/extension/actions.js +5 -0
- package/dist/adapters/extension/console-capture.js +18 -0
- package/dist/adapters/extension/fixtures.js +2 -0
- package/dist/adapters/extension/overlay.js +5 -4
- package/dist/adapters/extension/run.js +4 -2
- package/dist/adapters/extension/runtime.js +73 -18
- package/dist/adapters/mobile/devices.js +5 -1
- package/dist/adapters/mobile/launch.js +19 -2
- package/dist/adapters/mobile/provision.js +4 -2
- package/dist/adapters/mobile/run.js +3 -2
- package/dist/adapters/mobile/surface.js +1 -2
- package/dist/adapters/mobile/verify.js +15 -7
- package/dist/adapters/plugin-surface.js +14 -42
- package/dist/adapters/session-lock.js +23 -0
- package/dist/adapters/web-dapp/actions.js +7 -5
- package/dist/adapters/web-dapp/console-capture.js +2 -20
- package/dist/adapters/web-dapp/run.js +25 -24
- package/dist/adapters/web-dapp/surface.js +32 -108
- package/dist/adapters.js +5 -3
- package/dist/command-contract.js +1351 -643
- package/dist/commands/check.js +5 -7
- package/dist/commands/debug.js +2 -2
- package/dist/commands/doctor.js +3 -4
- package/dist/commands/domain.js +4 -3
- package/dist/commands/fixtures.js +3 -3
- package/dist/commands/help.js +6 -5
- package/dist/commands/launch/device.js +1 -1
- package/dist/commands/launch/extension.js +9 -7
- package/dist/commands/logs.js +2 -2
- package/dist/commands/prepare.js +4 -4
- package/dist/commands/provision.js +2 -2
- package/dist/commands/review.js +8 -10
- package/dist/commands/runtime-launch.js +2 -0
- package/dist/commands/status.js +4 -2
- package/dist/commands/task-init.js +2 -3
- package/dist/console-classifier.js +3 -2
- package/dist/doctor.js +11 -4
- package/dist/mm-harness-cli.js +241 -1210
- package/dist/paths.js +9 -4
- package/dist/recipe-security.js +1 -1
- package/dist/review/knowledge.js +10 -2
- package/dist/runner.js +18 -3
- package/docs/CONTRIBUTING.md +14 -4
- package/docs/RECIPES.md +14 -1
- package/docs/cli-contract.md +8 -7
- package/docs/feature-guard.md +1 -1
- package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
- package/library/actions/extension/platform/cdp.mjs +26 -0
- package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
- package/library/actions/shared/console-assert.mjs +174 -0
- package/library/actions/shared/console-collector.mjs +11 -4
- package/library/actions/shared/console-findings.mjs +108 -7
- package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
- package/library/actions/web-dapp/app/launch.mjs +1 -1
- package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
- package/library/actions/web-dapp/platform/hud.mjs +3 -10
- package/library/actions/web-dapp/platform/page.mjs +63 -18
- package/library/actions/web-dapp/platform/runtime.mjs +30 -179
- package/library/diagnostics/console-allowlist.json +13 -0
- package/library/manifests/extension.action-manifest.json +36 -0
- package/library/manifests/mobile.action-manifest.json +36 -0
- package/library/manifests/web-dapp.action-manifest.json +1 -1
- package/library/recipes/core/health/check.recipe.json +40 -0
- package/library/recipes/extension/health/check.recipe.json +71 -0
- package/library/recipes/mobile/health/check.recipe.json +71 -0
- package/package.json +9 -9
- package/scripts/completions.sh +1 -1
- package/site/assets/help-recipes.json +2 -1
- package/adapters/web-dapp/cleanup.mjs +0 -69
- package/adapters/web-dapp/inject.mjs +0 -75
- package/adapters/web-dapp/launch.mjs +0 -631
- package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
- package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
- package/adapters/web-dapp/lib/display.mjs +0 -115
- package/adapters/web-dapp/lib/hud.mjs +0 -148
- package/adapters/web-dapp/lib/policy-fence.mjs +0 -119
- package/adapters/web-dapp/lib/policy.mjs +0 -84
- package/adapters/web-dapp/lib/processes.mjs +0 -187
- package/adapters/web-dapp/lib/readiness.mjs +0 -272
- package/adapters/web-dapp/stop.mjs +0 -38
- package/adapters/web-dapp/verify.mjs +0 -51
- package/adapters/web-dapp/wallet-host.mjs +0 -520
- package/dist/adapters/web-dapp/doctor-checks.js +0 -33
- package/dist/adapters/web-dapp/launch.js +0 -87
- package/dist/adapters/web-dapp/overlay.js +0 -21
- package/dist/adapters/web-dapp/readiness.js +0 -10
- package/dist/adapters/web-dapp/stop.js +0 -20
- package/dist/cli.js +0 -202
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,7 @@ export const COLLECTOR_SCRIPT_NAME = 'console-tail.mjs';
|
|
|
15
15
|
export const CAPTURE_CONTROL_MARK = '[mm-harness-capture-control]';
|
|
16
16
|
|
|
17
17
|
// Same derivation as ownerMarker('console-collector', …) in
|
|
18
|
-
//
|
|
18
|
+
// @farmslot/adapter-web/web-dapp, so the web-dapp stop path finds it.
|
|
19
19
|
export function collectorOwnerMarker(runtimeDir) {
|
|
20
20
|
const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
|
|
21
21
|
return `--mm-harness-owner=console-collector-${id}`;
|
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
81
|
-
const
|
|
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
|
-
|
|
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
|
|
104
|
-
allowlisted
|
|
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
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// start forgets the app's stored agent key and the extension's dapp permission.
|
|
5
5
|
|
|
6
6
|
import { appPort, cdpPort, isDirectRun, resolveSigner, runAdapter, webDappPolicy } from '../platform/runtime.mjs';
|
|
7
|
-
import { launchWebDappBrowser } from '
|
|
7
|
+
import { launchWebDappBrowser } from '@farmslot/adapter-web/web-dapp';
|
|
8
8
|
|
|
9
9
|
export async function launch(input) {
|
|
10
10
|
const node = input.node ?? {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Helpers from @farmslot/adapter-web's web-dapp lib that its public
|
|
2
|
+
// `./web-dapp` entry does not export, loaded from the installed package, never
|
|
3
|
+
// copied: the display and HUD helpers the actions share with the launcher and
|
|
4
|
+
// wallet host (one hud.json format), and the fixture and readiness helpers the
|
|
5
|
+
// MetaMask signer module uses. The signer module cannot import `./web-dapp`
|
|
6
|
+
// itself: the launch leaf loads it while that entry is still evaluating.
|
|
7
|
+
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const packageRoot = path.dirname(createRequire(import.meta.url).resolve('@farmslot/adapter-web/package.json'));
|
|
13
|
+
const lib = (name) => import(pathToFileURL(path.join(packageRoot, 'src/web-dapp/lib', name)).href);
|
|
14
|
+
|
|
15
|
+
export const { resolveHeadless, resolveSlowMo } = await lib('display.mjs');
|
|
16
|
+
export const { HUD_ELEMENT, clearHudState, hudRenderExpression, nextHudState, readHudState, writeHudState } = await lib('hud.mjs');
|
|
17
|
+
export const { fixtureEntry } = await lib('runtime.mjs');
|
|
18
|
+
export const { check: readinessCheck } = await lib('readiness.mjs');
|
|
@@ -5,17 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { isAppUrl } from '@farmslot/adapter-web/origin';
|
|
7
7
|
|
|
8
|
-
import { clearHudState, hudRenderExpression, nextHudState, readHudState, writeHudState } from '
|
|
8
|
+
import { clearHudState, hudRenderExpression, nextHudState, readHudState, writeHudState } from './adapter-web-lib.mjs';
|
|
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
|
-
|
|
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) {
|