@deeeed/metamask-harness 0.4.0 → 0.5.1

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 (74) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/adapters/extension/inject.mjs +2 -0
  3. package/adapters/extension/refresh-build.sh +2 -2
  4. package/adapters/extension/start-watch.sh +2 -2
  5. package/adapters/manifest.json +19 -3
  6. package/adapters/mobile/start-metro.sh +1 -1
  7. package/adapters/mobile/verify.sh +1 -1
  8. package/adapters/shared/activate-repo-node.sh +1 -1
  9. package/adapters/shared/cli-ux.sh +24 -23
  10. package/adapters/shared/log-tui.mjs +4 -4
  11. package/adapters/shared/open-debug.mjs +1 -1
  12. package/adapters/shared/resolve-farmslot-ports-core.mjs +196 -0
  13. package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
  14. package/adapters/shared/resolve-farmslot-ports.sh +19 -126
  15. package/dist/adapters/core/surface.js +53 -0
  16. package/dist/adapters/extension/ensure-ready.js +109 -0
  17. package/dist/adapters/extension/extension-id.js +62 -0
  18. package/dist/adapters/extension/runtime-decision.js +305 -0
  19. package/dist/adapters/extension/runtime.js +324 -0
  20. package/dist/adapters/extension/surface.js +69 -0
  21. package/dist/adapters/mobile/deps-markers.js +22 -0
  22. package/dist/adapters/mobile/prepare.js +146 -0
  23. package/dist/adapters/mobile/provision.js +465 -0
  24. package/dist/adapters/mobile/runtime-decision.js +317 -0
  25. package/dist/adapters/mobile/surface.js +54 -0
  26. package/dist/adapters/resolve-farmslot-ports.js +22 -0
  27. package/dist/adapters/slot-ports.js +140 -0
  28. package/dist/adapters/surface.js +14 -0
  29. package/dist/adapters.js +485 -0
  30. package/dist/cli-color.js +79 -0
  31. package/dist/cli-commands.js +224 -0
  32. package/dist/cli-version.js +111 -0
  33. package/dist/cli.js +1571 -0
  34. package/dist/commands/debug.js +56 -0
  35. package/dist/commands/fixtures.js +153 -0
  36. package/dist/commands/launch.js +325 -0
  37. package/dist/commands/logs.js +73 -0
  38. package/dist/commands/shared.js +157 -0
  39. package/dist/commands/update.js +243 -0
  40. package/dist/completions-cache.js +53 -0
  41. package/dist/doctor.js +169 -0
  42. package/dist/harness.js +627 -0
  43. package/dist/heal-bounds.js +120 -0
  44. package/dist/index.js +25 -0
  45. package/dist/leaf-invoke.js +19 -0
  46. package/dist/live-adapter-contract.js +240 -0
  47. package/dist/manifest.js +37 -0
  48. package/dist/mm-harness-cli.js +521 -0
  49. package/dist/paths.js +179 -0
  50. package/dist/progress.js +94 -0
  51. package/dist/recording-target.js +133 -0
  52. package/dist/run-recording.js +271 -0
  53. package/dist/runner.js +88 -0
  54. package/dist/types.js +0 -0
  55. package/docs/CLI-SPEC.md +26 -3
  56. package/library/actions/core/perps/_controller.mjs +1 -1
  57. package/library/actions/extension/platform/cdp.mjs +1 -1
  58. package/library/actions/extension/wallet/ensure_unlocked.mjs +1 -1
  59. package/library/actions/harness-exports.mjs +27 -0
  60. package/library/actions/mobile/wallet/ensure_unlocked.mjs +1 -1
  61. package/library/actions/mobile/wallet/setup.mjs +1 -1
  62. package/package.json +5 -1
  63. package/src/adapters/core/surface.ts +15 -0
  64. package/src/adapters/extension/surface.ts +20 -3
  65. package/src/adapters/mobile/provision.ts +594 -0
  66. package/src/adapters/mobile/runtime-decision.ts +8 -1
  67. package/src/adapters/mobile/surface.ts +16 -4
  68. package/src/adapters/resolve-farmslot-ports.ts +13 -0
  69. package/src/adapters/slot-ports.ts +18 -25
  70. package/src/adapters/surface.ts +35 -0
  71. package/src/cli-commands.ts +1 -1
  72. package/src/cli.ts +149 -6
  73. package/src/harness.ts +140 -3
  74. package/src/mm-harness-cli.ts +52 -5
