@deeeed/metamask-harness 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3
4
+
5
+ ### Fixed
6
+ - **Extension `launch` reuses a healthy live runtime instead of always clean-building** — quick `launch` / `launch --sidepanel` on an extension slot now probes for a reusable runtime (webpack watcher LISTENing on `WATCHER_PORT` + CDP `/json/version` reachable on `CDP_PORT` + dist not stale). When all hold it REATTACHES: re-snapshots `dist/chrome` into the loaded runtime-dist and reloads the extension in place over CDP (service worker + pages), re-asserts the side panel, and applies `--url` — against the existing slot Chrome profile, with no watcher stop, webpack cache clear, or Chrome kill/relaunch. Reuse is best-effort: a failed probe (watcher down, CDP unreachable, stale dist) falls through to the clean path, and `--build` is the explicit escape that always clean-builds + relaunches a fresh runtime (no probe) to force-recover a broken slot.
7
+
3
8
  ## 0.6.2 - 2026-07-06
4
9
 
5
10
  ### Added
@@ -101,6 +101,7 @@ copyFile(path.join(runnerDir, 'adapters/extension/snapshot-dist.sh'), path.join(
101
101
  copyFile(path.join(runnerDir, 'adapters/extension/pin-remote-flags.cjs'), path.join(harnessDir, 'scripts/pin-remote-flags.cjs'));
102
102
  copyFile(path.join(runnerDir, 'adapters/extension/seed-fixture.sh'), path.join(harnessDir, 'scripts/seed-fixture.sh'));
103
103
  copyFile(path.join(runnerDir, 'adapters/extension/sidepanel-toggle.sh'), path.join(harnessDir, 'scripts/sidepanel-toggle.sh'));
104
+ copyFile(path.join(runnerDir, 'adapters/extension/reattach.sh'), path.join(harnessDir, 'scripts/reattach.sh'));
104
105
  copyFile(path.join(runnerDir, 'adapters/extension/readiness.mjs'), path.join(harnessDir, 'scripts/readiness.mjs'));
105
106
  copyFile(path.join(runnerDir, 'adapters/extension/wallet-fixture-state.cjs'), path.join(harnessDir, 'scripts/wallet-fixture-state.cjs'));
106
107
  copyFile(path.join(runnerDir, 'adapters/extension/verify.sh'), path.join(harnessDir, 'scripts/verify.sh'));
@@ -138,6 +139,7 @@ const manifest = {
138
139
  refreshBuild: `${harnessRel}/scripts/refresh-build.sh`,
139
140
  reopenBrowser: `${harnessRel}/scripts/reopen-browser.sh`,
140
141
  sidepanelToggle: `${harnessRel}/scripts/sidepanel-toggle.sh`,
142
+ reattach: `${harnessRel}/scripts/reattach.sh`,
141
143
  },
142
144
  patchedFiles: [],
143
145
  backupDir: null,
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env bash
2
+ # reattach.sh — reload a live extension slot in place (no rebuild, no relaunch)
3
+ #
4
+ # Purpose:
5
+ # Quick-reuse leaf for `mm-harness launch` on an already-healthy extension slot:
6
+ # re-snapshots dist/chrome into the loaded runtime-dist so the running Chrome
7
+ # picks up watched code changes, then reloads the extension over CDP (service
8
+ # worker + extension pages) and re-opens the wallet. Optionally navigates a
9
+ # main tab to --start-url. Runs entirely against the EXISTING slot Chrome
10
+ # profile — it never stops the watcher, kills Chrome, or clears the webpack
11
+ # cache (that is the clean path in live.sh).
12
+ #
13
+ # Inputs (flags / env):
14
+ # --target <metamask-extension> (default $PWD; becomes the working dir)
15
+ # --cdp-port <port> (required, numeric) — the slot's CDP endpoint
16
+ # --watcher-port <port> (optional; informational only)
17
+ # --start-url <url> (optional; opened as a main tab beside the wallet)
18
+ # --ext-id <id> (optional; else resolved from the extension.id file or CDP)
19
+ # --dist-dir <rel> (default dist/chrome), --settle-ms <ms> (default 8000)
20
+ #
21
+ # Outputs:
22
+ # Progress on stderr. Exit 0 — reloaded; 1 — repo/reload failed; 2 — bad args
23
+ # / non-numeric port / CDP unreachable.
24
+ #
25
+ # Never touches: product source files; the webpack watcher; the Chrome process;
26
+ # other slots' browsers (single CDP port scope).
27
+ set -euo pipefail
28
+
29
+ TARGET="$PWD"
30
+ CDP_PORT="${CDP_PORT:-}"
31
+ WATCHER_PORT="${WATCHER_PORT:-}"
32
+ START_URL="${EXTENSION_START_URL:-}"
33
+ EXT_ID="${EXT_ID:-}"
34
+ DIST_DIR="dist/chrome"
35
+ SETTLE_MS="8000"
36
+
37
+ require_value() { [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; }
38
+ while [ "$#" -gt 0 ]; do
39
+ case "$1" in
40
+ --target) require_value "$@"; TARGET="$2"; shift 2 ;;
41
+ --cdp-port) require_value "$@"; CDP_PORT="$2"; shift 2 ;;
42
+ --watcher-port) require_value "$@"; WATCHER_PORT="$2"; shift 2 ;;
43
+ --start-url) require_value "$@"; START_URL="$2"; shift 2 ;;
44
+ --ext-id) require_value "$@"; EXT_ID="$2"; shift 2 ;;
45
+ --dist-dir) require_value "$@"; DIST_DIR="$2"; shift 2 ;;
46
+ --settle-ms) require_value "$@"; SETTLE_MS="$2"; shift 2 ;;
47
+ -h|--help)
48
+ echo "Usage: reattach.sh --target <metamask-extension> --cdp-port <port> [--watcher-port <port>] [--start-url <url>] [--ext-id <id>] [--dist-dir <rel>] [--settle-ms <ms>]"
49
+ exit 0
50
+ ;;
51
+ *) echo "reattach: unknown arg: $1" >&2; exit 2 ;;
52
+ esac
53
+ done
54
+
55
+ if [ -z "$CDP_PORT" ]; then
56
+ echo "reattach: --cdp-port is required" >&2
57
+ echo " Next: mm-harness launch --build (clean build + relaunch a fresh slot)" >&2
58
+ exit 2
59
+ fi
60
+ case "$CDP_PORT" in
61
+ *[!0-9]*) echo "reattach: --cdp-port must be numeric (got: $CDP_PORT)" >&2; exit 2 ;;
62
+ esac
63
+
64
+ if [ ! -f "$TARGET/package.json" ]; then
65
+ echo "reattach: --target is not a checkout (no package.json): $TARGET" >&2
66
+ echo " Next: mm-harness launch --target /path/to/metamask-extension --build" >&2
67
+ exit 1
68
+ fi
69
+ TARGET="$(cd "$TARGET" && pwd)"
70
+
71
+ if ! curl -s -m 3 "http://127.0.0.1:${CDP_PORT}/json/version" >/dev/null 2>&1; then
72
+ echo "reattach: CDP not reachable on port ${CDP_PORT}; the slot browser is not live." >&2
73
+ echo " Next: mm-harness launch --build (clean build + relaunch)" >&2
74
+ exit 2
75
+ fi
76
+
77
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
78
+ DIST_ABS="$TARGET/$DIST_DIR"
79
+ # The running Chrome loads the runtime-dist snapshot (frozen at launch), not the
80
+ # live dist. Re-snapshot so the in-place reload serves the latest watched build.
81
+ RUNTIME_DIST_ABS="$TARGET/temp/recipe/runtime/runtime-dist"
82
+
83
+ if [ -f "$SCRIPT_DIR/snapshot-dist.sh" ] && [ -d "$RUNTIME_DIST_ABS" ]; then
84
+ echo "[reattach] re-snapshotting dist -> runtime-dist for in-place reload" >&2
85
+ bash "$SCRIPT_DIR/snapshot-dist.sh" --dist "$DIST_ABS" --runtime-dist "$RUNTIME_DIST_ABS" >&2 || {
86
+ echo "reattach: dist re-snapshot failed; the loaded extension may be mid-rebuild." >&2
87
+ echo " Next: mm-harness launch --build (clean build + relaunch)" >&2
88
+ exit 1
89
+ }
90
+ fi
91
+
92
+ # Resolve the extension id: explicit flag, then the recorded id file, else CDP.
93
+ if [ -z "$EXT_ID" ]; then
94
+ for idf in "$TARGET/temp/recipe/runtime/extension.id" "$SCRIPT_DIR/extension.id"; do
95
+ if [ -f "$idf" ]; then
96
+ EXT_ID="$(tr -d '[:space:]' < "$idf")"
97
+ [ -n "$EXT_ID" ] && break
98
+ fi
99
+ done
100
+ fi
101
+
102
+ cd "$TARGET"
103
+ echo "[reattach] reloading extension in place over CDP :${CDP_PORT}" >&2
104
+
105
+ CDP_PORT="$CDP_PORT" EXT_ID="$EXT_ID" START_URL="$START_URL" SETTLE_MS="$SETTLE_MS" node <<'NODE'
106
+ let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
107
+
108
+ const port = process.env.CDP_PORT;
109
+ const startUrl = process.env.START_URL || '';
110
+ const settleMs = Number(process.env.SETTLE_MS || '8000') || 8000;
111
+
112
+ function extensionIdFrom(context, provided) {
113
+ if (provided) return provided;
114
+ const urls = [];
115
+ for (const page of context.pages()) urls.push(page.url());
116
+ for (const worker of context.serviceWorkers()) urls.push(worker.url());
117
+ for (const url of urls) {
118
+ const match = /^chrome-extension:\/\/([^/]+)\//u.exec(url);
119
+ if (match) return match[1];
120
+ }
121
+ return '';
122
+ }
123
+
124
+ async function connect() {
125
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
126
+ const context = browser.contexts()[0];
127
+ return { browser, context };
128
+ }
129
+
130
+ (async () => {
131
+ // Phase 1: trigger a full extension reload (service worker + all pages) from an
132
+ // extension page so Chrome re-reads the freshly snapshotted runtime-dist.
133
+ let { browser, context } = await connect();
134
+ const extId = extensionIdFrom(context, process.env.EXT_ID);
135
+ if (!extId) {
136
+ await browser.close();
137
+ console.error('reattach: could not resolve the extension id from the live CDP targets.');
138
+ console.error(' Next: mm-harness launch --build (clean build + relaunch)');
139
+ process.exit(1);
140
+ }
141
+
142
+ let page = context
143
+ .pages()
144
+ .find((candidate) => candidate.url().startsWith(`chrome-extension://${extId}/`));
145
+ if (!page) {
146
+ page = await context.newPage();
147
+ await page.goto(`chrome-extension://${extId}/home.html`, {
148
+ waitUntil: 'domcontentloaded',
149
+ timeout: 15000,
150
+ });
151
+ }
152
+ await page.evaluate(() => chrome.runtime.reload()).catch(() => {
153
+ // The reload tears the page context down; a rejected evaluate is expected.
154
+ });
155
+ await browser.close();
156
+
157
+ // Phase 2: wait for the extension to come back, then re-open the wallet and,
158
+ // when requested, a dapp tab. Chrome itself never restarted, so CDP stays up.
159
+ const deadline = Date.now() + settleMs;
160
+ let back = false;
161
+ while (Date.now() < deadline) {
162
+ try {
163
+ const probe = await connect();
164
+ const id = extensionIdFrom(probe.context, extId);
165
+ await probe.browser.close();
166
+ if (id) { back = true; break; }
167
+ } catch {
168
+ // CDP momentarily unavailable during reload; retry until the deadline.
169
+ }
170
+ await new Promise((resolve) => setTimeout(resolve, 250));
171
+ }
172
+ if (!back) {
173
+ console.error(`reattach: extension did not come back within ${settleMs}ms after reload.`);
174
+ console.error(' Next: mm-harness launch --build (clean build + relaunch)');
175
+ process.exit(1);
176
+ }
177
+
178
+ ({ browser, context } = await connect());
179
+ let home = context
180
+ .pages()
181
+ .find(
182
+ (candidate) =>
183
+ candidate.url().startsWith(`chrome-extension://${extId}/`) &&
184
+ !candidate.url().includes('/sidepanel.html'),
185
+ );
186
+ if (!home) {
187
+ home = await context.newPage();
188
+ await home
189
+ .goto(`chrome-extension://${extId}/home.html`, { waitUntil: 'domcontentloaded', timeout: 15000 })
190
+ .catch(() => { /* wallet home is best-effort; the reload already applied */ });
191
+ }
192
+ if (startUrl) {
193
+ const dapp = context
194
+ .pages()
195
+ .find((candidate) => candidate.url().startsWith('http://') || candidate.url().startsWith('https://'));
196
+ const tab = dapp || (await context.newPage());
197
+ await tab
198
+ .goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 20000 })
199
+ .catch(() => { /* dapp navigation is best-effort */ });
200
+ }
201
+ await browser.close();
202
+ })().catch((error) => {
203
+ console.error(`reattach: ${error && error.message ? error.message : error}`);
204
+ console.error(' Next: mm-harness launch --build (clean build + relaunch)');
205
+ process.exit(1);
206
+ });
207
+ NODE
208
+
209
+ echo "[reattach] extension reloaded in place — no rebuild, no relaunch" >&2
210
+ echo " Next: mm-harness verify (confirm the reloaded runtime)" >&2
@@ -178,6 +178,14 @@
178
178
  "inputs": "<status|open|close|toggle|cycle> --cdp-port --ext-id --agent-dir --settle-ms; env REPO, CDP_PORT, EXT_ID, SETTLE_MS, AGENT_DIR",
