@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-07-06
4
+
5
+ Minor release: CLI hardening (decomposed, single-parser, dist-only) plus a
6
+ fresh-slot validation wave — every fix proven live on untouched -2 slots and
7
+ independently cross-reviewed.
8
+
9
+ ### Changed
10
+ - **CLI decomposition + dist-only publish** — `src/cli.ts` is parse+dispatch only;
11
+ handlers under `src/commands/<verb>.ts`; one commander parser; the npm tarball
12
+ ships compiled `dist/` without `src/` and `tsx` is a devDependency (plain-node,
13
+ smaller, faster). Source checkouts keep the tsx dev path (`MM_HARNESS_BIN`).
14
+ - `verify` auto-ensures its own overlay (no refusal on a fresh checkout); every
15
+ engine execution resolves the slot's ports/device first (call/run/fixtures get
16
+ the same slot isolation launch has).
17
+
18
+ ### Added
19
+ - **`stop` reaps leaked Metro bundlers by checkout** (port-agnostic, path-
20
+ boundary matched), `doctor` surfaces any orphan and teaches the reap.
21
+ - **`launch --url <dapp>`** (extension) opens the dapp in the main tab beside the
22
+ MetaMask sidepanel; `--stop-only` profile release before seed; stable per-checkout
23
+ Chrome profile + runtime-dist; post-open tab cleanup.
24
+ - DX: run-mode + stale-`dist` warning in `bin`; overlay-delegation notice; docs/CODE-MAP.md.
25
+
26
+ ### Fixed
27
+ - `launch` targets the slot's own simulator (never the `booted` alias) and boots it;
28
+ slot-port resolution layers rungs so a partial rung never nulls the port; provision
29
+ resolves the simulator from the pool and boots before install.
30
+ - `call` works from published installs (dist bridge, no type-strip crash);
31
+ `ensure_unlocked` fails teaching `fixtures set` on an un-onboarded wallet instead
32
+ of vacuously passing (mobile + extension); `provision --json` usage errors emit the
33
+ standard envelope; deps-not-ready teaches a command that works on a bare checkout.
3
34
  ## 0.5.1 - 2026-07-05
4
35
 
5
36
  Fresh-install hotfixes found in live validation of 0.5.0.
@@ -11,7 +42,12 @@ Fresh-install hotfixes found in live validation of 0.5.0.
11
42
 
12
43
  ## Unreleased
13
44
 
45
+ ### Fixed
46
+ - **`provision --json` adapter-detection failures emit the standard error envelope** — usage errors now match `doctor`/`launch` (envelope on stdout with `error.userAction`; stderr stays human-only). Previously `--json` callers got empty stdout.
47
+
14
48
  ### Changed
49
+ - **CLI decomposition** — `src/cli.ts` is parse+dispatch only; handlers live under `src/commands/<verb>.ts`; `provision` routes through `src/commands/provision.ts` (not overlay harness dispatch); launch composition splits under `src/commands/launch/`; commander owns per-command `--help` (no hand-rolled interception).
50
+ - **Published tarball is dist-only** — `files` no longer ships `src/`; `tsx` is a devDependency; `bin/mm-harness` and overlay leaves resolve `dist/*.js` (teaching error if a published install lacks `dist/`). Library actions keep importing `library/actions/harness-exports.mjs` (dist-preferring narrow bridge from 0.5.1); package exports remain for external consumers (`dist/index.js` published; `src/index.ts` when `--conditions=development` and dist is absent).
15
51
  - **resolve-farmslot-ports is TypeScript-first** — pool/context/formula port resolution lives in self-contained `adapters/shared/resolve-farmslot-ports-core.mjs` (works from runner and injected overlay copies); `src/adapters/resolve-farmslot-ports.ts` re-exports it for `slot-ports.ts`. `resolve-farmslot-ports.mjs` is the node leaf; `resolve-farmslot-ports.sh` remains a thin bash-compat wrapper for sourced callers.
16
52
 
17
53
  ## 0.5.0 - 2026-07-05
@@ -54,20 +90,7 @@ Minor release: the adapter-surface architecture plus the day's feature wave (0.3
54
90
  - **Colored grouped help with DEV OVERRIDE banner and SLOT line**; `mmdev-harness` completions; outcome-stating launch summaries with a `[dev]` marker.
55
91
  - **docs/UX-PRINCIPLES.md** — the CLI interaction contract (human surface rules; `--json` envelope purity is exempt and inviolable).
56
92
 
