@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
@@ -76,6 +76,32 @@ export function extensionTarget(targets, extensionId = null, cdpTargetId = null)
76
76
  return home ?? sidepanel ?? null;
77
77
  }
78
78
 
79
+ // The recipe-runner HUD (app.hud's clear) removed from every extension page on
80
+ // the slot's CDP port. Launches nothing; each step is bounded and a missing
81
+ // port or browser clears nothing.
82
+ const CLEAR_HUD_EXPRESSION = "(() => { const hud = document.getElementById('farmslot-recipe-hud'); hud?.remove(); document.getElementById('farmslot-recipe-hud-reserved-space')?.remove(); document.documentElement.style.removeProperty('--farmslot-recipe-hud-height'); document.body?.style.removeProperty('padding-bottom'); return Boolean(hud); })()";
83
+
84
+ export async function clearExtensionHud(env = process.env, timeoutMs = 2000) {
85
+ const port = Number(env.CDP_PORT ?? env.RECIPE_CDP_PORT);
86
+ if (!Number.isInteger(port) || port <= 0) return { cleared: 0 };
87
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) }).catch(() => null);
88
+ const targets = response?.ok ? await response.json().catch(() => []) : [];
89
+ let cleared = 0;
90
+ for (const target of Array.isArray(targets) ? targets : []) {
91
+ if (target?.type !== 'page' || !String(target.url ?? '').startsWith('chrome-extension://') || !target.webSocketDebuggerUrl) continue;
92
+ let session;
93
+ try {
94
+ session = await CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs });
95
+ const result = await session.call('Runtime.evaluate', { expression: CLEAR_HUD_EXPRESSION, returnByValue: true }, { timeoutMs });
96
+ if (result?.result?.value === true) cleared += 1;
97
+ } catch {
98
+ // A busy or closing page keeps nothing worth failing the run for.
99
+ } finally {
100
+ session?.close();
101
+ }
102
+ }
103
+ return { cleared };
104
+ }
79
105
 
80
106
  function resolveRelativeArtifactPath(artifactsDir, relPath) {
81
107
  const relative = relPath || 'screenshots/extension-page.png';
@@ -955,8 +981,52 @@ export class ExtensionPage extends CdpWebPage {
955
981
  location.hash = ${JSON.stringify(normalizedHash)};
956
982
  return { before, href: location.href };
957
983
  })()`);
958
- await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
959
- return { ...navigation, sameDocument: true };
984
+ const ready = await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
985
+ return {
986
+ before: navigation.before,
987
+ requestedHref: navigation.href,
988
+ href: ready.url,
989
+ redirected: ready.redirected,
990
+ sameDocument: true,
991
+ };
992
+ }
993
+
994
+ // The Extension router may replace a requested hash with its canonical route
995
+ // in the same document (e.g. Perps symbol casing). The runtime still decides
996
+ // readiness; this accepts the rewritten URL once it holds for two polls. Any
997
+ // other origin, page or query still fails.
998
+ async waitForDocumentReady({ expectedUrl, timeoutMs = 10000 } = {}) {
999
+ const deadline = Date.now() + timeoutMs;
1000
+ let url;
1001
+ let redirectCandidate;
1002
+ while (true) {
1003
+ await super.waitForDocumentReady({ timeoutMs: Math.max(1, deadline - Date.now()) });
1004
+ // A context lost between the two probes counts as an unmatched poll.
1005
+ url = await this.evaluate('location.href').catch(() => undefined);
1006
+ if (!expectedUrl || url === expectedUrl) return { url, redirected: false };
1007
+ const redirect = this.isSameDocumentRedirect(expectedUrl, url) ? url : undefined;
1008
+ if (redirect && redirect === redirectCandidate) return { url, redirected: true };
1009
+ redirectCandidate = redirect;
1010
+ const remainingMs = deadline - Date.now();
1011
+ if (remainingMs <= 0) break;
1012
+ await sleep(Math.min(50, remainingMs));
1013
+ }
1014
+ throw new Error(`Extension page did not reach ${expectedUrl} within ${timeoutMs}ms (last URL ${url ?? 'unavailable'})`);
1015
+ }
1016
+
1017
+ isSameDocumentRedirect(expectedUrl, actualUrl) {
1018
+ try {
1019
+ const expected = new URL(expectedUrl);
1020
+ const actual = new URL(actualUrl);
1021
+ return `${expected.protocol}//${expected.host}` === this.origin
1022
+ && actual.protocol === expected.protocol
1023
+ && actual.host === expected.host
1024
+ && actual.pathname === expected.pathname
1025
+ && actual.search === expected.search
1026
+ && actual.hash !== expected.hash;
1027
+ } catch {
1028
+ return false;
1029
+ }
960
1030
  }