179
179
  "outputs": "status/progress on stdout; exit 0/1/2"
180
180
  },
181
+ {
182
+ "id": "extension/reattach",
183
+ "entry": "adapters/extension/reattach.sh",
184
+ "kind": "bash",
185
+ "purpose": "Reload a live extension slot in place: re-snapshot dist into the loaded runtime-dist, then reload the extension (service worker + pages) over CDP — no watcher stop, no Chrome kill.",
186
+ "inputs": "--target --cdp-port --watcher-port --start-url --ext-id --dist-dir --settle-ms; env CDP_PORT, WATCHER_PORT, EXTENSION_START_URL, EXT_ID",
187
+ "outputs": "progress on stderr; exit 0/1/2"
188
+ },
181
189
  {
182
190
  "id": "extension/pin-remote-flags",
183
191
  "entry": "adapters/extension/pin-remote-flags.cjs",
@@ -181,6 +181,9 @@ function distCheck(target) {
181
181
  }
182
182
  return { status: "fresh", distGitId: distShort, head: headShort };
183
183
  }
184
+ function isExtensionDistStale(target) {
185
+ return distCheck(path.resolve(target)).status === "stale";
186
+ }
184
187
  async function cdpCheck(target, cdpPort) {
185
188
  if (!cdpPort) return { status: "skipped" };
186
189
  try {
@@ -301,5 +304,6 @@ function buildAction(target, clean) {
301
304
  }
302
305
  export {
303
306
  decideExtensionReadiness,
307
+ isExtensionDistStale,
304
308
  recordReadinessBaseline
305
309
  };
@@ -1,17 +1,72 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import http from "node:http";
1
3
  import fs from "node:fs";
2
4
  import path from "node:path";
3
5
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
6
+ import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
4
7
  import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
5
8
  import { spawnScriptStreaming } from "../shared.js";
6
9
  async function launchExtension(target, tier, wantWatch) {
7
- if (!wantWatch && tier === "quick") {
10
+ if (wantWatch) {
11
+ const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
12
+ const watchArgs = ["--target", target];
13
+ if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
14
+ console.error(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`);
15
+ return spawnScriptStreaming(startWatchSh, watchArgs, target);
16
+ }
17
+ if (tier === "build") {
8
18
  return extensionRebuild(target);
9
19
  }
10
- const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
11
- const watchArgs = ["--target", target];
12
- if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
13
- console.error(`\u2192 extension ${wantWatch ? "watch" : "build"} \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`);
14
- return spawnScriptStreaming(startWatchSh, watchArgs, target);
20
+ if (await extensionRuntimeReusable(target)) {
21
+ return extensionReattach(target);
22
+ }
23
+ return extensionRebuild(target);
24
+ }
25
+ async function extensionRuntimeReusable(target) {
26
+ const watcherPort = process.env.WATCHER_PORT;
27
+ const cdpPort = process.env.CDP_PORT;
28
+ if (!watcherPort || !cdpPort) return false;
29
+ if (!portHasListener(watcherPort)) return false;
30
+ if (!await cdpVersionReachable(cdpPort)) return false;
31
+ if (isExtensionDistStale(target)) return false;
32
+ return true;
33
+ }
34
+ function portHasListener(port) {
35
+ try {
36
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
37
+ encoding: "utf8",
38
+ stdio: ["ignore", "pipe", "ignore"]
39
+ });
40
+ return out.split(/\s+/u).some((value) => /^\d+$/u.test(value));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ function cdpVersionReachable(port) {
46
+ return new Promise((resolve) => {
47
+ const request = http.get(
48
+ { host: "127.0.0.1", port: Number(port), path: "/json/version", timeout: 2e3 },
49
+ (response) => {
50
+ response.resume();
51
+ const code = response.statusCode ?? 0;
52
+ resolve(code >= 200 && code < 300);
53
+ }
54
+ );
55
+ request.on("timeout", () => {
56
+ request.destroy();
57
+ resolve(false);
58
+ });
59
+ request.on("error", () => resolve(false));
60
+ });
61
+ }
62
+ async function extensionReattach(target) {
63
+ const reattachScript = recipeHarnessPath(target, "extension", "scripts", "reattach.sh");
64
+ const reattachArgs = ["--target", target];
65
+ if (process.env.CDP_PORT) reattachArgs.push("--cdp-port", process.env.CDP_PORT);
66
+ if (process.env.WATCHER_PORT) reattachArgs.push("--watcher-port", process.env.WATCHER_PORT);
67
+ if (process.env.EXTENSION_START_URL) reattachArgs.push("--start-url", process.env.EXTENSION_START_URL);
68
+ console.error(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`);
69
+ return spawnScriptStreaming(reattachScript, reattachArgs, target);
15
70
  }
16
71
  async function extensionRebuild(target) {
17
72
  const runtimeDirRel = recipeRuntimeDir();
@@ -35,6 +90,7 @@ async function extensionRebuild(target) {
35
90
  return result;
36
91
  }
37
92
  export {
93
+ extensionReattach,
38
94
  extensionRebuild,
39
95
  launchExtension
40
96
  };
@@ -230,8 +230,10 @@ Example:
230
230
  Launch the app \u2014 quick relaunch by default; --build for a full native/webpack build.
231
231
  Mobile: target is MANDATORY (ios | android). Extension: no target (--fullscreen default;
232
232
  --sidepanel to override). core is headless (teaching error \u2014 use verify/run).
233
+ Extension quick launch REUSES a healthy live runtime (reload in place, no rebuild);
234
+ --build is the escape hatch that always clean-builds + relaunches a fresh runtime.
233
235
 
234
- --build Full native/webpack build tier (default = quick relaunch)
236
+ --build Full clean build + relaunch (escape hatch; skips extension reuse)
235
237
  --verify Launch then poll CDP/bridge until ready (absorbs the old \`live\`)
236
238
  --runway Post-launch runway check (mobile only; teaching error elsewhere)
237
239
  --watch Persistent webpack watcher then relaunch (extension only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"