@@ -1,4 +1,5 @@
1
- # resolve-farmslot-ports.sh — resolve CDP + watcher ports for an extension checkout
1
+ #!/usr/bin/env bash
2
+ # resolve-farmslot-ports.sh — bash-compat wrapper over resolve-farmslot-ports.mjs
2
3
  #
3
4
  # Farmslot pool JSON is authoritative when a slot repo matches; otherwise fall back
4
5
  # to the local-extension-N formula (6660+N / 9010+N).
@@ -8,85 +9,15 @@
8
9
  # resolve_extension_runtime_ports /path/to/metamask-extension-N
9
10
  # -> prints CDP_PORT=... WATCHER_PORT=... SLOT_ID=... lines
10
11
 
11
- resolve_farmslot_ports_by_repo() {
12
- local repo="$1"
13
- local farmslot_root="${FARMSLOT_ROOT:-}"
14
- [ -n "$repo" ] || return 1
15
- repo="$(cd "$repo" && pwd -P)"
16
-
17
- local root candidate match
18
- for root in "$farmslot_root" "$HOME/dev/farmslot"; do
19
- [ -n "$root" ] && [ -d "$root/pool" ] || continue
20
- match="$(POOL_ROOT="$root" REPO_TARGET="$repo" python3 - <<'PY'
21
- import glob, json, os, re, sys
22
-
23
- pool_root = os.environ["POOL_ROOT"]
24
- repo = os.path.realpath(os.environ["REPO_TARGET"])
25
- base = os.path.basename(repo)
26
- slot_suffix = None
27
- m = re.search(r"-(\d+)$", base)
28
- if m:
29
- slot_suffix = m.group(1)
30
-
31
- def score_slot(slot):
32
- resources = slot.get("resources") or {}
33
- cdp = (resources.get("browser") or {}).get("cdp_port")
34
- port = (resources.get("dev-server") or {}).get("port")
35
- session = slot.get("session") or ""
36
- slot_id = slot.get("id") or ""
37
- score = 0
38
- if cdp is not None:
39
- score += 100
40
- if port is not None:
41
- score += 10
42
- if slot_suffix:
43
- if session == f"mme-{slot_suffix}":
44
- score += 50
45
- if slot_id.endswith(f"mme-{slot_suffix}") or f"-mme-{slot_suffix}" in slot_id:
46
- score += 40
47
- if "demo" not in slot_id.lower():
48
- score += 5
49
- return score, cdp, port, slot_id
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ _RESOLVE_PORTS_MJS="$SCRIPT_DIR/resolve-farmslot-ports.mjs"
50
14
 
51
- best = None
52
- for path in sorted(glob.glob(os.path.join(pool_root, "pool", "*.json"))):
53
- if ".bak." in os.path.basename(path):
54
- continue
55
- try:
56
- data = json.load(open(path, encoding="utf-8"))
57
- except Exception:
58
- continue
59
- for slot in data.get("slots") or []:
60
- slot_repo = slot.get("repo") or ""
61
- if not slot_repo:
62
- continue
63
- try:
64
- if os.path.realpath(slot_repo) != repo:
65
- continue
66
- except Exception:
67
- continue
68
- scored = score_slot(slot)
69
- if best is None or scored[0] > best[0]:
70
- best = scored
71
-
72
- if best is None:
73
- sys.exit(1)
15
+ _resolve_ports_cli() {
16
+ node "$_RESOLVE_PORTS_MJS" "$1" "${2:-.}"
17
+ }
74
18
 
75
- _, cdp, port, slot_id = best
76
- if cdp is not None:
77
- print(f"CDP_PORT={cdp}")
78
- if port is not None:
79
- print(f"WATCHER_PORT={port}")
80
- if slot_id:
81
- print(f"SLOT_ID={slot_id}")
82
- sys.exit(0)
83
- PY
84
- )" || continue
85
- [ -n "$match" ] || continue
86
- printf '%s\n' "$match"
87
- return 0
88
- done
89
- return 1
19
+ resolve_farmslot_ports_by_repo() {
20
+ _resolve_ports_cli resolve_farmslot_ports_by_repo "$1"
90
21
  }
91
22
 