961
1031
 
962
1032
  async readPositions() {
@@ -591,7 +591,9 @@ export async function navigateExtensionUi(input) {
591
591
  const url = input.node?.url;
592
592
  if (typeof url === 'string' && url.length > 0) {
593
593
  const navigation = await page.navigate(url);
594
- return { action: input.action, url, navigation, proofPath: 'ui-navigation' };
594
+ // The router may canonicalize the requested route; report where it landed.
595
+ const href = await currentHref(page);
596
+ return { action: input.action, url, href, navigation, proofPath: 'ui-navigation' };
595
597
  }
596
598
 
597
599
  const hash = input.node?.hash ?? input.node?.path;
@@ -1,9 +1,10 @@
1
- // Selects an account by address, ID, or name through the background request hook.
2
- // Falls back to visible account search when that hook is unavailable and checks the selected address.
1
+ // Selects an account by address, ID, or account group/internal name through the background request hook.
2
+ // Returns without a write when the account is already selected. Falls back to visible account search
3
+ // when that hook is unavailable and checks the selected address.
3
4
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
4
5
  import {
5
- listWalletAccountsExpression,
6
6
  readWalletStateExpression,
7
+ resolveWalletAccountExpression,
7
8
  selectWalletAccountExpression,
8
9
  } from './state.mjs';
9
10
 
@@ -18,25 +19,24 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
18
19
  if (!requestedAddress && !requestedId && !requestedName) {
19
20
  throw new Error('metamask.wallet.select_account extension live adapter requires node.address, node.id, or node.name.');
20
21
  }
22
+ const criteria = { address: requestedAddress, id: requestedId, name: requestedName };
21
23
  try {
22
- const selected = await page.evaluate(selectWalletAccountExpression({
23
- address: requestedAddress,
24
- id: requestedId,
25
- name: requestedName,
26
- }), { awaitPromise: true });
27
- return { action: input.action, selected, proofPath: 'extension-background-account-selection' };
24
+ const { alreadySelected, ...selected } = await page.evaluate(
25
+ selectWalletAccountExpression(criteria),
26
+ { awaitPromise: true },
27
+ );
28
+ return {
29
+ action: input.action,
30
+ selected,
31
+ ...(alreadySelected ? { alreadySelected: true } : {}),
32
+ proofPath: 'extension-background-account-selection',
33
+ };
28
34
  } catch (error) {
29
35
  if (!String(error?.message ?? error).includes('submitRequestToBackground is unavailable')) throw error;
30
36
  }
31
37
 
32
- let searchAddress = requestedAddress;
33
- if (!searchAddress) {
34
- const listed = await page.evaluate(listWalletAccountsExpression('all'), { awaitPromise: true });
35
- const account = listed.accounts.find((entry) =>
36
- (requestedId && entry.id === requestedId) ||
37
- (requestedName && entry.name === requestedName));
38
- searchAddress = normalize(account?.address);
39
- }
38
+ const resolved = await page.evaluate(resolveWalletAccountExpression(criteria), { awaitPromise: true });
39
+ const searchAddress = normalize(resolved?.address);
40
40
  if (!searchAddress) throw new Error('Requested account does not have a searchable EVM address.');
41
41
 
42
42
  await page.navigateHash('#/account-list');
@@ -94,40 +94,94 @@ export function listWalletAccountsExpression(scope) {
94
94
  })()`;
95
95
  }
96
96
 
97
+ // Resolves id, address, or internal metadata name against internalAccounts first. A name
98
+ // with no internal match falls back to AccountTreeController group names (multichain
99
+ // wallets keep fixture names there, e.g. group "dev1" over internal "Account 1") and
100
+ // selects the group's EVM account; a group without one is rejected.
101
+ const resolveAccountPreamble = `
102
+ const resolveRequestedAccount = (state, criteria) => {
103
+ const accounts = state.internal.accounts || {};
104
+ const entries = Object.entries(accounts);
105
+ const isEvm = (account) => /^0x[0-9a-f]{40}$/iu.test(String(account?.address || ''));
106
+ const direct = entries.find(([id, account]) =>
107
+ (criteria.id && id === criteria.id) ||
108
+ (criteria.address && String(account?.address || '').toLowerCase() === criteria.address) ||
109
+ (criteria.name && account?.metadata?.name === criteria.name));
110
+ if (direct) return { id: direct[0], accountGroup: null };
111
+ if (!criteria.name) return null;
112
+ const groups = [];
113
+ for (const wallet of Object.values(state.metamask.accountTree?.wallets || {})) {
114
+ for (const [groupId, group] of Object.entries(wallet?.groups || {})) {
115
+ if (group?.metadata?.name === criteria.name) groups.push({ id: groupId, accounts: group.accounts || [] });
116
+ }
117
+ }
118
+ if (groups.length > 1) {
119
+ throw new Error('Requested account name ' + JSON.stringify(criteria.name) + ' matches ' + groups.length +
120
+ ' account groups; select by address instead.');
121
+ }
122
+ if (!groups.length) return null;
123
+ const id = groups[0].accounts.find((candidate) => isEvm(accounts[candidate]));
124
+ if (!id) {
125
+ throw new Error('Account group ' + JSON.stringify(criteria.name) +
126
+ ' has no EVM account; select by id instead.');
127
+ }
128
+ return { id, accountGroup: { id: groups[0].id, name: criteria.name } };
129
+ };
130
+ const describeAccount = (state, resolved) => {
131
+ const account = state.internal.accounts?.[resolved.id];
132
+ return {
133
+ id: resolved.id,
134
+ address: account?.address || null,
135
+ name: account?.metadata?.name || null,
136
+ type: account?.type || null,
137
+ ...(resolved.accountGroup ? { accountGroup: resolved.accountGroup } : {}),
138
+ };
139
+ };
140
+ `;
141
+
142
+ function criteriaLiteral(criteria) {
143
+ return JSON.stringify({
144
+ address: criteria.address || null,
145
+ id: criteria.id || null,
146
+ name: criteria.name || null,
147
+ });
148
+ }
149
+
150
+ export function resolveWalletAccountExpression(criteria) {
151
+ return `(async () => {
152
+ ${walletStatePreamble}
153
+ ${resolveAccountPreamble}
154
+ const state = await readWalletState();
155
+ const resolved = resolveRequestedAccount(state, ${criteriaLiteral(criteria)});
156
+ if (!resolved) throw new Error('Requested account was not found in account groups or internalAccounts.');
157
+ return {
158
+ ...describeAccount(state, resolved),
159
+ alreadySelected: resolved.id === (state.internal.selectedAccount || null),
160
+ };
161
+ })()`;
162
+ }
163
+
97
164
  export function selectWalletAccountExpression(criteria, timeoutMs = 5000) {
98
165
  return `(async () => {
99
166
  ${walletStatePreamble}
167
+ ${resolveAccountPreamble}
100
168
  const before = await readWalletState();
101
- const accounts = before.internal.accounts || {};
102
- const requestedAddress = ${JSON.stringify(criteria.address)};
103
- const requestedId = ${JSON.stringify(criteria.id)};
104
- const requestedName = ${JSON.stringify(criteria.name)};
105
- const match = Object.entries(accounts).find(([id, account]) => {
106
- const metadata = account?.metadata || {};
107
- return (requestedId && id === requestedId) ||
108
- (requestedAddress && String(account?.address || '').toLowerCase() === requestedAddress) ||
109
- (requestedName && metadata.name === requestedName);
110
- });
111
- if (!match) throw new Error('Requested account was not found in internalAccounts.');
169
+ const resolved = resolveRequestedAccount(before, ${criteriaLiteral(criteria)});
170
+ if (!resolved) throw new Error('Requested account was not found in account groups or internalAccounts.');
171
+ const id = resolved.id;
172
+ if ((before.internal.selectedAccount || null) === id) {
173
+ return { ...describeAccount(before, resolved), alreadySelected: true };
174
+ }
112
175
  if (typeof before.hooks.submitRequestToBackground !== 'function') {
113
176
  throw new Error('stateHooks.submitRequestToBackground is unavailable.');
114
177
  }
115
- const [id] = match;
116
178
  await before.hooks.submitRequestToBackground('setSelectedInternalAccount', [id]);
117
179
  const deadline = Date.now() + ${Number(timeoutMs)};
118
180
  let observedId = before.internal.selectedAccount || null;
119
181
  while (Date.now() <= deadline) {
120
182
  const after = await readWalletState();
121
183
  observedId = after.internal.selectedAccount || null;
122
- if (observedId === id) {
123
- const selected = after.internal.accounts?.[observedId];
124
- return {
125
- id: observedId,
126
- address: selected?.address || null,
127
- name: selected?.metadata?.name || null,
128
- type: selected?.type || null,
129
- };
130
- }
184
+ if (observedId === id) return describeAccount(after, resolved);
131
185
  await new Promise((resolve) => setTimeout(resolve, 100));
132
186
  }
133
187
  throw new Error(
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // Fail the recipe when the app logged an error or uncaught exception that no
3
+ // console allowlist marks as known noise. Reads app-console.log, written by the
4
+ // adapter-rn console forwarder (adapters/mobile/start-console-forwarder.sh).
5
+ // The same classifier and allowlist feed run diagnostics.
6
+ //
7
+ // Window: since "launch" (the default) judges the events since the launch
8
+ // began, by their own timestamps. A launch records its start once its
9
+ // refusals are behind it and before anything can restart the app
10
+ // (app-console.launch.json, recordMobileConsoleLaunch in
11
+ // src/adapters/mobile/launch.ts); a recovery attempt that restarts the app
12
+ // records it again. Stamps come from the device's clock, so the window is
13
+ // moved onto it by the offset the capture control line shows. Every launch
14
+ // restarts the forwarder, and start-console-forwarder.sh empties
15
+ // app-console.log and the forwarder's replay cursor as it does, so a
16
+ // relaunched app's first lines are kept; but on a launch that keeps the app
17
+ // runtime, the new forwarder replays that runtime's console from before the
18
+ // launch. The forwarder stamps each line with the event's own runtime time,
19
+ // so a record stamped before the launch is left out. Without a mark the
20
+ // action fails as unavailable. since "start-of-log" reads the whole file.
21
+ //
22
+ // Device: app-console.log holds every device the forwarder sees; only the
23
+ // device the control line came back from (the one the bridge selects for this
24
+ // run) is judged. When that line names no device, every device is, and the
25
+ // output says so (devices: "all").
26
+ //
27
+ // It never passes on missing evidence: the forwarder named by
28
+ // console-forwarder.pid must be alive and writing this checkout's
29
+ // app-console.log, and a control line logged inside the app through the debug
30
+ // bridge must reach that file; otherwise the action fails as unavailable. The
31
+ // opaque runtime (an installed release build) has no bridge, so it always
32
+ // fails as unavailable.
33
+
34
+ import { randomUUID } from 'node:crypto';
35
+ import { existsSync, realpathSync } from 'node:fs';
36
+ import path from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+
39
+ import { recipeRuntimeDir } from '@farmslot/recipe-cli/harness';
40
+
41
+ import { captureUnavailable, consoleLevels, inWindow, nearestInstant, judgeConsole, nodeAllowlist, readFrom, readLaunchMark, reportVerdict, throwOnFindings } from '../../shared/console-assert.mjs';
42
+ import { CAPTURE_CONTROL_MARK, controlLineReached, fileSize, processCommand, readPid } from '../../shared/console-collector.mjs';
43
+ import { consoleRecords, recordStamp } from '../../shared/console-findings.mjs';
44
+ import { evalSync, runAdapter } from '../platform/bridge.mjs';
45
+
46
+ const LOG = 'app-console.log';
47
+ const LAUNCH_MARK = 'app-console.launch.json';
48
+ const unavailable = (detail) => captureUnavailable(detail, 'The app console');
49
+
50
+ // The forwarder start-console-forwarder.sh started for this checkout: a live
51
+ // process running console-forwarder.cjs whose --out is this runtime's
52
+ // app-console.log, as the script writes it (the checkout's resolved path).
53
+ export function forwarderStatus({ pidFile, log }) {
54
+ const pid = readPid(pidFile);
55
+ if (!pid) return { ok: false, detail: `no console forwarder pid in ${pidFile}` };
56
+ const command = processCommand(pid);
57
+ if (!command) return { ok: false, detail: `console forwarder pid ${pid} is not running` };
58
+ if (!/\/console-forwarder\.cjs --port \S+ --out /u.test(command) || !command.endsWith(` --out ${log}`)) {
59
+ return { ok: false, detail: `pid ${pid} is not this checkout's console forwarder` };
60
+ }
61
+ return { ok: true, detail: `console forwarder pid ${pid}` };
62
+ }
63
+
64
+ // Log a control line inside the app; the forwarder writes it at info level,
65
+ // and the classifier never reports a capture control line anyway.
66
+ function logInApp(input, line) {
67
+ return evalSync(input, `(console.info(${JSON.stringify(line)}), true)`);
68
+ }
69
+
70
+ export async function assertNoConsoleErrors(input, env = process.env, deps = {}) {
71
+ const node = input.node ?? {};
72
+ const levels = consoleLevels(node);
73
+ if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1') {
74
+ throw unavailable('the opaque runtime has no debug bridge to log a control line through');
75
+ }
76
+ const projectRoot = input.context?.projectRoot;
77
+ if (!projectRoot || !existsSync(projectRoot)) throw unavailable(`no checkout at ${projectRoot}`);
78
+ const runtime = path.join(realpathSync(projectRoot), recipeRuntimeDir());
79
+ const log = path.join(runtime, LOG);
80
+ const forwarder = (deps.forwarderStatus ?? forwarderStatus)({ pidFile: path.join(runtime, 'console-forwarder.pid'), log });
81
+ if (!forwarder.ok) throw unavailable(forwarder.detail);
82
+ const mark = node.since === 'start-of-log' ? null : readLaunchMark(path.join(runtime, LAUNCH_MARK), unavailable);
83
+ const nonce = randomUUID();
84
+ const start = fileSize(log);
85
+ const clock = deps.clock ?? (() => deps.now ?? Date.now());
86
+ const before = clock();
87
+ try {
88
+ await (deps.logInApp ?? logInApp)(input, `${CAPTURE_CONTROL_MARK} ${nonce}`);
89
+ } catch (error) {
90
+ // No line logged: the capture is unproven, which is this action's failure.
91
+ throw unavailable(`could not log the capture control line in the app (${error.message})`);
92
+ }
93
+ const after = clock();
94
+ const timeoutMs = Number(node.timeout_ms) > 0 ? Number(node.timeout_ms) : 8000;
95
+ if (!await controlLineReached(log, start, nonce, timeoutMs)) {
96
+ throw unavailable(`the capture control line logged in the app did not reach ${log} within ${timeoutMs}ms`);
97
+ }
98
+ const { text } = readFrom(log, { offset: 0 });
99
+ // The control line names the device, and its stamp, from the device's clock,
100
+ // bounds that clock's offset from this machine's (a physical device's clock
101
+ // can run minutes off; the simulator shares the host's). The line was
102
+ // logged between `before` and `after` on the host, so bridge latency only
103
+ // widens the bound; it never moves the launch past a fresh record.
104
+ const control = consoleRecords(text).find((record) => record.line.includes(nonce));
105
+ const controlStamp = control ? recordStamp(control) : null;
106
+ const device = controlStamp?.device ?? null;
107
+ const controlAt = controlStamp ? nearestInstant(controlStamp.clock, before) : null;
108
+ const offset = controlAt === null ? { min: 0, max: 0 } : { min: controlAt - after, max: controlAt - before };
109
+ const allowlist = nodeAllowlist(node, env);
110
+ const keep = inWindow({ sinceMs: mark?.launchedAtMs ?? null, device, now: clock(), offset });
111
+ const { findings, allowlisted } = judgeConsole([text], { levels, allowlist, keep });
112
+ const output = {
113
+ action: input.action,
114
+ since: node.since ?? 'launch',
115
+ devices: device ?? 'all',
116
+ levels: [...levels],
117
+ findings,
118
+ allowlisted,
119
+ allowlistProblems: allowlist.problems,
120
+ capture: { forwarder: forwarder.detail, control: `control line from the app reached ${LOG}` },
121
+ proofPath: 'mobile-console-capture',
122
+ };
123
+ throwOnFindings(findings, allowlist.problems);
124
+ return output;
125
+ }
126
+
127
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
128
+ reportVerdict(() => runAdapter((input) => assertNoConsoleErrors(input)));
129
+ }
@@ -1,4 +1,5 @@
1
1
  // Select a wallet account by address, ID, or name and verify the resulting selection.
2
+ // A name with no internal account match falls back to AccountTreeController group names.
2
3
  // Observe ambiguous bridge write failures until the deadline without repeating the switch.
3
4
  import { pathToFileURL } from 'node:url';
4
5
  import {
@@ -34,13 +35,13 @@ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
34
35
  };
35
36
  }
36
37
 
37
- async function readSelectedAccount(input, deadline) {
38
+ async function readBridge(input, deadline, args, timeoutMessage) {
38
39
  let lastError = null;
39
40
  while (Date.now() < deadline) {
40
41
  try {
41
42
  return await bridgeCommand(
42
43
  withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
43
- ['get-selected-account'],
44
+ args,
44
45
  );
45
46
  } catch (error) {
46
47
  if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
@@ -48,24 +49,34 @@ async function readSelectedAccount(input, deadline) {
48
49
  await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
49
50
  }
50
51
  }
51
- throw lastError ?? new Error('Timed out reading the selected Mobile account.');
52
+ throw lastError ?? new Error(timeoutMessage);
52
53
  }
53
54
 
54
- async function readAccounts(input, deadline) {
55
- let lastError = null;
56
- while (Date.now() < deadline) {
57
- try {
58
- return await bridgeCommand(
59
- withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
60
- ['list-accounts'],
61
- );
62
- } catch (error) {
63
- if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
64
- lastError = error;
65
- await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
55
+ const readSelectedAccount = (input, deadline) =>
56
+ readBridge(input, deadline, ['get-selected-account'], 'Timed out reading the selected Mobile account.');
57
+
58
+ const readAccounts = (input, deadline) =>
59
+ readBridge(input, deadline, ['list-accounts'], 'Timed out reading Mobile accounts.');
60
+
61
+ // Multichain wallets name the account group, not always the internal account.
62
+ async function findAccountByGroupName(input, deadline, accounts, name) {
63
+ const wallets = await readBridge(
64
+ input,
65
+ deadline,
66
+ ['get-state', 'engine.backgroundState.AccountTreeController.accountTree.wallets'],
67
+ 'Timed out reading Mobile account groups.',
68
+ );
69
+ const groups = [];
70
+ for (const wallet of Object.values(wallets && typeof wallets === 'object' ? wallets : {})) {
71
+ for (const [groupId, group] of Object.entries(wallet?.groups ?? {})) {
72
+ if (group?.metadata?.name === name) groups.push({ id: groupId, accounts: group.accounts ?? [] });
66
73
  }
67
74
  }
68
- throw lastError ?? new Error('Timed out reading Mobile accounts.');
75
+ if (groups.length > 1) {
76
+ throw new Error(`Requested mobile account name ${JSON.stringify(name)} matches ${groups.length} account groups; select by address instead.`);
77
+ }
78
+ const match = accounts.find((account) => groups[0]?.accounts.includes(account?.id));
79
+ return match ? { ...match, accountGroup: { id: groups[0].id, name } } : null;
69
80
  }
70
81
 
71
82
  function ambiguousWriteFailure(writeError, match, finalSelected, readError) {
@@ -98,7 +109,7 @@ export async function selectAccount(input) {
98
109
  if (!Array.isArray(accounts)) {
99
110
  throw new Error(`metamask.wallet.select_account expected list-accounts to return an array, got ${typeof accounts}.`);
100
111
  }
101
- const match = accounts.find((account) => {
112
+ let match = accounts.find((account) => {
102
113
  const address = String(account?.address ?? '').toLowerCase();
103
114
  const id = String(account?.id ?? '');
104
115
  const name = String(account?.name ?? '');
@@ -106,8 +117,11 @@ export async function selectAccount(input) {
106
117
  (requestedId && id === requestedId) ||
107
118
  (requestedName && name === requestedName);
108
119
  });
120
+ if (!match && requestedName) {
121
+ match = await findAccountByGroupName(input, deadline, accounts, requestedName);
122
+ }
109
123
  if (!match?.address) {
110
- throw new Error(`Requested mobile account was not found in list-accounts: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
124
+ throw new Error(`Requested mobile account was not found in list-accounts or account groups: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
111
125
  }
112
126
  const current = await readSelectedAccount(input, deadline);
113
127
  const matchAddress = String(match.address).toLowerCase();
@@ -118,6 +132,7 @@ export async function selectAccount(input) {
118
132
  address: current?.address ?? match.address,
119
133
  id: current?.id ?? match.id,
120
134
  name: current?.name ?? match.name,
135
+ ...(match.accountGroup ? { accountGroup: match.accountGroup } : {}),
121
136
  },
122
137
  alreadySelected: true,
123
138
  proofPath: 'agentic-account-selection',
@@ -172,6 +187,7 @@ export async function selectAccount(input) {
172
187
  address: selected?.address ?? result?.address ?? match.address,
173
188
  id: selected?.id ?? result?.id ?? match.id,
174
189
  name: selected?.name ?? result?.name ?? match.name,
190
+ ...(match.accountGroup ? { accountGroup: match.accountGroup } : {}),
175
191
  },
176
192
  ...(ambiguousWriteError
177
193
  ? { recoveredAfterAmbiguousWrite: true }