@deeeed/metamask-harness 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +37 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +2 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/sidepanel-toggle.sh +33 -12
  8. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  9. package/adapters/manifest.json +13 -0
  10. package/adapters/mobile/inject.sh +1 -0
  11. package/adapters/mobile/stop-metro.sh +20 -0
  12. package/adapters/shared/cli-ux.sh +20 -2
  13. package/adapters/shared/ensure-runner-deps.sh +30 -0
  14. package/adapters/shared/reap-checkout-metros.sh +53 -0
  15. package/adapters/shared/recipe-harness-root.mjs +23 -0
  16. package/adapters/shared/resolve-farmslot-ports-core.mjs +15 -6
  17. package/bin/mm-harness +45 -4
  18. package/dist/adapters/extension/runtime.js +3 -1
  19. package/dist/adapters/mobile/provision.js +34 -1
  20. package/dist/adapters/slot-ports.js +3 -8
  21. package/dist/cli.js +32 -1463
  22. package/dist/commands/call.js +183 -0
  23. package/dist/commands/completion-candidates.js +58 -0
  24. package/dist/commands/doctor.js +101 -0
  25. package/dist/commands/ensure-ready.js +24 -0
  26. package/dist/commands/flows.js +62 -0
  27. package/dist/commands/launch/extension.js +40 -0
  28. package/dist/commands/{launch.js → launch/index.js} +15 -47
  29. package/dist/commands/launch/mobile.js +10 -0
  30. package/dist/commands/manifest.js +72 -0
  31. package/dist/commands/parse-args.js +189 -0
  32. package/dist/commands/provision.js +136 -0
  33. package/dist/commands/resolve-extension.js +23 -0
  34. package/dist/commands/run-engine.js +341 -0
  35. package/dist/commands/run.js +217 -0
  36. package/dist/commands/runtime-decision.js +58 -0
  37. package/dist/commands/runtime-health.js +25 -0
  38. package/dist/commands/runtime-launch.js +139 -0
  39. package/dist/commands/self-test.js +52 -0
  40. package/dist/commands/stop.js +52 -0
  41. package/dist/harness.js +8 -48
  42. package/dist/mm-harness-cli.js +13 -8
  43. package/docs/CLI-SPEC.md +1 -1
  44. package/docs/CODE-MAP.md +62 -0
  45. package/library/README.md +14 -0
  46. package/library/actions/extension/platform/cdp.mjs +1 -1
  47. package/library/actions/extension/wallet/ensure_unlocked.mjs +6 -0
  48. package/library/actions/mobile/wallet/ensure_unlocked.mjs +13 -1
  49. package/package.json +6 -7
  50. package/src/adapters/core/surface.ts +0 -71
  51. package/src/adapters/extension/ensure-ready.ts +0 -185
  52. package/src/adapters/extension/extension-id.ts +0 -107
  53. package/src/adapters/extension/runtime-decision.ts +0 -445
  54. package/src/adapters/extension/runtime.ts +0 -407
  55. package/src/adapters/extension/surface.ts +0 -88
  56. package/src/adapters/mobile/deps-markers.ts +0 -21
  57. package/src/adapters/mobile/prepare.ts +0 -246
  58. package/src/adapters/mobile/provision.ts +0 -594
  59. package/src/adapters/mobile/runtime-decision.ts +0 -466
  60. package/src/adapters/mobile/surface.ts +0 -71
  61. package/src/adapters/resolve-farmslot-ports.ts +0 -13
  62. package/src/adapters/slot-ports.ts +0 -158
  63. package/src/adapters/surface.ts +0 -117
  64. package/src/adapters.ts +0 -601
  65. package/src/cli-color.ts +0 -92
  66. package/src/cli-commands.ts +0 -250
  67. package/src/cli-version.ts +0 -141
  68. package/src/cli.ts +0 -2091
  69. package/src/commands/debug.ts +0 -65
  70. package/src/commands/fixtures.ts +0 -198
  71. package/src/commands/launch.ts +0 -470
  72. package/src/commands/logs.ts +0 -99
  73. package/src/commands/shared.ts +0 -235
  74. package/src/commands/update.ts +0 -316
  75. package/src/completions-cache.ts +0 -86
  76. package/src/doctor.ts +0 -215
  77. package/src/harness.ts +0 -797
  78. package/src/heal-bounds.ts +0 -198
  79. package/src/index.ts +0 -15
  80. package/src/leaf-invoke.ts +0 -28
  81. package/src/live-adapter-contract.ts +0 -274
  82. package/src/manifest.ts +0 -47
  83. package/src/mm-harness-cli.ts +0 -655
  84. package/src/paths.ts +0 -198
  85. package/src/progress.ts +0 -117
  86. package/src/recording-target.ts +0 -147
  87. package/src/run-recording.ts +0 -329
  88. package/src/runner.ts +0 -108
  89. package/src/types.ts +0 -57
