@deeeed/metamask-harness 0.3.9 → 0.5.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 (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/ADAPTER-SURFACE.md +119 -0
  42. package/docs/CLI-SPEC.md +26 -3
  43. package/docs/UX-PRINCIPLES.md +3 -0
  44. package/package.json +10 -2
  45. package/src/adapters/core/surface.ts +71 -0
  46. package/src/adapters/extension/surface.ts +88 -0
  47. package/src/adapters/mobile/provision.ts +594 -0
  48. package/src/adapters/mobile/surface.ts +71 -0
  49. package/src/adapters/slot-ports.ts +165 -0
  50. package/src/adapters/surface.ts +117 -0
  51. package/src/cli-commands.ts +1 -1
  52. package/src/cli.ts +239 -49
  53. package/src/commands/debug.ts +3 -1
  54. package/src/commands/fixtures.ts +13 -8
  55. package/src/commands/launch.ts +7 -156
  56. package/src/commands/logs.ts +29 -13
  57. package/src/harness.ts +140 -3
  58. package/src/mm-harness-cli.ts +71 -18
@@ -0,0 +1,165 @@
1
+ // Slot-port + dev-server process plumbing shared by the per-platform surfaces.
2
+ // Re-homed here (out of commands/launch.ts) so the surface implementations own
3
+ // port/device resolution and the extension watcher-stop without launch.ts and
4
+ // the surface registry forming an import cycle.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
11
+ import { recipeRuntimeDir, runnerDir } from '../paths.ts';
12
+
13
+ // Apply KEY=VALUE lines from slot resolution to process.env.
14
+ // overwrite=true → pool/context match, always overrides existing env.
15
+ // overwrite=false → formula match, only fills vars that are unset.
16
+ export function applyKVLines(output: string, overwrite: boolean): void {
17
+ for (const line of output.split('\n')) {
18
+ const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
19
+ if (!m) continue;
20
+ const [, key, val] = m;
21
+ switch (key) {
22
+ case 'WATCHER_PORT':
23
+ if (overwrite || !process.env['WATCHER_PORT']) {
24
+ process.env['WATCHER_PORT'] = val;
25
+ process.env['METRO_PORT'] = val;
26
+ process.env['RECIPE_WATCHER_PORT'] = val;
27
+ }
28
+ break;
29
+ case 'IOS_SIMULATOR':
30
+ if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
31
+ break;
32
+ case 'SLOT_ID':
33
+ if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
34
+ break;
35
+ case 'CDP_PORT':
36
+ if (overwrite || !process.env['CDP_PORT']) {
37
+ process.env['CDP_PORT'] = val;
38
+ process.env['RECIPE_CDP_PORT'] = val;
39
+ }
40
+ break;
41
+ }
42
+ }
43
+ }
44
+
45
+ function sourceResolve(fn: string, target: string): string {
46
+ const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
47
+ try {
48
+ return execFileSync('bash', ['-c', `source "${resolveScript}" && ${fn} "${target}"`], {
49
+ encoding: 'utf8',
50
+ timeout: 5000,
51
+ stdio: ['ignore', 'pipe', 'ignore'],
52
+ });
53
+ } catch {
54
+ return '';
55
+ }
56
+ }
57
+
58
+ // Resolve mobile slot port/simulator: the slot context the orchestrator wrote
59
+ // into the checkout wins first, then the farmslot pool (both overwrite env),
60
+ // then the slot-suffix formula (only fills unset vars). Called before explicit
61
+ // CLI flag overrides so flags always win at the top.
62
+ export function resolveMobileSlotPorts(target: string): void {
63
+ // The checkout's own runtime context is authoritative — it names the exact
64
+ // simulator/port this slot was prepared with, surviving pool renames.
65
+ const ctxOut = sourceResolve('resolve_mobile_runtime_context', target);
66
+ if (ctxOut.trim()) {
67
+ applyKVLines(ctxOut, true);
68
+ return;
69
+ }
70
+ // Pool match always wins — overwrite whatever is in the environment.
71
+ const poolOut = sourceResolve('resolve_farmslot_ports_by_repo', target);
72
+ if (poolOut.trim()) {
73
+ applyKVLines(poolOut, true);
74
+ return;
75
+ }
76
+ // Formula match only fills unset vars (never overrides explicit env/pool).
77
+ const defOut = sourceResolve('resolve_mobile_slot_defaults', target);
78
+ if (defOut.trim()) applyKVLines(defOut, false);
79
+ }
80
+
81
+ // Resolve extension slot ports the same way as mobile: the checkout's runtime
82
+ // context first (cdpPort/devServerPort written by the orchestrator's prepare),
83
+ // then the farmslot pool, then the directory-suffix formula (fills unset only).
84
+ export function resolveExtensionSlotPorts(target: string): void {
85
+ // The prepared checkout's context OVERWRITES inherited env (same authority as
86
+ // mobile's context/pool resolution): a stale CDP_PORT from the shell must not
87
+ // hijack the slot's browser. Explicit CLI flags are applied after and win.
88
+ const contextPath = resolveRuntimeContextPath(target);
89
+ const cdp = readRuntimeContextField(contextPath, 'cdpPort');
90
+ if (cdp) {
91
+ process.env['CDP_PORT'] = cdp;
92
+ process.env['RECIPE_CDP_PORT'] = cdp;
93
+ }
94
+ const dev = readRuntimeContextField(contextPath, 'devServerPort');
95
+ if (dev) {
96
+ process.env['WATCHER_PORT'] = dev;
97
+ process.env['RECIPE_WATCHER_PORT'] = dev;
98
+ }
99
+ if (process.env['CDP_PORT']) return;
100
+ const poolOut = sourceResolve('resolve_farmslot_ports_by_repo', target);
101
+ if (poolOut.trim()) {
102
+ applyKVLines(poolOut, true);
103
+ return;
104
+ }
105
+ const defOut = sourceResolve('resolve_default_extension_ports', target);
106
+ if (defOut.trim()) applyKVLines(defOut, false);
107
+ }
108
+
109
+ // Kill the harness-owned webpack watcher for this checkout: pid file first,
110
+ // then a ps-scan for orphans (argv or lsof-cwd match), TERM then KILL. Scoped
111
+ // to the target checkout — watchers of other slots are never touched. Returns
112
+ // how many processes were signalled so callers can state the outcome.
113
+ export function stopExtensionWatcher(target: string): number {
114
+ const runtimeAbs = path.join(target, recipeRuntimeDir());
115
+ const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
116
+ let signalled = 0;
117
+ try {
118
+ const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
119
+ if (/^\d+$/u.test(pid)) {
120
+ try { process.kill(Number(pid), 'SIGTERM'); signalled += 1; } catch { /* already dead */ }
121
+ }
122
+ fs.rmSync(webpackPidFile, { force: true });
123
+ } catch { /* no pid file */ }
124
+ // Scan for any remaining orphan webpack/yarn-start processes in this checkout.
125
+ try {
126
+ const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
127
+ const orphanPids: number[] = [];
128
+ for (const line of psOut.split('\n')) {
129
+ const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
130
+ if (!match) continue;
131
+ const [, pidStr, cmd] = match;
132
+ const isWatcher =
133
+ cmd.includes('yarn start') ||
134
+ cmd.includes('webpack --watch') ||
135
+ cmd.includes('development/webpack/launch.ts --watch');
136
+ if (!isWatcher) continue;
137
+ if (cmd.includes(target)) {
138
+ orphanPids.push(Number(pidStr));
139
+ continue;
140
+ }
141
+ // lsof cwd fallback for processes that don't embed the path in argv.
142
+ try {
143
+ const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
144
+ encoding: 'utf8',
145
+ timeout: 2000,
146
+ });
147
+ if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
148
+ orphanPids.push(Number(pidStr));
149
+ }
150
+ } catch { /* lsof unavailable or permission denied */ }
151
+ }
152
+ if (orphanPids.length > 0) {
153
+ for (const pid of orphanPids) {
154
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
155
+ }
156
+ // Brief pause then force-kill survivors.
157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
158
+ for (const pid of orphanPids) {
159
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
160
+ }
161
+ signalled += orphanPids.length;
162
+ }
163
+ } catch { /* ps not available */ }
164
+ return signalled;
165
+ }
@@ -0,0 +1,117 @@
1
+ // The per-platform surface: one interface every command resolves platform
2
+ // behavior through, so no command hand-rolls `if (adapter === …)` for behavior a
3
+ // platform owns. A command NEVER branches on adapter for behavior this interface
4
+ // owns; a new platform behavior extends the surface (a new member here + its
5
+ // three implementations), not the command. This is the enforcement mechanism for
6
+ // UX-PRINCIPLES.md principle 1 (context-aware by default). See
7
+ // docs/ADAPTER-SURFACE.md.
8
+
9
+ import type { MetaMaskRecipeAdapter } from '../types.ts';
10
+ import { coreSurface } from './core/surface.ts';
11
+ import { extensionSurface } from './extension/surface.ts';
12
+ import { mobileSurface } from './mobile/surface.ts';
13
+
14
+ // Read-only runtime readiness, normalized across platforms so `doctor` renders
15
+ // one line the same way regardless of adapter. Device platforms fill deps +
16
+ // devServer; core (headless) reports deps presence only (no devServer).
17
+ export interface AdapterRuntimeStatus {
18
+ decision: string;
19
+ reasonCode?: string;
20
+ reasons: string[];
21
+ deps?: string;
22
+ // The platform's dev server (Metro for mobile, webpack watcher for extension),
23
+ // labelled so the render names the right thing. Absent for headless core.
24
+ devServer?: { label: string; status: string };
25
+ }
26
+
27
+ // One log file a platform writes, most-relevant first. `logs` tails the first
28
+ // candidate that exists.
29
+ export interface AdapterLogSource {
30
+ label: string;
31
+ path: string;
32
+ }
33
+
34
+ // Result of stopping the platform's dev server. `headless` is the core case —
35
+ // there is nothing to stop, so `stop` teaches instead. `stopped` carries the
36
+ // exit status plus, when the platform counts them, how many processes were
37
+ // signalled and the raw leaf output for the --json envelope.
38
+ export type AdapterDevServerStop =
39
+ | { kind: 'headless'; message: string; userAction: string }
40
+ | { kind: 'stopped'; status: number; summary: string; signalled?: number; output?: string };
41
+
42
+ export interface AdapterDevServer {
43
+ // One noun for the dev server this platform runs ("Metro", "webpack watcher").
44
+ describe(): string;
45
+ // Stop the dev server this checkout owns, port/pid-scoped. Idempotent:
46
+ // nothing-to-stop is success, never an error.
47
+ stop(target: string): AdapterDevServerStop;
48
+ }
49
+
50
+ // Platform-phrased Next: hints so no command prints another platform's vocabulary.
51
+ // `launch` (re)starts the app + dev server; `relaunch` rebuilds first (the
52
+ // fixtures-set retry). core has no app, so its hints teach the headless path.
53
+ export interface AdapterHints {
54
+ launch: string;
55
+ relaunch: string;
56
+ }
57
+
58
+ export interface AdapterRunwayProvisionOptions {
59
+ json?: boolean;
60
+ platform?: string;
61
+ branch?: string;
62
+ defaultBranch?: string;
63
+ run?: string;
64
+ cacheRoot?: string;
65
+ simulator?: string;
66
+ runtime?: string;
67
+ deviceType?: string;
68
+ slot?: string;
69
+ watcherPort?: string;
70
+ runtimeDir?: string;
71
+ force?: boolean;
72
+ resolveOnly?: boolean;
73
+ rerunCommand: string;
74
+ }
75
+
76
+ export interface AdapterRunwayProvisionResult {
77
+ schemaVersion: 1;
78
+ command: 'provision';
79
+ adapter: MetaMaskRecipeAdapter;
80
+ target: string;
81
+ platform?: string;
82
+ status: 'pass' | 'fail';
83
+ exitCode: number;
84
+ error?: { code: string; message: string; userAction: string };
85
+ [key: string]: unknown;
86
+ }
87
+
88
+ export interface AdapterRunwayProvision {
89
+ run(target: string, options: AdapterRunwayProvisionOptions): Promise<AdapterRunwayProvisionResult>;
90
+ }
91
+
92
+ export interface AdapterSurface {
93
+ readonly adapter: MetaMaskRecipeAdapter;
94
+ // core runs no app/dev server; device adapters (mobile/extension) do. Commands
95
+ // ask this instead of testing `adapter === 'core'`.
96
+ readonly headless: boolean;
97
+ // Resolve slot ports/device into the environment (checkout context > pool >
98
+ // formula). No-op for core.
99
+ resolveSlotPorts(target: string): void;
100
+ // Read-only readiness for `doctor`. Never launches or mutates.
101
+ runtimeStatus(target: string): Promise<AdapterRuntimeStatus>;
102
+ devServer: AdapterDevServer;
103
+ runwayProvision: AdapterRunwayProvision;
104
+ // Ordered candidate log files for `logs`. Empty for core.
105
+ logSources(target: string): AdapterLogSource[];
106
+ hints: AdapterHints;
107
+ }
108
+
109
+ const SURFACES: Record<MetaMaskRecipeAdapter, AdapterSurface> = {
110
+ mobile: mobileSurface,
111
+ extension: extensionSurface,
112
+ core: coreSurface,
113
+ };
114
+
115
+ export function getAdapterSurface(adapter: MetaMaskRecipeAdapter): AdapterSurface {
116
+ return SURFACES[adapter];
117
+ }
@@ -33,7 +33,7 @@ export const SPEC: CommandSpecMap = {
33
33
  { name: 'logs', aliases: ['tail'], desc: 'Compact build events or full log', flags: ['--full', '-f'] },
34
34
  { name: 'debug', aliases: ['devtools', 'inspect'], desc: 'Open DevTools UI', flags: ['--json', '--no-open'] },
35
35
  { name: 'actions', desc: 'List runnable recipe actions', flags: ['--json'] },
36
- { name: 'doctor', desc: 'Check harness/orchestration health', flags: ['--json'] },
36
+ { name: 'doctor', desc: 'Check harness/orchestration health', flags: ['--json', '--target', '--adapter', '--runtime-dir'] },
37
37
  { name: 'run', desc: 'Execute a proof recipe', args: ['recipe.json'] },
38
38
  { name: 'interactive', aliases: ['menu'], desc: 'Interactive command menu' },
39
39
  { name: 'prepare', desc: 'Install harness (+ optional validate)', flags: ['--target', '--runtime-dir', '--json'] },