57
- ## Unreleased
58
-
59
- ### Added
60
- - Add `mm-harness provision` for thin mobile Runway artifact provisioning with a shared integrity-checked cache and simulator install path.
61
- - Add `mm-harness provision runway ios --resolve-only` to return Runway run/revision/artifact metadata without touching simulator, cache, download, install, or baseline state.
62
- - **Dist publish path** — `npm run build` compiles `src/` to `dist/`; `npm pack` / global install runs the compiled CLI entry (`dist/mm-harness-cli.js`) while a source checkout without `dist/` keeps the tsx+src dev path. Contracts: `packaging-dist-install`, `packaging-dev-checkout`.
63
-
64
- ### Changed
65
- - **Leaf stderr hints use mm-harness verbs** — Metro/webpack compact-log prefixes, log-tail hints, and speed-ladder copy now point at `mm-harness logs`, `mm-harness doctor`, `mm-harness launch`, and `mm-harness install` instead of retired `recipe *` commands. Contract: `leaf-branding-retired`.
66
-
67
93
  ### Fixed
68
- - `mm-harness provision runway ios` now routes through the same Runway provisioning surface as `install --runway`, so overlay dispatch accepts the compatibility command instead of rejecting it as an unsupported harness action.
69
- - Runway provisioning now accepts farm context flags (`--slot`, `--watcher-port`, `--runtime-dir`), records slot/watcher/runtime-dir baselines correctly, ignores stale provision baselines unless the app is still installed on the simulator, and emits rerunnable `Next:` commands from the actual invocation.
70
- - Runway iOS artifact extraction now handles the live nested zip layout and app bundle names other than `MetaMask.app` while keeping the cached app directory integrity hash/size contract.
71
94
  - **Mobile preflight no longer re-runs `yarn setup` on every launch** — the deps readiness decision escalated an mtime hint to `stale` and ran the FULL `yarn setup` (submodules + inpage bridge + jetify + pods) inside the launch phase on every invocation in orchestrated (farmslot) contexts. `depsCheck` reports `stale` for a no-baseline checkout whenever a manifest is newer than the install markers; the orchestrator's git phase refreshes tracked-file mtimes on every sync while its deps phase leaves `node_modules` untouched, so "manifest newer than markers" is normal and not proof of drift — yet it duplicated the orchestrator's deps work and turned a fast preflight into 15+ minutes (the behavior that also looked like a hang before 0.3.8's streaming). **Presence is now authoritative:** without a recorded baseline an mtime-only `stale` is trusted as `current` (with a one-line stderr warning), so a churned-but-present checkout launches with zero `yarn` in both tiers. Genuine drift is still caught by the recorded-baseline fingerprint. `mm-harness launch` (quick tier / no `--build`) also dropped `preflightMode` before deciding, so the fast contract never took effect; it is threaded through now.
72
95
  - **Fast tier never installs; genuine gaps teach the orchestrator** — in the fast tier (quick launch / orchestrated), deps that genuinely need work (absent markers, an absent required package, a fingerprint drift, or Metro unable to resolve a module) return a teaching block naming the orchestrator deps/prepare phase (plus the standalone `yarn setup:expo` / `launch --build` remedies) instead of an implicit setup — deps are the orchestrator's contract. The standalone/full tier still installs.
73
96
  - **Deps baseline recorded the instant a setup leaf succeeds** — `prepareMobile` records the deps fingerprint baseline immediately after any successful `yarn-setup`, unconditionally (previously only a later `record: true` re-decide did, gated on the original decision being `install` and on `RECIPE_UP_INSTALL_ATTEMPTED`). Genuine-drift detection is fingerprint-based and self-sustaining after one good install.
@@ -153,7 +176,7 @@ Minor release: the adapter-surface architecture plus the day's feature wave (0.3
153
176
  - Core run teaches the correct next step when no account is set.
154
177
  - Resolved CodeQL alerts (insecure temp dir, file-access-to-http).
155
178
 
156
- ## Unreleased
179
+ ## 0.2.0 - 2026-07-03
157
180
 
158
181
  ### Added
159
182
 
@@ -164,12 +187,6 @@ Minor release: the adapter-surface architecture plus the day's feature wave (0.3
164
187
  ### Changed
165
188
 
166
189
  - **Teaching errors are type-enforced**: `usageOut` now requires a `userAction` escape argument, so a teaching error without a reachable next step no longer compiles. The repo-type detection failure teaches BOTH escapes (`cd into a checkout / pass --target <path>` and `--adapter <mobile|extension|core>`) consistently across `doctor`, `launch`, `verify`, and the overlay path.
167
- - **Repository reorganized into `src/`, `adapters/`, `library/` domains**: all TypeScript now lives under `src/` (shared CLI/core plus typed per-adapter logic in `src/adapters/<adapter>/`); everything that executes against or is injected into the app runtime lives under `adapters/` (`adapters/<adapter>/` per-app glue, `adapters/shared/` cross-adapter OS-glue, `adapters/porcelain/` composed entrypoints, `adapters/*/overlay/` the only injected payload); `library/` holds composable vocabulary only. The `runner/` and `orchestration/` trees no longer exist. The surface doctor moved to `scripts/adapter-surface-doctor.mjs` with its index at `adapters/manifest.json`. Placement rules (R1–R11) are documented in `AGENTS.md`. This is a path-only reorganization; runtime behavior is unchanged.
168
-
169
- ## 0.2.0 - 2026-07-03
170
-
171
- ### Changed
172
-
173
190
  - **Docs realigned to the shipped single-bin surface**: `README.md`, `docs/harness-cli.md` (reduced to a pointer at `CLI-SPEC.md`), `docs/recipe-libraries.md`, `docs/extension-runtime-commands.md`, `docs/package-boundaries.md`, and `docs/live-adapter-contract.md` no longer teach the retired `metamask-recipe`/`mm-recipe`/`mme-recipe` names or "planned/stub" daily-loop claims — every command example is `mm-harness`. (`docs/MENTAL-MODEL.md` and `docs/CLI-SPEC.md` retain the old names by design: they are the before→after migration contract.)
174
191
  - **Published package trimmed to runtime needs**: the `files` field drops dev-only helpers (`scripts/check.mjs`, `scripts/quality/`, `scripts/link-local-farmslot.mjs`, `scripts/validate-action-e2e-artifacts.mjs`) and repo-dev docs (`AGENTS.md`, `CLAUDE.md`), keeping `bin`, `runner`, `orchestration`, `library`, the runtime completion scripts, `docs`, `README.md`, and `CHANGELOG.md`. The `files` field now also negates `scripts/README.md` (dev-only) so the published tarball no longer ships it alongside the two runtime completion scripts.
175
192
  - **Comment-hygiene guard extended**: `scripts/quality/comment-hygiene.mjs` now also fails on presentation vocabulary (`verb`/`daily`) in comments and object-property keys (previously only filenames and declarations), enforced with word boundaries so `verbatim`/`verbose` and user-facing help titles like `DAILY LOOP` stay exempt. Source comments and the `verb: 'rebuild'` progress field were reworded/renamed to `command`.
@@ -88,6 +88,7 @@ install_v1_runner_assets() {
88
88
  if [ -n "$METAMASK_RUNNER_PROTOCOL_ROOT" ]; then
89
89
  printf 'export FARMSLOT_ROOT=${FARMSLOT_ROOT:-%s}\n' "$runner_protocol_root_q"
90
90
  fi
91
+ printf 'if [ -n "${MM_HARNESS_BIN:-}" ] && [ "$MM_HARNESS_BIN" != %s ]; then echo "mm-harness overlay: %s: %s (reinstall to repoint: mm-harness install)" >&2; fi\n' "$runner_exec_q" 'delegating to the runner pinned at install time' "$runner_exec_q"
91
92
  printf 'exec %s "$@"\n' "$runner_exec_q"
92
93
  } > "$HARNESS_DIR/runner/bin/mm-harness"
93
94
  chmod +x "$HARNESS_DIR/runner/bin/mm-harness"
@@ -18,7 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import process from 'node:process';
21
- import { recipeHarnessRoot } from '../../src/paths.ts';
21
+ import { recipeHarnessRoot } from '../shared/recipe-harness-root.mjs';
22
22
 
23
23
  function usage() { console.error('Usage: cleanup-extension-harness.mjs [--target <metamask-extension>]'); }
24
24
  let target = process.cwd();
@@ -23,7 +23,7 @@ import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { spawnSync } from 'node:child_process';
25
25
  import process from 'node:process';
26
- import { recipeHarnessRoot } from '../../src/paths.ts';
26
+ import { recipeHarnessRoot } from '../shared/recipe-harness-root.mjs';
27
27
 
28
28
  function usage() {
29
29
  console.error('Usage: inject-extension-harness.mjs [--target <metamask-extension>] [--no-git-exclude]');
@@ -72,6 +72,7 @@ const delegate = [
72
72
  '#!/usr/bin/env bash',
73
73
  'set -euo pipefail',
74
74
  protocolRoot ? `export FARMSLOT_ROOT=\${FARMSLOT_ROOT:-${shellQuote(protocolRoot)}}` : null,
75
+ `if [ -n "\${MM_HARNESS_BIN:-}" ] && [ "\$MM_HARNESS_BIN" != ${shellQuote(path.join(runnerDir, 'bin/mm-harness'))} ]; then echo "mm-harness overlay: delegating to the runner pinned at install time: ${shellQuote(path.join(runnerDir, 'bin/mm-harness'))} (reinstall to repoint: mm-harness install)" >&2; fi`,
75
76
  `exec ${shellQuote(path.join(runnerDir, 'bin/mm-harness'))} "$@"`,
76
77
  ].filter(Boolean).join('\n') + '\n';
77
78
  fs.writeFileSync(path.join(harnessDir, 'runner/bin/mm-harness'), delegate, { mode: 0o755 });
@@ -39,9 +39,13 @@ if (!Number.isInteger(cdpPort) || cdpPort <= 0) {
39
39
  for (const key of ['chrome-bin', 'profile', 'extension-dir', 'chrome-log', 'chrome-pid']) {
40
40
  if (!args[key]) throw new Error(`Missing --${key}`);
41
41
  }
42
- if (!fs.existsSync(args['chrome-bin'])) throw new Error(`Chrome binary not found: ${args['chrome-bin']}`);
43
- if (!fs.existsSync(path.join(args['extension-dir'], 'manifest.json'))) {
44
- throw new Error(`Extension dist manifest not found: ${path.join(args['extension-dir'], 'manifest.json')}`);
42
+ // --stop-only releases the profile before the dist snapshot exists on a first
43
+ // run, so launch-input validation applies only to a real launch.
44
+ if (args['stop-only'] === undefined) {
45
+ if (!fs.existsSync(args['chrome-bin'])) throw new Error(`Chrome binary not found: ${args['chrome-bin']}`);
46
+ if (!fs.existsSync(path.join(args['extension-dir'], 'manifest.json'))) {
47
+ throw new Error(`Extension dist manifest not found: ${path.join(args['extension-dir'], 'manifest.json')}`);
48
+ }
45
49
  }
46
50
 
47
51
  // Isolation guard: a standalone launch must use a per-checkout profile, never a
@@ -72,6 +76,14 @@ if (previousPid !== null && processLoadsProfile(previousPid, args.profile)) owne
72
76
  terminatePids([...ownedPids]);
73
77
  removeProfileSingletonLocks(args.profile);
74
78
 
79
+ // --stop-only: release the profile (owned Chrome terminated, singleton locks
80
+ // cleared) without launching. The prefill step needs the profile's LevelDB
81
+ // unlocked BEFORE it writes; a prior run's Chrome would hold the lock and the
82
+ // write fails with "Database is not open".
83
+ if (args['stop-only'] !== undefined) {
84
+ process.exit(0);
85
+ }
86
+
75
87
  const logFd = fs.openSync(args['chrome-log'], 'a');
76
88
  let child;
77
89
  try {
@@ -88,7 +100,9 @@ try {
88
100
  '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
89
101
  `--disable-extensions-except=${args['extension-dir']}`,
90
102
  `--load-extension=${args['extension-dir']}`,
91
- 'chrome://extensions/',
103
+ // Initial tab: the caller's dapp URL when provided (sidepanel demos pair the
104
+ // extension sidepanel with a dapp in the main tab), else the extensions page.
105
+ args['start-url'] || 'chrome://extensions/',
92
106
  ], {
93
107
  detached: true,
94
108
  env: {
@@ -50,6 +50,7 @@ while [ "$#" -gt 0 ]; do
50
50
  --artifacts-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; ARTIFACTS="$2"; shift 2 ;;
51
51
  --prepare-cmd) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; PREPARE_CMD="$2"; shift 2 ;;
52
52
  --launch-existing-dist) LAUNCH_EXISTING_DIST=true; shift ;;
53
+ --start-url) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; START_URL="$2"; shift 2 ;;
53
54
  --start-watch|--start-test-watch) START_WATCH=true; LAUNCH_EXISTING_DIST=true; shift ;;
54
55
  --dist-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; DIST_DIR="$2"; shift 2 ;;
55
56
  --chrome-user-data-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CHROME_USER_DATA_DIR="$2"; shift 2 ;;
@@ -114,8 +115,15 @@ fi
114
115
 
115
116
  if $LAUNCH_EXISTING_DIST && [ -z "$PREPARE_CMD" ]; then
116
117
  DIST_ABS="$TARGET/$DIST_DIR"
117
- RUNTIME_DIST_ABS="$ARTIFACTS/runtime-dist"
118
- PROFILE_ABS="${CHROME_USER_DATA_DIR:-$ARTIFACTS/chrome-profile}"
118
+ # Stable beside the profile: the reused profile registers the unpacked
119
+ # extension by PATH; a per-run snapshot path gets pruned and the next launch
120
+ # loads nothing ("No EIP-6963 Provider Detected").
121
+ RUNTIME_DIST_ABS="$TARGET/temp/recipe/runtime/runtime-dist"
122
+ # Stable per-checkout profile: successive runs recognize their own Chrome
123
+ # (ownership guard matches on --user-data-dir), take it over cleanly, and the
124
+ # seeded wallet persists across relaunches. A per-run profile made every
125
+ # relaunch "foreign" and let live/ pruning delete profiles still in use.
126
+ PROFILE_ABS="${CHROME_USER_DATA_DIR:-$TARGET/temp/recipe/runtime/chrome-profile}"
119
127
  FIXTURE_STATE_ABS="$ARTIFACTS/fixture-state.json"
120
128
  FIXTURE_VALIDATION_ABS="$ARTIFACTS/logs/fixture-account-parity.json"
121
129
  # Wallet fixture resolution chain + provenance live in seed-fixture.sh.
@@ -233,6 +241,7 @@ NODE
233
241
  if $START_WATCH; then
234
242
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
235
243
  fi
244
+ prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1 --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}")
236
245
  prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --runtime-dist ${quoted_runtime_dist}")
237
246
  # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
238
247
  # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
@@ -249,6 +258,9 @@ NODE
249
258
  prepare_parts+=("bash ${quoted_seed_fixture} prefill --target ${quoted_target} --fixture ${quoted_wallet_fixture} --state ${quoted_fixture_state} --profile ${quoted_profile} --extension-dir ${quoted_runtime_dist} --extension-id-file ${quoted_extension_id_file}")
250
259
  fi
251
260
  chrome_launch_cmd="node ${quoted_chrome_launcher} --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}"
261
+ if [ -n "${START_URL:-}" ]; then
262
+ chrome_launch_cmd="$chrome_launch_cmd --start-url $(printf '%q' "$START_URL")"
263
+ fi
252
264
  prepare_parts+=("$chrome_launch_cmd")
253
265
  prepare_parts+=("for i in {1..60}; do curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null 2>&1 && break; sleep 1; done; curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null")
254
266
  if [ -n "$WALLET_FIXTURE_ABS" ]; then
@@ -98,10 +98,12 @@ resolve_ext_id() {
98
98
  printf '%s\n' "$EXT_ID"
99
99
  return
100
100
  fi
101
- if [ -f "$AGENT_DIR/extension.id" ]; then
102
- tr -d '[:space:]' < "$AGENT_DIR/extension.id"
103
- return
104
- fi
101
+ for idf in "$AGENT_DIR/extension.id" "$REPO/temp/recipe/runtime/extension.id"; do
102
+ if [ -f "$idf" ]; then
103
+ tr -d '[:space:]' < "$idf"
104
+ return
105
+ fi
106
+ done
105
107
  json_list | python3 -c "import json,re,sys; d=json.load(sys.stdin); ids=[]; [ids.extend(re.findall(r'^chrome-extension://([^/]+)/', t.get('url',''))) for t in d]; print(ids[0] if ids else '')"
106
108
  }
107
109
 
@@ -239,15 +241,34 @@ const { chromium } = require('playwright');
239
241
  document.getElementById('__recipe_open_sidepanel__')?.remove();
240
242
  });