@@ -423,6 +423,9 @@ function groupedHelp() {
423
423
  lines.push("");
424
424
  lines.push(`${out("warn", "DEV OVERRIDE ACTIVE")} \u2014 this run is served by MM_HARNESS_BIN=${out("path", process.env.MM_HARNESS_BIN)} (unset it to return to the installed/global bin).`);
425
425
  }
426
+ if (process.env.MM_HARNESS_BIN && process.env.MM_HARNESS_RUN_MODE) {
427
+ lines.push(`${out("label", "running from:")} ${out("path", process.env.MM_HARNESS_RUN_MODE)} (source checkout; dist shadows src when both exist)`);
428
+ }
426
429
  const slotLine = detectedSlotLine(out);
427
430
  if (slotLine) {
428
431
  lines.push("");
@@ -469,14 +472,8 @@ function translateActionsRaw(argv) {
469
472
  const program = new Command();
470
473
  program.name("mm-harness").description("the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay").helpOption("-h, --help", "Show grouped help").showHelpAfterError("(run `mm-harness --help` for the full surface)").configureHelp({ formatHelp: () => groupedHelp() });
471
474
  for (const command of REAL) {
472
- program.command(command.name).description(command.summary).allowUnknownOption().helpOption(false).argument("[args...]").action(async () => {
473
- const ddIdx = rawArgv.indexOf("--");
474
- const flagsBeforeSep = ddIdx === -1 ? rawArgv : rawArgv.slice(0, ddIdx);
475
- if (flagsBeforeSep.includes("--help") || flagsBeforeSep.includes("-h")) {
476
- process.stdout.write(`${command.helpText}
477
- `);
478
- process.exit(0);
479
- }
475
+ program.command(command.name).description(command.summary).allowUnknownOption().helpOption("-h, --help", "Show command help").configureHelp({ formatHelp: () => `${command.helpText}
476
+ ` }).argument("[args...]").action(async () => {
480
477
  if (command.name === "update") {
481
478
  process.exit(await handleUpdate(rawArgv.slice(1)));
482
479
  }
@@ -514,8 +511,16 @@ const NUDGE_SKIP = ["update", "completions", "completion-candidates"];
514
511
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
515
512
  setImmediate(() => void maybeNudge());
516
513
  }
514
+ function hasPassthroughHelp(argv) {
515
+ const divider = argv.indexOf("--");
516
+ if (divider === -1) return false;
517
+ return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
518
+ }
517
519
  if (rawArgv.length === 0) {
518
520
  process.stdout.write(groupedHelp());
519
521
  process.exit(0);
520
522
  }
523
+ if (hasPassthroughHelp(rawArgv)) {
524
+ process.exit(await delegate(rawArgv));
525
+ }
521
526
  await program.parseAsync(process.argv);
package/docs/CLI-SPEC.md CHANGED
@@ -180,7 +180,7 @@ Audience: **human** (daily, ≤1 flag), **agent** (depth), **both**.
180
180
 
181
181
  ## `launch` (REAL as of wave 3 → main DAILY LOOP verb)
182
182
 
183
- > **Implemented (wave 3):** `handleLaunch` in `runner/src/commands/launch.ts`. The typed
183
+ > **Implemented (wave 3):** `handleLaunch` in `src/commands/launch/`. The typed
184
184
  > layer owns grammar (mobile-target mandatory, core/extension refusals), tier
185
185
  > selection, quick-launch change detection, overlay auto-ensure, healing policy +
186
186
  > bounds, teaching errors, and the `--json` contract. It does NOT rewrite
@@ -0,0 +1,62 @@
1
+ # Code map — where things live and how a command flows
2
+
3
+ One page for humans. The contracts live in docs/UX-PRINCIPLES.md and
4
+ docs/ADAPTER-SURFACE.md; this is the geography.
5
+
6
+ ## How a command flows
7
+
8
+ ```
9
+ bin/mm-harness bash front door: MM_HARNESS_BIN dev/prod switch,
10
+ │ dist-vs-src selection (dist wins when present;
11
+ │ stale-dist warning), deps ensure
12
+
13
+ src/mm-harness-cli.ts commander surface: grouped colored help, per-command
14
+ │ --help, retired-name teaching; delegates in-process
15
+
16
+ src/cli.ts parse + dispatch ONLY — no verb logic lives here
17
+
18
+ ├── src/commands/<verb>.ts one module per verb (doctor, stop, call, run,
19
+ │ │ provision, fixtures, flows, logs, debug, …)
20
+ │ ▼
21
+ │ src/adapters/surface.ts AdapterSurface registry — per-platform behavior
22
+ │ │ (ports, runtime status, dev server, log sources)
23
+ │ ▼
24
+ │ src/adapters/{mobile,extension,core}/ TS logic per platform
25
+
26
+ └── src/harness.ts overlay lifecycle (install/verify/cleanup) — writes
27
+ │ the per-checkout overlay + runner delegate
28
+
29
+ adapters/ SHELL LEAVES (repo root — not src/adapters!):
30
+ mobile/ extension/ single-OS-op scripts the TS layer spawns
31
+ core/ shared/ (simctl/adb/tmux/metro/webpack lifecycle)
32
+ ```
33
+
34
+ ## The two `adapters/` directories
35
+
36
+ - `adapters/` (repo root) — executable shell leaves. Thin, OS-facing, spawned by TS.
37
+ - `src/adapters/` — TypeScript platform logic behind the AdapterSurface.
38
+ Rule: logic/decisions in TS; a leaf only executes. A leaf converts to TS only when
39
+ it starts making decisions (see backlog ruling), never as a campaign.
40
+
41
+ ## Other roots
42
+
43
+ - `library/` — recipe library: actions (`library/actions/<adapter>/…`, executable
44
+ action modules — importing one RUNS it, see library/README.md), flows, recipes.
45
+ - `tests/contract/` — hermetic bash contract tests (PATH-stubbed OS tools). The
46
+ launch-leaf-flags test enforces composer↔leaf flag agreement.
47
+ - `dist/` — esbuild output; the ONLY thing published runs. gitignored; in a source
48
+ checkout it SHADOWS src when present (bin warns when it goes stale).
49
+ - Per-checkout runtime state lives in the TARGET repo: `temp/recipe/runtime/`
50
+ (context, logs, wallet fixture) and `temp/recipe/harness/<adapter>/` (overlay +
51
+ runner delegate pinned to the installing bin via `.runner-source`).
52
+
53
+ ## Adding things
54
+
55
+ - **A verb**: module in `src/commands/`, register in `src/cli.ts` dispatch + the
56
+ commander metadata in `src/mm-harness-cli.ts` (help group), contract test.
57
+ - **A flag**: parse in the verb module; if it reaches a leaf, the leaf's parser
58
+ must accept it (launch-leaf-flags test will fail otherwise) — or pass via env.
59
+ - **A leaf**: shell under `adapters/<platform>/`, spawned via the shared spawn
60
+ helpers (never raw exec), contract test with PATH stubs, errors teach escape.
61
+ - **A platform behavior**: extend the AdapterSurface, implement per platform —
62
+ verbs must not branch on adapter for surface-owned behavior.
@@ -0,0 +1,14 @@
1
+ # Recipe library
2
+
3
+ - `actions/<adapter>/<group>/<name>.mjs` — executable action modules.
4
+ **Importing an action module RUNS it**: each ends with `runAdapter(main)` at top
5
+ level, which reads the live-adapter input path from the environment and executes.
6
+ That is the engine contract (the recipe engine spawns/imports them with the
7
+ input env set) — a bare `import` outside the engine throws
8
+ "Missing live adapter input path", which is expected, not a bug.
9
+ - Actions load harness helpers ONLY through `actions/harness-exports.mjs` (the
10
+ dist-preferring bridge) — never deep-import `src/` (published installs have no
11
+ src/, and the entrypoint would close an import cycle through adapters.ts).
12
+ - `recipes/` — runnable smoke/validation recipes (`mm-harness run <file>`).
13
+ - Flow resolution order is personal > team > canonical; shadowing is recorded in
14
+ summary.json (never silent).
@@ -14,7 +14,7 @@ import {
14
14
  } from '../../harness-exports.mjs';
15
15
 
16
16
  // Resolve the Farmslot harness through normal package dependencies by default.
17
- // Local Farmslot source is only a dev override handled by src/paths.ts.
17
+ // Local Farmslot source is only a dev override handled by the harness paths module.
18
18
  const {
19
19
  CdpSession,
20
20
  CdpWebPage,
@@ -19,6 +19,12 @@ async function fixturePassword(projectRoot) {
19
19
  }
20
20
 
21
21
  runAdapter((input) => withExtensionPage(input, async (page) => {
22
+ // A fresh profile shows onboarding, which has no password input — that is not
23
+ // an unlocked wallet, and passing here would be vacuous.
24
+ const onboarding = await page.evaluate(`(() => (window.location.hash || '').includes('onboarding'))()`);
25
+ if (onboarding) {
26
+ throw new Error('No wallet onboarded in this extension profile — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked');
27
+ }
22
28
  const locked = await page.evaluate(`(() => Boolean(document.querySelector('input[type="password"]')))()`);
23
29
  if (!locked) {
24
30
  return { action: input.action, unlocked: true, alreadyUnlocked: true, proofPath: 'extension-unlocked-state' };
@@ -19,7 +19,14 @@ async function fixturePassword(projectRoot) {
19
19
  }
20
20
 
21
21
  function selectedAccount(status, input) {
22
- return selectedStatus(status, input)?.account ?? null;
22
+ // The bridge reports an account OBJECT even pre-onboarding, with every field
23
+ // empty — an empty shell is not a wallet. Only a populated address/id counts,
24
+ // otherwise a fresh app "passes" unlock while sitting on the onboarding screen.
25
+ const account = selectedStatus(status, input)?.account ?? null;
26
+ if (!account || typeof account !== 'object') return null;
27
+ const address = typeof account.address === 'string' ? account.address : '';
28
+ const id = typeof account.id === 'string' ? account.id : '';
29
+ return address.length > 0 || id.length > 0 ? account : null;
23
30
  }
24
31
 
25
32
  function routeName(status, input) {
@@ -68,6 +75,11 @@ async function waitForUnlocked(input, timeoutMs = 15000) {
68
75
 
69
76
  runAdapter(async (input) => {
70
77
  const before = await status(input);
78
+ if (!selectedAccount(before, input) && routeName(before, input) !== 'Login') {
79
+ throw new Error(
80
+ `No wallet onboarded on this device (route ${routeName(before, input) || 'unknown'}, empty wallet status) — unlock has nothing to unlock.\n Next: mm-harness fixtures set # applies the fixture wallet, then re-run: mm-harness call ensure_unlocked`,
81
+ );
82
+ }
71
83
  if (selectedAccount(before, input) && routeName(before, input) !== 'Login') {
72
84
  return {
73
85
  action: input.action,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -19,7 +19,6 @@
19
19
  "@farmslot/protocol": "^0.7.2",
20
20
  "@farmslot/recipe-harness": "^0.3.3",
21
21
  "commander": "^12.0.0",
22
- "tsx": "^4.23.0",
23
22
  "viem": "^2.54.3"
24
23
  },
25
24
  "resolutions": {
@@ -33,15 +32,16 @@
33
32
  "esbuild": "0.28.1",
34
33
  "eslint": "^9",
35
34
  "globals": "^17.7.0",
35
+ "tsx": "^4.23.0",
36
36
  "typescript": "^5.6.0",
37
37
  "typescript-eslint": "^8"
38
38
  },
39
- "main": "./src/index.ts",
40
- "types": "./src/index.ts",
39
+ "main": "./dist/index.js",
41
40
  "exports": {
42
41
  ".": {
43
- "types": "./src/index.ts",
44
- "import": "./src/index.ts"
42
+ "development": "./src/index.ts",
43
+ "import": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
45
  }
46
46
  },
47
47
  "publishConfig": {
@@ -50,7 +50,6 @@
50
50
  "files": [
51
51
  "bin",
52
52
  "dist",
53
- "src",
54
53
  "adapters",
55
54
  "library",
56
55
  "scripts/completions.sh",
@@ -1,71 +0,0 @@
1
- // Core surface: headless. No app, no dev server — dependency presence is the
2
- // only runtime signal, and lifecycle commands teach the reachable headless path.
3
- import path from 'node:path';
4
-
5
- import { depsCheck } from '@farmslot/recipe-harness/runtime/deps-readiness';
6
-
7
- import type {
8
- AdapterDevServerStop,
9
- AdapterLogSource,
10
- AdapterRuntimeStatus,
11
- AdapterRunwayProvisionResult,
12
- AdapterSurface,
13
- } from '../surface.ts';
14
-
15
- export const coreSurface: AdapterSurface = {
16
- adapter: 'core',
17
- headless: true,
18
-
19
- resolveSlotPorts(): void {
20
- // Headless: no ports or device to resolve.
21
- },
22
-
23
- async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
24
- const deps = depsCheck(path.resolve(target));
25
- const ready = deps.status === 'current';
26
- return {
27
- decision: ready ? 'ready' : 'install',
28
- reasonCode: ready ? 'deps-present' : `deps-${deps.status}`,
29
- reasons: [
30
- ready
31
- ? 'Core is headless; dependencies are installed. Run recipes with mm-harness run.'
32
- : 'Core is headless; dependencies are not fully installed.',
33
- ],
34
- deps: deps.status,
35
- };
36
- },
37
-
38
- runwayProvision: {
39
- async run(target, options): Promise<AdapterRunwayProvisionResult> {
40
- return {
41
- schemaVersion: 1,
42
- command: 'provision',
43
- adapter: 'core',
44
- target: path.resolve(target),
45
- status: 'fail',
46
- exitCode: 2,
47
- error: { code: 'UNSUPPORTED_ADAPTER', message: 'core is headless and has no mobile simulator to provision.', userAction: options.rerunCommand || 'mm-harness run <recipe> --adapter core' },
48
- };
49
- },
50
- },
51
-
52
- devServer: {
53
- describe: () => 'no dev server (headless)',
54
- stop(): AdapterDevServerStop {
55
- return {
56
- kind: 'headless',
57
- message: 'core is headless — no dev server runs for a core checkout',
58
- userAction: 'use mm-harness verify (readiness) or mm-harness run (execute a recipe)',
59
- };
60
- },
61
- },
62
-
63
- logSources(): AdapterLogSource[] {
64
- return [];
65
- },
66
-
67
- hints: {
68
- launch: 'mm-harness run <recipe> # run recipes against the headless core',
69
- relaunch: 'mm-harness verify',
70
- },
71
- };
@@ -1,185 +0,0 @@
1
- // ensure-ready.ts — ensure the extension runtime is attachable.
2
- // runner/extension/extension-ensure-ready.ts) App/instance control.
3
- import path from 'node:path';
4
-
5
- import { resolveExtensionId } from './extension-id.ts';
6
-
7
- /**
8
- * "Drive the extension to a ready state" — the single source of truth for the
9
- * open-home + collapse-to-one-tab + verify-healthy logic that host launchers
10
- * and reopen helpers previously hand-rolled. They left duplicate home tabs, which trips
11
- * runtime-health's "exactly one home page" contract and makes agents see a
12
- * broken-looking runtime.
13
- *
14
- * Guarantees, given a live CDP port:
15
- * - the extension id is resolved deterministically (resolveExtensionId),
16
- * - exactly ONE chrome-extension home.html page target is open (opened if none,
17
- * extras closed if several),
18
- * - the runtime is verified healthy (checkExtensionRuntimeHealth).
19
- *
20
- * Pure-ish: it only opens/closes tabs to converge on the single-tab invariant;
21
- * it does not rebuild or relaunch. Unlocking the wallet remains with the host
22
- * launcher for now (a future increment can fold ensure_unlocked in here too).
23
- */
24
-
25
- export interface EnsureReadyResult {
26
- schemaVersion: 1;
27
- adapter: 'extension';
28
- target: string;
29
- cdpPort: number;
30
- extensionId: string | null;
31
- opened: boolean;
32
- homeTabs: { before: number; closed: number; after: number };
33
- ready: boolean;
34
- reasonCode: string;
35
- health: { status: 'PASS' | 'FAIL' | 'unknown'; findings: string[] };
36
- }
37
-
38
- interface CdpTarget {
39
- id?: string;
40
- type?: string;
41
- url?: string;
42
- }
43
-
44
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
45
-
46
- async function jsonList(port: number): Promise<CdpTarget[]> {
47
- try {
48
- const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) });
49
- if (!res.ok) return [];
50
- const value = await res.json();
51
- return Array.isArray(value) ? (value as CdpTarget[]) : [];
52
- } catch {
53
- return [];
54
- }
55
- }
56
-
57
- async function closeTab(port: number, id: string): Promise<void> {
58
- try {
59
- await fetch(`http://127.0.0.1:${port}/json/close/${id}`, { signal: AbortSignal.timeout(4000) });
60
- } catch {
61
- // best-effort; a tab that won't close is surfaced by the final health check
62
- }
63
- }
64
-
65
- async function activateTab(port: number, id: string): Promise<void> {
66
- try {
67
- await fetch(`http://127.0.0.1:${port}/json/activate/${id}`, { signal: AbortSignal.timeout(4000) });
68
- } catch {
69
- // best-effort; foregrounding only steers the headed-window screen capture onto
70
- // the wallet — the authoritative readiness signal is the health check below
71
- }
72
- }
73
-
74
- // Blank / New-Tab pages Chrome opens by default. The headed-window screen capture
75
- // (device-grid stream) follows whatever tab is foreground, so a lingering New Tab
76
- // makes the stream show chrome://newtab instead of the wallet. Safe to close once
77
- // a home tab exists.
78
- function isStrayTab(url?: string): boolean {
79
- if (typeof url !== 'string') return false;
80
- return /^chrome:\/\/newtab\/?$/.test(url) || /^chrome:\/\/new-tab-page\b/.test(url) || url === 'about:blank';
81
- }
82
-
83
- async function openHome(port: number, extensionId: string): Promise<boolean> {
84
- const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(`chrome-extension://${extensionId}/home.html`)}`;
85
- try {
86
- let res = await fetch(endpoint, { method: 'PUT', signal: AbortSignal.timeout(8000) });
87
- if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
88
- return res.ok;
89
- } catch {
90
- return false;
91
- }
92
- }
93
-
94
- function homePages(targets: CdpTarget[], extensionId: string): CdpTarget[] {
95
- const prefix = `chrome-extension://${extensionId}`;
96
- return targets.filter(
97
- (t) => t.type === 'page' && typeof t.url === 'string' && t.url.startsWith(prefix) && t.url.includes('/home.html') && t.id,
98
- );
99
- }
100
-
101
- export async function ensureExtensionReady(
102
- target: string,
103
- options: { cdpPort: number },
104
- ): Promise<EnsureReadyResult> {
105
- const resolved = path.resolve(target);
106
- const { cdpPort } = options;
107
- const base = (extra: Partial<EnsureReadyResult>): EnsureReadyResult => ({
108
- schemaVersion: 1,
109
- adapter: 'extension',
110
- target: resolved,
111
- cdpPort,
112
- extensionId: null,
113
- opened: false,
114
- homeTabs: { before: 0, closed: 0, after: 0 },
115
- ready: false,
116
- reasonCode: 'unknown',
117
- health: { status: 'unknown', findings: [] },
118
- ...extra,
119
- });
120
-
121
- const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
122
- if (!extensionId) return base({ reasonCode: 'no-extension-id' });
123
-
124
- const homes = homePages(await jsonList(cdpPort), extensionId);
125
- const before = homes.length;
126
-
127
- // Converge on exactly one home tab.
128
- // - 0 tabs → open one.
129
- // - 1 tab → keep it (cheap; the health check below validates it).
130
- // - >1 tabs → close ALL and open one fresh. /json/list alone can't tell a
131
- // healthy tab from a chrome-error one (a crashed page still lists its
132
- // intended url), so keeping the first could discard the good tab and keep a
133
- // broken one. A fresh tab is deterministically clean.
134
- let opened = false;
135
- let closed = 0;
136
- if (homes.length > 1) {
137
- for (const h of homes) {
138
- await closeTab(cdpPort, String(h.id));
139
- closed += 1;
140
- }
141
- await sleep(500);
142
- opened = await openHome(cdpPort, extensionId);
143
- await sleep(1500);
144
- } else if (homes.length === 0) {
145
- opened = await openHome(cdpPort, extensionId);
146
- await sleep(1500);
147
- }
148
-
149
- // Drop stray New Tab / blank pages and foreground the wallet so the headed
150
- // window captured by the device-grid stream shows MetaMask, not chrome://newtab.
151
- // Only prune once a home tab exists, so the window is never left with zero tabs
152
- // (which would close it).
153
- const converged = await jsonList(cdpPort);
154
- const homesNow = homePages(converged, extensionId);
155
- if (homesNow.length >= 1) {
156
- for (const t of converged) {
157
- if (t.type === 'page' && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
158
- }
159
- if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
160
- }
161
-
162
- const after = homePages(await jsonList(cdpPort), extensionId).length;
163
-
164
- // Verify with the single health source. Lazy import: extension-runtime.ts
165
- // loads the recipe harness at module scope, so it must not be pulled into
166
- // any no-CDP path.
167
- let health: EnsureReadyResult['health'] = { status: 'unknown', findings: [] };
168
- try {
169
- const { checkExtensionRuntimeHealth } = await import('./runtime.ts');
170
- const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
171
- health = { status: report.status, findings: report.findings };
172
- } catch (error) {
173
- health = { status: 'FAIL', findings: [error instanceof Error ? error.message : String(error)] };
174
- }
175
-
176
- const ready = health.status === 'PASS' && after === 1;
177
- return base({
178
- extensionId,
179
- opened,
180
- homeTabs: { before, closed, after },
181
- ready,
182
- reasonCode: ready ? 'ready' : after !== 1 ? 'tab-count' : 'unhealthy',
183
- health,
184
- });
185
- }
@@ -1,107 +0,0 @@
1
- // extension-id.ts — deterministic extension (CRX) id resolution.
2
- // App control: derives the id from the loaded dist manifest key. Name keeps
3
- // the extension- prefix: this is the CRX id, not an instance/slot id.
4
- import crypto from 'node:crypto';
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
-
8
- /**
9
- * Single source of truth for "which extension id is this MetaMask build".
10
- *
11
- * WHY THIS EXISTS. The id was resolved in three different, drifting places —
12
- * host `launch-browser.sh` (Playwright candidate loop), `reopen-browser.sh`
13
- * (`serviceWorkers()[0]`, which grabs Chrome's *component* extensions and broke
14
- * recovery), and the harness CDP target selector. They disagreed. This module
15
- * makes the runner the one resolver host launchers defer to.
16
- *
17
- * The id is **deterministic**: Chrome derives an unpacked extension's id from
18
- * the `key` in its manifest (base64-decode → sha256 → first 16 bytes → each
19
- * nibble mapped to a-p). Since the browser is launched with `--load-extension`
20
- * pointing at exactly this dist, the computed id IS the loaded extension's id —
21
- * no service-worker/page guessing, robust even when the MV3 worker is idle.
22
- *
23
- * `--cdp-port` (optional) only *verifies* the computed id is actually present in
24
- * the running browser; it never changes the answer.
25
- */
26
-
27
- export interface ResolveExtensionResult {
28
- adapter: 'extension';
29
- target: string;
30
- extensionId: string | null;
31
- source: 'manifest-key' | 'cdp-target' | 'none';
32
- verified: boolean | null;
33
- }
34
-
35
- const DIST_MANIFEST = 'dist/chrome/manifest.json';
36
-
37
- /** Chrome's unpacked-extension id derivation from a manifest `key` (base64 DER). */
38
- export function extensionIdFromKey(keyBase64: string): string {
39
- const der = Buffer.from(keyBase64, 'base64');
40
- const hash = crypto.createHash('sha256').update(der).digest();
41
- let id = '';
42
- for (let i = 0; i < 16; i += 1) {
43
- id += String.fromCharCode(97 + (hash[i] >> 4));
44
- id += String.fromCharCode(97 + (hash[i] & 0xf));
45
- }
46
- return id;
47
- }
48
-
49
- function idFromDistManifest(target: string): string | null {
50
- const manifestPath = path.join(target, DIST_MANIFEST);
51
- if (!fs.existsSync(manifestPath)) return null;
52
- try {
53
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { key?: unknown };
54
- return typeof manifest.key === 'string' && manifest.key.length > 0
55
- ? extensionIdFromKey(manifest.key)
56
- : null;
57
- } catch {
58
- return null;
59
- }
60
- }
61
-
62
- /** chrome-extension ids visible to CDP (page targets + service workers). */
63
- async function cdpExtensionIds(cdpPort: number): Promise<string[]> {
64
- try {
65
- const res = await fetch(`http://127.0.0.1:${cdpPort}/json/list`, { signal: AbortSignal.timeout(4000) });
66
- if (!res.ok) return [];
67
- const targets = (await res.json()) as Array<{ url?: string }>;
68
- const ids = new Set<string>();
69
- for (const t of targets) {
70
- const m = /^chrome-extension:\/\/([^/]+)/u.exec(String(t.url ?? ''));
71
- if (m) ids.add(m[1]);
72
- }
73
- return [...ids];
74
- } catch {
75
- return [];
76
- }
77
- }
78
-
79
- /**
80
- * Resolve the MetaMask extension id for a checkout. Pure file/crypto by default;
81
- * `--cdp-port` adds a presence check (and a fallback when the dist has no `key`).
82
- */
83
- export async function resolveExtensionId(
84
- target: string,
85
- options: { cdpPort?: number } = {},
86
- ): Promise<ResolveExtensionResult> {
87
- const resolved = path.resolve(target);
88
- const fromKey = idFromDistManifest(resolved);
89
- let cdpIds: string[] | null = null;
90
- if (options.cdpPort) cdpIds = await cdpExtensionIds(options.cdpPort);
91
-
92
- if (fromKey) {
93
- return {
94
- adapter: 'extension',
95
- target: resolved,
96
- extensionId: fromKey,
97
- source: 'manifest-key',
98
- verified: cdpIds ? cdpIds.includes(fromKey) : null,
99
- };
100
- }
101
- // No `key` in the dist manifest (rare). Fall back to a live CDP id if exactly
102
- // one extension is loaded; otherwise we cannot disambiguate deterministically.
103
- if (cdpIds && cdpIds.length === 1) {
104
- return { adapter: 'extension', target: resolved, extensionId: cdpIds[0], source: 'cdp-target', verified: true };
105
- }
106
- return { adapter: 'extension', target: resolved, extensionId: null, source: 'none', verified: cdpIds ? false : null };
107
- }