92
23
  infer_extension_slot_suffix() {
@@ -100,27 +31,18 @@ infer_extension_slot_suffix() {
100
31
  }
101
32
 
102
33
  resolve_default_extension_ports() {
103
- local repo="$1" n
104
- n="$(infer_extension_slot_suffix "$repo" || true)"
105
- [ -n "$n" ] || return 1
106
- printf 'CDP_PORT=%s\n' "$((6660 + n))"
107
- printf 'WATCHER_PORT=%s\n' "$((9010 + n))"
108
- printf 'SLOT_ID=local-extension-%s\n' "$n"
34
+ _resolve_ports_cli resolve_default_extension_ports "$1"
109
35
  }
110
36
 
111
37
  resolve_extension_runtime_ports() {
112
- local repo="${1:-.}"
113
- if resolve_farmslot_ports_by_repo "$repo"; then
114
- return 0
115
- fi
116
- resolve_default_extension_ports "$repo"
38
+ _resolve_ports_cli resolve_extension_runtime_ports "${1:-.}"
117
39
  }
118
40
 
119
41
  apply_resolved_extension_ports() {
120
42
  local repo="${1:-.}" line key val from_pool=false resolved=""
121
- if resolved="$(resolve_farmslot_ports_by_repo "$repo" 2>/dev/null)"; then
43
+ if resolved="$(_resolve_ports_cli resolve_farmslot_ports_by_repo "$repo" 2>/dev/null)"; then
122
44
  from_pool=true
123
- elif resolved="$(resolve_default_extension_ports "$repo" 2>/dev/null)"; then
45
+ elif resolved="$(_resolve_ports_cli resolve_default_extension_ports "$repo" 2>/dev/null)"; then
124
46
  from_pool=false
125
47
  else
126
48
  return 0
@@ -143,52 +65,23 @@ apply_resolved_extension_ports() {
143
65
  done <<< "$resolved"
144
66
  }
145
67
 
146
- # Mobile slot defaults: derive Metro port + iOS simulator name from directory suffix.
147
- # Port formula: 8060 + N (slot 1 → 8061). iOS simulator: mm-N.
148
- # Mirrors the extension convention (6660+N / 9010+N) for multi-slot mobile farms.
149
68
  resolve_mobile_slot_defaults() {
150
- local repo="$1" n
151
- n="$(infer_extension_slot_suffix "$repo" || true)"
152
- [ -n "$n" ] || return 1
153
- printf 'WATCHER_PORT=%s\n' "$((8060 + n))"
154
- printf 'IOS_SIMULATOR=mm-%s\n' "$n"
155
- printf 'SLOT_ID=local-mobile-%s\n' "$n"
69
+ _resolve_ports_cli resolve_mobile_slot_defaults "$1"
156
70
  }
157
71
 
158
- # Slot context the orchestrator's prepare wrote INTO the checkout — the
159
- # authoritative device/port mapping for this repo (survives pool renames that
160
- # break any naming formula). Highest-priority detection source.
161
72
  resolve_mobile_runtime_context() {
162
- local repo="$1" ctx
163
- ctx="$repo/${RECIPE_RUNTIME_DIR:-temp/recipe/runtime}/agentic-runtime.json"
164
- [ -f "$ctx" ] || return 1
165
- node -e '
166
- try {
167
- const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
168
- if (!c.simulator && !c.metroPort) process.exit(1);
169
- if (c.metroPort) console.log(`WATCHER_PORT=${c.metroPort}`);
170
- if (c.simulator) console.log(`IOS_SIMULATOR=${c.simulator}`);
171
- if (c.slotId) console.log(`SLOT_ID=${c.slotId}`);
172
- } catch { process.exit(1); }
173
- ' "$ctx" 2>/dev/null
73
+ _resolve_ports_cli resolve_mobile_runtime_context "$1"
174
74
  }
175
75
 
176
76
  resolve_mobile_runtime_ports() {
177
- local repo="${1:-.}"
178
- if resolve_mobile_runtime_context "$repo"; then
179
- return 0
180
- fi
181
- if resolve_farmslot_ports_by_repo "$repo"; then
182
- return 0
183
- fi
184
- resolve_mobile_slot_defaults "$repo"
77
+ _resolve_ports_cli resolve_mobile_runtime_ports "${1:-.}"
185
78
  }
186
79
 
187
80
  apply_resolved_mobile_ports() {
188
81
  local repo="${1:-.}" line key val from_pool=false resolved=""
189
- if resolved="$(resolve_farmslot_ports_by_repo "$repo" 2>/dev/null)"; then
82
+ if resolved="$(_resolve_ports_cli resolve_farmslot_ports_by_repo "$repo" 2>/dev/null)"; then
190
83
  from_pool=true
191
- elif resolved="$(resolve_mobile_slot_defaults "$repo" 2>/dev/null)"; then
84
+ elif resolved="$(_resolve_ports_cli resolve_mobile_slot_defaults "$repo" 2>/dev/null)"; then
192
85
  from_pool=false
193
86
  else
194
87
  return 0
@@ -209,4 +102,4 @@ apply_resolved_mobile_ports() {
209
102
  ;;
210
103
  esac
211
104
  done <<< "$resolved"
212
- }
105
+ }
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+ import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
+ const coreSurface = {
4
+ adapter: "core",
5
+ headless: true,
6
+ resolveSlotPorts() {
7
+ },
8
+ async runtimeStatus(target) {
9
+ const deps = depsCheck(path.resolve(target));
10
+ const ready = deps.status === "current";
11
+ return {
12
+ decision: ready ? "ready" : "install",
13
+ reasonCode: ready ? "deps-present" : `deps-${deps.status}`,
14
+ reasons: [
15
+ ready ? "Core is headless; dependencies are installed. Run recipes with mm-harness run." : "Core is headless; dependencies are not fully installed."
16
+ ],
17
+ deps: deps.status
18
+ };
19
+ },
20
+ runwayProvision: {
21
+ async run(target, options) {
22
+ return {
23
+ schemaVersion: 1,
24
+ command: "provision",
25
+ adapter: "core",
26
+ target: path.resolve(target),
27
+ status: "fail",
28
+ exitCode: 2,
29
+ error: { code: "UNSUPPORTED_ADAPTER", message: "core is headless and has no mobile simulator to provision.", userAction: options.rerunCommand || "mm-harness run <recipe> --adapter core" }
30
+ };
31
+ }
32
+ },
33
+ devServer: {
34
+ describe: () => "no dev server (headless)",
35
+ stop() {
36
+ return {
37
+ kind: "headless",
38
+ message: "core is headless \u2014 no dev server runs for a core checkout",
39
+ userAction: "use mm-harness verify (readiness) or mm-harness run (execute a recipe)"
40
+ };
41
+ }
42
+ },
43
+ logSources() {
44
+ return [];
45
+ },
46
+ hints: {
47
+ launch: "mm-harness run <recipe> # run recipes against the headless core",
48
+ relaunch: "mm-harness verify"
49
+ }
50
+ };
51
+ export {
52
+ coreSurface
53
+ };
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+ import { resolveExtensionId } from "./extension-id.js";
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ async function jsonList(port) {
5
+ try {
6
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5e3) });
7
+ if (!res.ok) return [];
8
+ const value = await res.json();
9
+ return Array.isArray(value) ? value : [];
10
+ } catch {
11
+ return [];
12
+ }
13
+ }
14
+ async function closeTab(port, id) {
15
+ try {
16
+ await fetch(`http://127.0.0.1:${port}/json/close/${id}`, { signal: AbortSignal.timeout(4e3) });
17
+ } catch {
18
+ }
19
+ }
20
+ async function activateTab(port, id) {
21
+ try {
22
+ await fetch(`http://127.0.0.1:${port}/json/activate/${id}`, { signal: AbortSignal.timeout(4e3) });
23
+ } catch {
24
+ }
25
+ }
26
+ function isStrayTab(url) {
27
+ if (typeof url !== "string") return false;
28
+ return /^chrome:\/\/newtab\/?$/.test(url) || /^chrome:\/\/new-tab-page\b/.test(url) || url === "about:blank";
29
+ }
30
+ async function openHome(port, extensionId) {
31
+ const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(`chrome-extension://${extensionId}/home.html`)}`;
32
+ try {
33
+ let res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(8e3) });
34
+ if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8e3) });
35
+ return res.ok;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ function homePages(targets, extensionId) {
41
+ const prefix = `chrome-extension://${extensionId}`;
42
+ return targets.filter(
43
+ (t) => t.type === "page" && typeof t.url === "string" && t.url.startsWith(prefix) && t.url.includes("/home.html") && t.id
44
+ );
45
+ }
46
+ async function ensureExtensionReady(target, options) {
47
+ const resolved = path.resolve(target);
48
+ const { cdpPort } = options;
49
+ const base = (extra) => ({
50
+ schemaVersion: 1,
51
+ adapter: "extension",
52
+ target: resolved,
53
+ cdpPort,
54
+ extensionId: null,
55
+ opened: false,
56
+ homeTabs: { before: 0, closed: 0, after: 0 },
57
+ ready: false,
58
+ reasonCode: "unknown",
59
+ health: { status: "unknown", findings: [] },
60
+ ...extra
61
+ });
62
+ const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
63
+ if (!extensionId) return base({ reasonCode: "no-extension-id" });
64
+ const homes = homePages(await jsonList(cdpPort), extensionId);
65
+ const before = homes.length;
66
+ let opened = false;
67
+ let closed = 0;
68
+ if (homes.length > 1) {
69
+ for (const h of homes) {
70
+ await closeTab(cdpPort, String(h.id));
71
+ closed += 1;
72
+ }
73
+ await sleep(500);
74
+ opened = await openHome(cdpPort, extensionId);
75
+ await sleep(1500);
76
+ } else if (homes.length === 0) {
77
+ opened = await openHome(cdpPort, extensionId);
78
+ await sleep(1500);
79
+ }
80
+ const converged = await jsonList(cdpPort);
81
+ const homesNow = homePages(converged, extensionId);
82
+ if (homesNow.length >= 1) {
83
+ for (const t of converged) {
84
+ if (t.type === "page" && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
85
+ }
86
+ if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
87
+ }
88
+ const after = homePages(await jsonList(cdpPort), extensionId).length;
89
+ let health = { status: "unknown", findings: [] };
90
+ try {
91
+ const { checkExtensionRuntimeHealth } = await import("./runtime.js");
92
+ const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
93
+ health = { status: report.status, findings: report.findings };
94
+ } catch (error) {
95
+ health = { status: "FAIL", findings: [error instanceof Error ? error.message : String(error)] };
96
+ }
97
+ const ready = health.status === "PASS" && after === 1;
98
+ return base({
99
+ extensionId,
100
+ opened,
101
+ homeTabs: { before, closed, after },
102
+ ready,
103
+ reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
104
+ health
105
+ });
106
+ }
107
+ export {
108
+ ensureExtensionReady
109
+ };
@@ -0,0 +1,62 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const DIST_MANIFEST = "dist/chrome/manifest.json";
5
+ function extensionIdFromKey(keyBase64) {
6
+ const der = Buffer.from(keyBase64, "base64");
7
+ const hash = crypto.createHash("sha256").update(der).digest();
8
+ let id = "";
9
+ for (let i = 0; i < 16; i += 1) {
10
+ id += String.fromCharCode(97 + (hash[i] >> 4));
11
+ id += String.fromCharCode(97 + (hash[i] & 15));
12
+ }
13
+ return id;
14
+ }
15
+ function idFromDistManifest(target) {
16
+ const manifestPath = path.join(target, DIST_MANIFEST);
17
+ if (!fs.existsSync(manifestPath)) return null;
18
+ try {
19
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
20
+ return typeof manifest.key === "string" && manifest.key.length > 0 ? extensionIdFromKey(manifest.key) : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ async function cdpExtensionIds(cdpPort) {
26
+ try {
27
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/list`, { signal: AbortSignal.timeout(4e3) });
28
+ if (!res.ok) return [];
29
+ const targets = await res.json();
30
+ const ids = /* @__PURE__ */ new Set();
31
+ for (const t of targets) {
32
+ const m = /^chrome-extension:\/\/([^/]+)/u.exec(String(t.url ?? ""));
33
+ if (m) ids.add(m[1]);
34
+ }
35
+ return [...ids];
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+ async function resolveExtensionId(target, options = {}) {
41
+ const resolved = path.resolve(target);
42
+ const fromKey = idFromDistManifest(resolved);
43
+ let cdpIds = null;
44
+ if (options.cdpPort) cdpIds = await cdpExtensionIds(options.cdpPort);
45
+ if (fromKey) {
46
+ return {
47
+ adapter: "extension",
48
+ target: resolved,
49
+ extensionId: fromKey,
50
+ source: "manifest-key",
51
+ verified: cdpIds ? cdpIds.includes(fromKey) : null
52
+ };
53
+ }
54
+ if (cdpIds && cdpIds.length === 1) {
55
+ return { adapter: "extension", target: resolved, extensionId: cdpIds[0], source: "cdp-target", verified: true };
56
+ }
57
+ return { adapter: "extension", target: resolved, extensionId: null, source: "none", verified: cdpIds ? false : null };
58
+ }
59
+ export {
60
+ extensionIdFromKey,
61
+ resolveExtensionId
62
+ };