241
243
 
242
- if (createdPage) {
244
+ // The wallet now lives in the sidepanel: leftover extension home tabs would
245
+ // show it twice and steal the foreground from the dapp tab. Close every
246
+ // non-sidepanel extension page (not just one this helper created), then
247
+ // bring the first http(s) tab back to front.
248
+ for (const candidate of context.pages()) {
249
+ if (
250
+ candidate.url().startsWith(`chrome-extension://${extId}/`) &&
251
+ !candidate.url().includes('/sidepanel.html')
252
+ ) {
253
+ try {
254
+ await candidate.close();
255
+ } catch (error) {
256
+ console.warn(
257
+ `[sidepanel] extension page close failed after successful open: ${
258
+ error && error.message ? error.message : error
259
+ }`,
260
+ );
261
+ }
262
+ }
263
+ }
264
+ const dappPage = context
265
+ .pages()
266
+ .find((candidate) => candidate.url().startsWith('http://') || candidate.url().startsWith('https://'));
267
+ if (dappPage) {
243
268
  try {
244
- await page.close();
245
- } catch (error) {
246
- console.warn(
247
- `[sidepanel] helper page close failed after successful open: ${
248
- error && error.message ? error.message : error
249
- }`,
250
- );
269
+ await dappPage.bringToFront();
270
+ } catch {
271
+ // focus is cosmetic; the panel is already open
251
272
  }
252
273
  }
253
274
  await browser.close();
@@ -668,8 +668,8 @@ async function detectExtension(context, extensionDir, extensionIdFile) {
668
668
  const page = await context.newPage();
669
669
  try {
670
670
  await page.goto(`chrome-extension://${candidate.id}/home.html`, {
671
- waitUntil: 'load',
672
- timeout: 10000,
671
+ waitUntil: 'domcontentloaded',
672
+ timeout: 30000,
673
673
  });
674
674
  if (page.url().startsWith('chrome-error://')) {
675
675
  throw new Error('candidate resolved to chrome-error page');
@@ -726,34 +726,48 @@ async function waitForWalletScreen(page) {
726
726
  return { state: 'unknown', selector: null };
727
727
  }
728
728
 
729
+ async function attemptUnlock(page, password) {
730
+ // Selector ladder: current testids first, then the generic selectors the CDP
731
+ // unlock action uses (proven against the same build). A freshly seeded vault
732
+ // can also still be initializing, so a single attempt is not conclusive —
733
+ // the caller retries.
734
+ const filled = await page.evaluate((pw) => {
735
+ const input =
736
+ document.querySelector('[data-testid="unlock-password"]') ??
737
+ document.querySelector('input[type="password"]');
738
+ if (!input) return false;
739
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
740
+ setter.call(input, pw);
741
+ input.dispatchEvent(new Event('input', { bubbles: true }));
742
+ input.dispatchEvent(new Event('change', { bubbles: true }));
743
+ return true;
744
+ }, password);
745
+ if (!filled) return false;
746
+ return page.evaluate(() => {
747
+ const button =
748
+ document.querySelector('[data-testid="unlock-submit"]') ??
749
+ document.querySelector('button[type="submit"]') ??
750
+ Array.from(document.querySelectorAll('button')).find((candidate) => /unlock/iu.test(candidate.innerText || candidate.textContent || ''));
751
+ if (!button) return false;
752
+ button.click();
753
+ return true;
754
+ });
755
+ }
756
+
729
757
  async function unlockIfNeeded(page, password) {
730
758
  let state = await waitForWalletScreen(page);
731
- if (state.state === 'locked') {
732
- await page.fill('[data-testid="unlock-password"]', password);
733
- try {
734
- await page.locator('[data-testid="unlock-submit"]').first().click({ timeout: 15000 });
735
- } catch (error) {
736
- const message = error && error.message ? error.message : String(error);
737
- if (!message.includes('Timeout')) throw error;
738
- const clicked = await page.evaluate(() => {
739
- const button = document.querySelector('[data-testid="unlock-submit"]');
740
- if (!button) return false;
741
- button.click();
742
- return true;
743
- });
744
- if (!clicked) throw new Error(`Unlock submit timed out and DOM fallback could not find the button: ${message}`);
745
- }
746
- const deadline = Date.now() + 45000;
747
- while (Date.now() < deadline) {
748
- state = await waitForWalletScreen(page);
749
- if (state.state === 'unlocked') {
750
- break;
751
- }
759
+ const deadline = Date.now() + 90000;
760
+ while (state.state !== 'unlocked' && state.state !== 'onboarding' && Date.now() < deadline) {
761
+ if (state.state === 'locked') {
762
+ await attemptUnlock(page, password);
763
+ await page.waitForTimeout(1500);
764
+ } else {
752
765
  await page.waitForTimeout(750);
753
766
  }
767
+ state = await waitForWalletScreen(page);
754
768
  }
755
769
  if (state.state !== 'unlocked') {
756
- throw new Error(`Wallet did not reach unlocked home screen after fixture seeding (state=${state.state})`);
770
+ throw new Error(`Wallet did not reach unlocked home screen after fixture seeding (state=${state.state}). Next: mm-harness call ensure_unlocked # then relaunch`);
757
771
  }
758
772
  return state;
759
773
  }
@@ -322,6 +322,14 @@
322
322
  "inputs": "ESM import from resolve-farmslot-ports.mjs or resolve-farmslot-ports.ts",
323
323
  "outputs": "resolveFarmslotPortsByRepo and related functions"
324
324
  },
325
+ {
326
+ "id": "lib/recipe-harness-root",
327
+ "entry": "adapters/shared/recipe-harness-root.mjs",
328
+ "kind": "lib",
329
+ "purpose": "Resolve RECIPE_HARNESS_ROOT for overlay node leaves without importing src/paths.ts.",
330
+ "inputs": "ESM import; env RECIPE_HARNESS_ROOT optional override",
331
+ "outputs": "recipeHarnessRoot() relative path string"
332
+ },
325
333
  {
326
334
  "id": "lib/resolve-farmslot-ports-sh",
327
335
  "entry": "adapters/shared/resolve-farmslot-ports.sh",
@@ -330,6 +338,11 @@
330
338
  "inputs": "source + resolve_* / apply_resolved_* function calls",
331
339
  "outputs": "KEY=VALUE lines on stdout; env vars via apply_resolved_*"
332
340
  },
341
+ {
342
+ "id": "lib/reap-checkout-metros",
343
+ "entry": "adapters/shared/reap-checkout-metros.sh",
344
+ "kind": "lib"
345
+ },
333
346
  {
334
347
  "id": "lib/sync-wallet-fixture",
335
348
  "entry": "adapters/shared/sync-wallet-fixture.sh",
@@ -187,6 +187,7 @@ install_v1_runner_assets() {
187
187
  if [ -n "$METAMASK_RUNNER_PROTOCOL_ROOT" ]; then
188
188
  printf 'export FARMSLOT_ROOT=${FARMSLOT_ROOT:-%s}\n' "$runner_protocol_root_q"
189
189
  fi
190
+ printf 'if [ -n "${MM_HARNESS_BIN:-}" ] && [ "$MM_HARNESS_BIN" != %s ]; then echo "mm-harness overlay: %s: %s (reinstall to repoint: mm-harness install)" >&2; fi\n' "$runner_exec_q" 'delegating to the runner pinned at install time' "$runner_exec_q"
190
191
  printf 'exec %s "$@"\n' "$runner_exec_q"
191
192
  } > "$HARNESS_DIR/runner/bin/mm-harness"
192
193
  chmod +x "$HARNESS_DIR/runner/bin/mm-harness"
@@ -54,8 +54,23 @@ if [ -n "$pids" ]; then
54
54
  else
55
55
  printf 'Metro not running on port %s — nothing to stop\n' "$PORT" >&2
56
56
  fi
57
+
58
+ # The stop decision is made; everything below is best-effort cleanup (pid file,
59
+ # leaked-bundler sweep, tmux window). None of it may fail the command — the
60
+ # idempotent-stop contract is already satisfied. Drop set -e for the tail so an
61
+ # incidental non-zero (platform-dependent) can never turn success into exit 1.
62
+ set +e
57
63
  rm -f "$PID_FILE"
58
64
 
65
+ # Port-scoped stop above misses bundlers a prior launch left on a DIFFERENT port
66
+ # (port drift / missing pid file). Sweep every Metro bound to this checkout so
67
+ # stop fully cleans up — the leak that stacked bundlers across relaunches.
68
+ # Guarded: the reap lib may be absent in a minimal install; skip rather than abort.
69
+ if [ -f "$SCRIPT_DIR/../shared/reap-checkout-metros.sh" ]; then
70
+ . "$SCRIPT_DIR/../shared/reap-checkout-metros.sh"
71
+ reap_checkout_metros "$TARGET" || true
72
+ fi
73
+
59
74
  # Close the read-only log-tail window start-metro opened, if it is still there.
60
75
  if [ -f "$TMUX_FILE" ] && command -v tmux >/dev/null 2>&1; then
61
76
  win="$(cat "$TMUX_FILE" 2>/dev/null || true)"
@@ -64,3 +79,8 @@ if [ -f "$TMUX_FILE" ] && command -v tmux >/dev/null 2>&1; then
64
79
  fi
65
80
  fi
66
81
  rm -f "$TMUX_FILE"
82
+
83
+ # Reaching here = cleanup done (nothing needed stopping, or it stopped and
84
+ # windows/pids were cleared). The idempotent-stop contract is success; do not
85
+ # leak an incidental non-zero from the last command on any platform.
86
+ exit 0
@@ -26,7 +26,16 @@ recipe_color() {
26
26
  }
27
27
 
28
28
  recipe_progress_script() {
29
- printf '%s' "${RECIPE_PROGRESS_SCRIPT:-${RECIPE_RUNNER_ROOT:?}/src/progress.ts}"
29
+ if [ -n "${RECIPE_PROGRESS_SCRIPT:-}" ]; then
30
+ printf '%s' "$RECIPE_PROGRESS_SCRIPT"
31
+ return 0
32
+ fi
33
+ local root="${RECIPE_RUNNER_ROOT:?}"
34
+ if [ -f "$root/dist/progress.js" ]; then
35
+ printf '%s' "$root/dist/progress.js"
36
+ else
37
+ printf '%s' "$root/src/progress.ts"
38
+ fi
30
39
  }
31
40
 
32
41
  recipe_log_tui_script() {
@@ -34,7 +43,16 @@ recipe_log_tui_script() {
34
43
  }
35
44
 
36
45
  recipe_version_script() {
37
- printf '%s' "${RECIPE_VERSION_SCRIPT:-${RECIPE_RUNNER_ROOT:?}/src/cli-version.ts}"
46
+ if [ -n "${RECIPE_VERSION_SCRIPT:-}" ]; then
47
+ printf '%s' "$RECIPE_VERSION_SCRIPT"
48
+ return 0
49
+ fi
50
+ local root="${RECIPE_RUNNER_ROOT:?}"
51
+ if [ -f "$root/dist/cli-version.js" ]; then
52
+ printf '%s' "$root/dist/cli-version.js"
53
+ else
54
+ printf '%s' "$root/src/cli-version.ts"
55
+ fi
38
56
  }
39
57
 
40
58
  recipe_log_ui_mode() {
@@ -21,7 +21,32 @@ deps_ready() {
21
21
  [ -f "$RUNNER_DIR/node_modules/@farmslot/recipe-harness/package.json" ]
22
22
  }
23
23
 
24
+ # Library actions import @deeeed/metamask-harness by package name; a source checkout
25
+ # is not npm-installed into its own node_modules, so symlink self for resolution.
26
+ ensure_self_package_link() {
27
+ local link="$RUNNER_DIR/node_modules/@deeeed/metamask-harness"
28
+ /bin/mkdir -p "$RUNNER_DIR/node_modules/@deeeed"
29
+ if [ ! -e "$link" ]; then
30
+ ln -sf "$RUNNER_DIR" "$link"
31
+ fi
32
+ }
33
+
34
+ ensure_dev_package_condition() {
35
+ if [ -f "$RUNNER_DIR/dist/index.js" ]; then
36
+ return 0
37
+ fi
38
+ if [ ! -f "$RUNNER_DIR/src/index.ts" ]; then
39
+ return 0
40
+ fi
41
+ case " ${NODE_OPTIONS:-} " in
42
+ *" --conditions=development "*) return 0 ;;
43
+ esac
44
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--conditions=development"
45
+ }
46
+
24
47
  if deps_ready; then
48
+ ensure_self_package_link
49
+ ensure_dev_package_condition
25
50
  exit 0
26
51
  fi
27
52
 
@@ -39,6 +64,8 @@ if [ -n "$PROTOCOL_ROOT" ] && [ -f "$RUNNER_DIR/scripts/link-local-farmslot.mjs"
39
64
  FARMSLOT_ROOT="$(cd "$PROTOCOL_ROOT" && pwd -P)" \
40
65
  node "$RUNNER_DIR/scripts/link-local-farmslot.mjs" >&2 || true
41
66
  if deps_ready; then
67
+ ensure_self_package_link
68
+ ensure_dev_package_condition
42
69
  exit 0
43
70
  fi
44
71
  fi
@@ -54,3 +81,6 @@ if ! deps_ready; then
54
81
  echo "mm-harness: runner dependencies still missing after npm install: $RUNNER_DIR" >&2
55
82
  exit 1
56
83
  fi
84
+
85
+ ensure_self_package_link
86
+ ensure_dev_package_condition
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # reap-checkout-metros.sh — kill EVERY Metro/Expo bundler bound to a given
3
+ # checkout, regardless of port. Port-scoped stop leaks a bundler whenever a
4
+ # launch drifts ports or fails to record its pid; this sweeps them by checkout
5
+ # path so `stop` fully cleans up and `launch` starts from zero.
6
+ #
7
+ # reap_checkout_metros <checkout-abs-path> [keep-pid]
8
+ # keep-pid (optional): a pid to spare (the bundler just started/handled).
9
+ reap_checkout_metros() {
10
+ local repo="$1" keep="${2:-}" reaped=""
11
+ [ -n "$repo" ] || return 0
12
+ # Match the bundler's argv: `expo start` / metro under this checkout. ps argv
13
+ # embeds the cwd-launched command; expo/metro run with the repo on the path.
14
+ local pid args
15
+ while IFS= read -r pid; do
16
+ [ -n "$pid" ] || continue
17
+ [ "$pid" = "$keep" ] && continue
18
+ args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
19
+ # Anchor to a path boundary so /repos/mm-1 does not match /repos/mm-10.
20
+ case "$args" in
21
+ *"$repo/"*|*"$repo "*) : ;; # argv references this checkout
22
+ *) continue ;;
23
+ esac
24
+ case "$args" in
25
+ *"expo start"*|*"metro"*|*"react-native/cli"*|*"@react-native-community/cli"*)
26
+ kill "$pid" 2>/dev/null && reaped="$reaped $pid" ;;
27
+ esac
28
+ done <<LIST
29
+ $(pgrep -f "expo start|metro" 2>/dev/null)
30
+ LIST
31
+ if [ -n "$reaped" ]; then
32
+ printf 'Reaped leaked Metro bundler(s) for this checkout:%s\n' "$reaped" >&2
33
+ fi
34
+ return 0
35
+ }
36
+
37
+ # detect_checkout_metros <checkout-abs-path> — print pids of every metro/expo
38
+ # bundler bound to the checkout, one per line, WITHOUT killing. Powers doctor's
39
+ # orphan visibility so leaked bundlers are surfaced without a launch.
40
+ detect_checkout_metros() {
41
+ local repo="$1" pid args
42
+ [ -n "$repo" ] || return 0
43
+ while IFS= read -r pid; do
44
+ [ -n "$pid" ] || continue
45
+ args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
46
+ case "$args" in *"$repo/"*|*"$repo "*) : ;; *) continue ;; esac
47
+ case "$args" in
48
+ *"expo start"*|*"metro"*|*"react-native/cli"*|*"@react-native-community/cli"*) printf '%s\n' "$pid" ;;
49
+ esac
50
+ done <<LIST
51
+ $(pgrep -f "expo start|metro" 2>/dev/null)
52
+ LIST
53
+ }
@@ -0,0 +1,23 @@
1
+ // recipe-harness-root.mjs — overlay harness root path for node leaves (no src/ import).
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
7
+
8
+ function validateRelative(name, value) {
9
+ if (!value || path.isAbsolute(value)) {
10
+ throw new Error(`${name} must be a non-empty relative path: ${value}`);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ function readDefaults() {
16
+ const defaultsPath = path.join(runnerDir, 'adapters/shared/path-defaults.json');
17
+ const parsed = JSON.parse(fs.readFileSync(defaultsPath, 'utf8'));
18
+ return validateRelative('recipeHarnessRoot', parsed.recipeHarnessRoot || 'temp/recipe/harness');
19
+ }
20
+
21
+ export function recipeHarnessRoot() {
22
+ return validateRelative('RECIPE_HARNESS_ROOT', process.env.RECIPE_HARNESS_ROOT || readDefaults());
23
+ }