@deeeed/metamask-harness 0.74.0 → 0.76.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 (96) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/adapters/shared/reap-checkout-metros.sh +9 -0
  5. package/dist/action-values.js +15 -0
  6. package/dist/adapters/core/actions.js +20 -0
  7. package/dist/adapters/core/live-script-env.js +36 -0
  8. package/dist/adapters/core/overlay.js +34 -0
  9. package/dist/adapters/core/readiness.js +8 -0
  10. package/dist/adapters/core/run.js +54 -0
  11. package/dist/adapters/core/surface.js +12 -1
  12. package/dist/adapters/extension/action-inputs.js +206 -0
  13. package/dist/adapters/extension/actions.js +51 -0
  14. package/dist/adapters/extension/console-capture.js +13 -1
  15. package/dist/adapters/extension/doctor-checks.js +135 -0
  16. package/dist/adapters/extension/fixtures.js +103 -0
  17. package/dist/adapters/extension/launch.js +315 -0
  18. package/dist/adapters/extension/launched-browser.js +51 -0
  19. package/dist/adapters/extension/overlay.js +55 -0
  20. package/dist/adapters/extension/readiness.js +32 -0
  21. package/dist/adapters/extension/reload.js +40 -0
  22. package/dist/adapters/extension/run.js +105 -0
  23. package/dist/adapters/extension/surface.js +31 -3
  24. package/dist/adapters/harness-freshness.js +4 -2
  25. package/dist/adapters/mobile/actions.js +54 -0
  26. package/dist/adapters/mobile/devices.js +21 -0
  27. package/dist/adapters/mobile/doctor-checks.js +40 -0
  28. package/dist/adapters/mobile/fixtures.js +141 -0
  29. package/dist/adapters/mobile/launch.js +192 -0
  30. package/dist/adapters/mobile/lifecycle.js +300 -0
  31. package/dist/adapters/mobile/overlay.js +27 -0
  32. package/dist/adapters/mobile/prepare-target.js +38 -0
  33. package/dist/adapters/mobile/readiness.js +39 -0
  34. package/dist/adapters/mobile/reload.js +15 -0
  35. package/dist/adapters/mobile/run.js +156 -0
  36. package/dist/adapters/mobile/runtime-identity.js +34 -0
  37. package/dist/adapters/mobile/surface.js +39 -3
  38. package/dist/adapters/mobile/ui.js +701 -0
  39. package/dist/adapters/performance/cdp-trace.js +24 -6
  40. package/dist/adapters/performance/device-platform.js +10 -0
  41. package/dist/adapters/registry.js +1 -1
  42. package/dist/adapters/terminal/actions.js +43 -0
  43. package/dist/adapters/terminal/console-capture.js +14 -1
  44. package/dist/adapters/terminal/doctor-checks.js +22 -0
  45. package/dist/adapters/terminal/overlay.js +18 -0
  46. package/dist/adapters/terminal/readiness.js +11 -0
  47. package/dist/adapters/terminal/run.js +30 -0
  48. package/dist/adapters/terminal/surface.js +21 -3
  49. package/dist/adapters.js +32 -824
  50. package/dist/cli-commands.js +1 -0
  51. package/dist/cli.js +3 -1
  52. package/dist/command-contract.js +3 -0
  53. package/dist/commands/call.js +2 -5
  54. package/dist/commands/debug.js +6 -25
  55. package/dist/commands/device-target.js +2 -1
  56. package/dist/commands/doctor.js +26 -137
  57. package/dist/commands/fixtures.js +13 -217
  58. package/dist/commands/launch/device.js +2 -6
  59. package/dist/commands/launch/extension.js +3 -303
  60. package/dist/commands/launch/index.js +1 -1
  61. package/dist/commands/launch/mobile.js +2 -190
  62. package/dist/commands/launch/quote.js +7 -0
  63. package/dist/commands/logs.js +1 -1
  64. package/dist/commands/parse-args.js +9 -4
  65. package/dist/commands/pin.js +226 -0
  66. package/dist/commands/prepare.js +23 -53
  67. package/dist/commands/provision.js +3 -7
  68. package/dist/commands/reload.js +8 -47
  69. package/dist/commands/run-engine.js +38 -138
  70. package/dist/commands/run-report.js +4 -48
  71. package/dist/commands/run.js +6 -48
  72. package/dist/commands/runtime-decision.js +5 -27
  73. package/dist/commands/self-test.js +1 -3
  74. package/dist/commands/status.js +20 -28
  75. package/dist/commands/stop.js +1 -1
  76. package/dist/doctor.js +12 -120
  77. package/dist/execution-provenance.js +3 -2
  78. package/dist/harness-pin.js +10 -0
  79. package/dist/harness.js +31 -100
  80. package/dist/heal-bounds.js +3 -2
  81. package/dist/live-adapter-contract.js +8 -34
  82. package/dist/manifest.js +3 -5
  83. package/dist/metamask-action-validation.js +5 -196
  84. package/dist/mm-harness-cli.js +30 -4
  85. package/dist/network-observation.js +10 -3
  86. package/dist/paths.js +8 -2
  87. package/dist/performance-observation.js +15 -22
  88. package/dist/pr-body/render.js +3 -1
  89. package/dist/recording-target.js +4 -39
  90. package/dist/run-diagnostics.js +21 -60
  91. package/dist/run-recording.js +5 -4
  92. package/dist/runner.js +20 -368
  93. package/dist/runtime-context.js +12 -17
  94. package/docs/CONTRIBUTING.md +29 -7
  95. package/package.json +2 -1
  96. package/scripts/completions.sh +2 -1
@@ -10,7 +10,8 @@ import { pathToFileURL } from "node:url";
10
10
  import { build } from "esbuild";
11
11
  import { init, parse } from "es-module-lexer";
12
12
  import { resolveRequiredLocalProtocolRoot, runnerDir } from "./paths.js";
13
- import { corePnpNodeOptions } from "./commands/core-readiness.js";
13
+ import { adapterRegistry } from "./adapters/registry.js";
14
+ import { getAdapterSurface } from "./adapters/surface.js";
14
15
  import { serializeTrustedMutationContext } from "./funding-execution-context.js";
15
16
  function actionFileStem(action) {
16
17
  return String(action).replace(/[^a-zA-Z0-9._-]/g, "_");
@@ -213,8 +214,11 @@ function bindImportMetaUrls() {
213
214
  }
214
215
  };
215
216
  }
217
+ function tsxLiveScripts(platform) {
218
+ return platform && adapterRegistry.has(platform) ? getAdapterSurface(platform).actions.tsxLiveScripts : void 0;
219
+ }
216
220
  function commandFor(file, projectRoot, platform) {
217
- const needsTsx = platform === "core" || importsSourceTypescript(file);
221
+ const needsTsx = Boolean(tsxLiveScripts(platform)) || importsSourceTypescript(file);
218
222
  if (!needsTsx) {
219
223
  return { command: process.execPath, args: [file] };
220
224
  }
@@ -268,36 +272,6 @@ function importsSourceTypescriptFrom(file, visited) {
268
272
  async function resolveLiveAdapter(platform, action, runtimeEnv) {
269
273
  return firstExecutablePath(candidatePaths(platform, action, runtimeEnv));
270
274
  }
271
- const CORE_WORKSPACE_PACKAGES = [
272
- "@metamask/base-controller",
273
- "@metamask/messenger",
274
- "@metamask/controller-utils",
275
- "@metamask/keyring-controller"
276
- ];
277
- async function platformAdapterEnv(platform, projectRoot, tempDir) {
278
- if (platform !== "core") return {};
279
- const paths = {};
280
- for (const pkg of CORE_WORKSPACE_PACKAGES) {
281
- const packageDir = path.join(projectRoot, "packages", pkg.replace("@metamask/", ""));
282
- const distEntry = path.join(packageDir, "dist/index.cjs");
283
- if (existsSync(distEntry)) continue;
284
- const srcDir = path.join(packageDir, "src");
285
- if (!existsSync(path.join(srcDir, "index.ts"))) continue;
286
- paths[pkg] = [path.join(srcDir, "index.ts")];
287
- paths[`${pkg}/*`] = [path.join(srcDir, "*")];
288
- }
289
- if (Object.keys(paths).length === 0) return {};
290
- const tsconfigPath = path.join(tempDir, "core-adapter.tsconfig.json");
291
- await writeFile(
292
- tsconfigPath,
293
- `${JSON.stringify({ compilerOptions: { baseUrl: projectRoot, paths } }, null, 2)}
294
- `
295
- );
296
- return {
297
- TSX_TSCONFIG_PATH: tsconfigPath,
298
- NODE_OPTIONS: corePnpNodeOptions(projectRoot, process.env.NODE_OPTIONS)
299
- };
300
- }
301
275
  async function runLiveAdapterScript({ platform, action, node, context, prepared }) {
302
276
  const script = prepared?.entryPath ?? await resolveLiveAdapter(platform, action, context?.env);
303
277
  if (!script) return null;
@@ -320,7 +294,7 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
320
294
  await writeFile(inputPath, `${JSON.stringify(input, null, 2)}
321
295
  `);
322
296
  const command = prepared ? commandForPrepared(context.projectRoot, platform) : commandFor(script, context.projectRoot, platform);
323
- const platformEnv = await platformAdapterEnv(platform, context.projectRoot, tempDir);
297
+ const platformEnv = await tsxLiveScripts(platform)?.env(context.projectRoot, tempDir) ?? {};
324
298
  const processTimeoutMs = liveAdapterProcessTimeoutMs(node);
325
299
  const result = await runProcess(command.command, [...command.args, inputPath], {
326
300
  cwd: context.projectRoot,
@@ -380,7 +354,7 @@ function liveAdapterProcessTimeoutMs(node) {
380
354
  return 6e4 + settleAllowance;
381
355
  }
382
356
  function commandForPrepared(projectRoot, platform) {
383
- if (platform !== "core") {
357
+ if (!tsxLiveScripts(platform)) {
384
358
  return { command: process.execPath, args: ["--input-type=module", "-"] };
385
359
  }
386
360
  const tsxBin = resolveTsxBin(projectRoot);
package/dist/manifest.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
4
+ import { getAdapterSurface } from "./adapters/surface.js";
4
5
  import { manifestPath, readJson, importRecipeProtocol } from "./paths.js";
5
6
  function loadMetaMaskMobileActionManifest() {
6
7
  return asActionManifest(readJson(manifestPath("mobile")));
@@ -16,10 +17,7 @@ function loadMetaMaskTerminalActionManifest() {
16
17
  }
17
18
  function loadActionManifest(adapter, overridePath) {
18
19
  if (overridePath) return asActionManifest(readJson(path.resolve(overridePath)));
19
- if (adapter === "mobile") return loadMetaMaskMobileActionManifest();
20
- if (adapter === "core") return loadMetaMaskCoreActionManifest();
21
- if (adapter === "terminal") return loadMetaMaskTerminalActionManifest();
22
- return loadMetaMaskExtensionActionManifest();
20
+ return asActionManifest(readJson(getAdapterSurface(adapter).actions.manifestPath()));
23
21
  }
24
22
  async function resolveActionManifest(adapter, overridePath, librarySources, taskActionRoot) {
25
23
  if (overridePath) {
@@ -40,7 +38,7 @@ async function resolveActionManifest(adapter, overridePath, librarySources, task
40
38
  })
41
39
  };
42
40
  }
43
- const canonicalPath = path.resolve(manifestPath(adapter));
41
+ const canonicalPath = path.resolve(getAdapterSurface(adapter).actions.manifestPath());
44
42
  const canonicalRoot = path.dirname(path.dirname(canonicalPath));
45
43
  const sources = librarySources?.length ? librarySources : [{ name: "metamask", root: canonicalRoot }];
46
44
  const manifests = [];
@@ -1,3 +1,5 @@
1
+ import { adapterRegistry } from "./adapters/registry.js";
2
+ import { getAdapterSurface } from "./adapters/surface.js";
1
3
  function isRecord(value) {
2
4
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
5
  }
@@ -22,208 +24,15 @@ function validateMetaMaskActionInputs(recipe, adapter, params) {
22
24
  if (!isRecord(recipe) || !isRecord(recipe.workflow)) return [];
23
25
  const nodes = isRecord(recipe.workflow.nodes) ? recipe.workflow.nodes : void 0;
24
26
  if (!nodes) return [];
27
+ const surface = adapter && adapterRegistry.has(adapter) ? getAdapterSurface(adapter) : void 0;
25
28
  const findings = [];
26
29
  for (const [nodeId, rawNode] of Object.entries(nodes)) {
27
30
  const node = params ? resolveMetaMaskParameterValue(rawNode, params) : rawNode;
28
31
  if (!isRecord(node)) continue;
29
32
  const action = node.action;
30
- const maximum = (name, limit) => {
31
- if (typeof node[name] !== "number" || node[name] <= limit) return;
32
- findings.push({
33
- severity: "error",
34
- code: "recipe.invalid_param",
35
- path: `workflow.nodes.${nodeId}.${name}`,
36
- message: `${action} ${name} must not exceed ${limit}`
37
- });
38
- };
39
- if (action === "metamask.perps.read_snapshot" && adapter === "extension") {
40
- if (node.market !== void 0 && node.markets !== void 0) {
41
- findings.push({
42
- severity: "error",
43
- code: "recipe.invalid_param",
44
- path: `workflow.nodes.${nodeId}.markets`,
45
- message: `${action} accepts market or markets, not both`
46
- });
47
- }
48
- if (isRecord(node.history)) {
49
- const start = node.history.start_time_ms;
50
- const end = node.history.end_time_ms;
51
- if (typeof start === "number" && typeof end === "number" && start > end) {
52
- findings.push({
53
- severity: "error",
54
- code: "recipe.invalid_param",
55
- path: `workflow.nodes.${nodeId}.history.start_time_ms`,
56
- message: `${action} history.start_time_ms must not exceed history.end_time_ms`
57
- });
58
- }
59
- if (typeof node.history.limit === "number" && node.history.limit > 500) {
60
- findings.push({
61
- severity: "error",
62
- code: "recipe.invalid_param",
63
- path: `workflow.nodes.${nodeId}.history.limit`,
64
- message: `${action} history.limit must not exceed 500`
65
- });
66
- }
67
- }
68
- if (typeof node.max_items_per_section === "number" && node.max_items_per_section > 500) {
69
- findings.push({
70
- severity: "error",
71
- code: "recipe.invalid_param",
72
- path: `workflow.nodes.${nodeId}.max_items_per_section`,
73
- message: `${action} max_items_per_section must not exceed 500`
74
- });
75
- }
76
- if (typeof node.timeout_ms === "number" && node.timeout_ms > 6e4) {
77
- findings.push({
78
- severity: "error",
79
- code: "recipe.invalid_param",
80
- path: `workflow.nodes.${nodeId}.timeout_ms`,
81
- message: `${action} timeout_ms must not exceed 60000`
82
- });
83
- }
84
- if (isRecord(node.sampling)) {
85
- if (typeof node.sampling.count === "number" && node.sampling.count > 10) {
86
- findings.push({
87
- severity: "error",
88
- code: "recipe.invalid_param",
89
- path: `workflow.nodes.${nodeId}.sampling.count`,
90
- message: `${action} sampling.count must not exceed 10`
91
- });
92
- }
93
- if (typeof node.sampling.interval_ms === "number" && node.sampling.interval_ms > 3e4) {
94
- findings.push({
95
- severity: "error",
96
- code: "recipe.invalid_param",
97
- path: `workflow.nodes.${nodeId}.sampling.interval_ms`,
98
- message: `${action} sampling.interval_ms must not exceed 30000`
99
- });
100
- }
101
- }
102
- if (isRecord(node.expect)) {
103
- if (typeof node.expect.minimum_changed_market_count === "number" && node.expect.minimum_changed_market_count > 100) {
104
- findings.push({
105
- severity: "error",
106
- code: "recipe.invalid_param",
107
- path: `workflow.nodes.${nodeId}.expect.minimum_changed_market_count`,
108
- message: `${action} expect.minimum_changed_market_count must not exceed 100`
109
- });
110
- }
111
- if (typeof node.expect.minimum_history_count === "number" && node.expect.minimum_history_count > 500) {
112
- findings.push({
113
- severity: "error",
114
- code: "recipe.invalid_param",
115
- path: `workflow.nodes.${nodeId}.expect.minimum_history_count`,
116
- message: `${action} expect.minimum_history_count must not exceed 500`
117
- });
118
- }
119
- }
120
- }
121
- if (action === "metamask.perps.assert_visible_consistency" && adapter === "extension") {
122
- maximum("sample_count", 10);
123
- maximum("sample_interval_ms", 3e4);
124
- maximum("minimum_changed_count", 200);
125
- maximum("minimum_recent_activity_count", 50);
126
- maximum("timeout_ms", 12e4);
127
- maximum("max_items", 200);
128
- }
129
- const fundedVisibleMutation = adapter === "extension" && (action === "metamask.perps.close_visible_position" || action === "metamask.perps.close_orders" || action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl" || action === "metamask.perps.place_order" && node.confirm_live === true);
130
- if (fundedVisibleMutation) {
131
- const required = (name, pattern, message) => {
132
- if (typeof node[name] === "string" && pattern.test(node[name])) return;
133
- findings.push({
134
- severity: "error",
135
- code: "recipe.invalid_param",
136
- path: `workflow.nodes.${nodeId}.${name}`,
137
- message
138
- });
139
- };
140
- const decimal = action === "metamask.perps.close_visible_position" || action === "metamask.perps.update_position_tpsl" ? node.funding_amount : node.amount ?? node.notional;
141
- const decimalText = String(decimal ?? "");
142
- const match = /^([1-9][0-9]*)(?:\.([0-9]{1,6}))?$/u.exec(decimalText);
143
- if (!match) {
144
- findings.push({
145
- severity: "error",
146
- code: "recipe.invalid_param",
147
- path: `workflow.nodes.${nodeId}.${action === "metamask.perps.close_visible_position" || action === "metamask.perps.update_position_tpsl" ? "funding_amount" : "amount"}`,
148
- message: `${action} funded decimal amount must be positive plain decimal text with at most six places`
149
- });
150
- }
151
- if (action === "metamask.perps.place_order" && node.submit_attempts !== void 0 && node.submit_attempts !== 1) {
152
- findings.push({
153
- severity: "error",
154
- code: "recipe.invalid_param",
155
- path: `workflow.nodes.${nodeId}.submit_attempts`,
156
- message: `${action} funded visible submission requires submit_attempts=1`
157
- });
158
- }
159
- if (action === "metamask.perps.close_visible_position" || action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl") {
160
- required("funding_side", /^(?:long|short)$/u, `${action} requires the authorized position side`);
161
- }
162
- if (action === "metamask.perps.edit_margin" || action === "metamask.perps.update_position_tpsl") {
163
- required(
164
- "funding_position_size",
165
- /^(?=[0-9.]*[1-9])[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u,
166
- `${action} requires the exact visible position size`
167
- );
168
- }
169
- if (action === "metamask.perps.close_visible_position" && node.cleanup_only === true) {
170
- required(
171
- "funding_position_size",
172
- /^(?=[0-9.]*[1-9])[0-9]+(?:\.[0-9]+)? [A-Z0-9:_-]{2,20}$/u,
173
- `${action} cleanup requires the exact visible position size`
174
- );
175
- if (node.amount != null) {
176
- findings.push({
177
- severity: "error",
178
- code: "recipe.invalid_param",
179
- path: `workflow.nodes.${nodeId}.amount`,
180
- message: `${action} cleanup must close the full exact position`
181
- });
182
- }
183
- }
184
- if (action === "metamask.perps.edit_margin" && !/^[1-9][0-9]*(?:\.[0-9]{1,6})?$/u.test(String(node.funding_amount ?? ""))) {
185
- findings.push({
186
- severity: "error",
187
- code: "recipe.invalid_param",
188
- path: `workflow.nodes.${nodeId}.funding_amount`,
189
- message: `${action} requires the authorized position amount`
190
- });
191
- }
192
- if (action === "metamask.perps.close_orders") {
193
- required("side", /^(?:long|short)$/u, `${action} requires the exact order side`);
194
- const outputPrice = /^\{\{outputs\.[A-Za-z0-9_.-]+\}\}$/u.test(String(node.limit_price ?? ""));
195
- if (!outputPrice && (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/u.test(String(node.limit_price ?? "")) || !(Number(node.limit_price) > 0))) {
196
- findings.push({
197
- severity: "error",
198
- code: "recipe.invalid_param",
199
- path: `workflow.nodes.${nodeId}.limit_price`,
200
- message: `${action} requires an explicit positive limit_price for resumable cancellation`
201
- });
202
- }
203
- }
204
- if (action === "metamask.perps.place_order" && String(node.order_type ?? "market") === "limit" && (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/u.test(String(node.limit_price ?? "")) || !(Number(node.limit_price) > 0))) {
205
- findings.push({
206
- severity: "error",
207
- code: "recipe.invalid_param",
208
- path: `workflow.nodes.${nodeId}.limit_price`,
209
- message: `${action} funded limit submission requires explicit limit_price; offset-only pricing is not resumable`
210
- });
211
- }
212
- for (const name of ["identity_rounding_tolerance_base_units", "balance_reconciliation_tolerance_base_units"]) {
213
- if (node[name] === void 0) continue;
214
- const value = String(node[name]);
215
- if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(value) || BigInt(value) > 1000000n) {
216
- findings.push({
217
- severity: "error",
218
- code: "recipe.invalid_param",
219
- path: `workflow.nodes.${nodeId}.${name}`,
220
- message: `${action} ${name} must be an integer string from 0 through 1000000`
221
- });
222
- }
223
- }
224
- }
33
+ findings.push(...surface?.actions.inputFindings?.(nodeId, node) ?? []);
225
34
  const state = typeof node.state === "string" ? node.state.toLowerCase() : void 0;
226
- const createsOrder = action === "metamask.perps.place_order" || adapter !== "extension" && (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
35
+ const createsOrder = action === "metamask.perps.place_order" || !surface?.actions.ensureChecksOnly && (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
227
36
  if (!createsOrder) continue;
228
37
  const requireOneOf = (names, message) => {
229
38
  const present = names.some((name) => {
@@ -7,6 +7,7 @@ import { color } from "./cli-color.js";
7
7
  import { withCommandJournal } from "./command-journal.js";
8
8
  import { JsonStreamWriter } from "./json-stream.js";
9
9
  import { handleUpdate, maybeNudge } from "./commands/update.js";
10
+ import { handlePin } from "./commands/pin.js";
10
11
  import { handleCallHelp } from "./commands/call.js";
11
12
  import { handleHelp } from "./commands/help.js";
12
13
  import { handleTutorial } from "./commands/tutorial.js";
@@ -834,6 +835,27 @@ Example:
834
835
  Example:
835
836
  mm-harness reload`
836
837
  },
838
+ {
839
+ name: "pin",
840
+ summary: "Copy this installed mm-harness into an immutable, versioned store and print the copy for a task to run, so a later global upgrade cannot change a running task.",
841
+ example: "mm-harness pin",
842
+ helpText: `mm-harness pin [flags]
843
+
844
+ Copies the installed mm-harness running this command into an immutable,
845
+ versioned store (~/.local/share/mm-harness/pins/<version>-<hash>/; APFS clone
846
+ where available) and prints the copy's executable. A task records that path,
847
+ so \`npm i -g\` upgrading the global install never changes the harness a running
848
+ task runs. Idempotent: the same install returns the same pin. A source checkout
849
+ (MM_HARNESS_BIN) or an existing pin is printed as is.
850
+
851
+ --json Machine-readable { version, source, executable, pinned, created }
852
+
853
+ MM_HARNESS_PIN_ROOT overrides the store location.
854
+
855
+ Example:
856
+ mm-harness pin
857
+ mm-harness pin --json`
858
+ },
837
859
  {
838
860
  name: "update",
839
861
  summary: "Update the installed mm-harness to the published latest (--check reports only; --json = {current, latest, updateAvailable}).",
@@ -980,7 +1002,7 @@ const HELP_GROUPS = [
980
1002
  {
981
1003
  title: "MAINTAIN",
982
1004
  blurb: "keep the installed harness current with the npm registry",
983
- commands: ["setup-base", "update"]
1005
+ commands: ["setup-base", "update", "pin"]
984
1006
  }
985
1007
  ];
986
1008
  function commandMeta(name) {
@@ -1010,8 +1032,9 @@ function detectedSlotLine(out) {
1010
1032
  if (ctx.slotId) parts.push(`slot ${out("ok", String(ctx.slotId))}`);
1011
1033
  if (ctx.simulator) parts.push(`device ${out("ok", String(ctx.simulator))}`);
1012
1034
  const devServerPort = ctx.watcherPort ?? ctx.devServerPort ?? ctx.metroPort;
1013
- if (adapter && adapter !== "core" && devServerPort) {
1014
- parts.push(`${getAdapterSurface(adapter).devServer.label} :${out("ok", String(devServerPort))}`);
1035
+ const surface = adapter ? getAdapterSurface(adapter) : void 0;
1036
+ if (surface && !surface.headless && devServerPort) {
1037
+ parts.push(`${surface.devServer.label} :${out("ok", String(devServerPort))}`);
1015
1038
  }
1016
1039
  if (ctx.gitBranch) parts.push(`branch ${out("info", String(ctx.gitBranch))}`);
1017
1040
  if (parts.length === 0) return null;
@@ -1088,6 +1111,9 @@ for (const command of REAL) {
1088
1111
  if (command.name === "update") {
1089
1112
  process.exit(await handleUpdate(rawArgv.slice(1)));
1090
1113
  }
1114
+ if (command.name === "pin") {
1115
+ process.exit(handlePin(rawArgv.slice(1)));
1116
+ }
1091
1117
  if (command.name === "help") {
1092
1118
  process.exitCode = handleHelp(rawArgv.slice(1), { packageRoot, harnessVersion: pkgVersion });
1093
1119
  return;
@@ -1116,7 +1142,7 @@ program.command("completions").description("Install/print bundled shell tab-comp
1116
1142
  const result = spawnSync("bash", [script, ...rawArgv.slice(1)], { stdio: "inherit" });
1117
1143
  process.exit(result.status ?? 1);
1118
1144
  });
1119
- const NUDGE_SKIP = ["help", "tutorial", "setup-base", "update", "completions", "completion-candidates"];
1145
+ const NUDGE_SKIP = ["help", "tutorial", "setup-base", "update", "pin", "completions", "completion-candidates"];
1120
1146
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
1121
1147
  maybeNudge();
1122
1148
  }
@@ -4,6 +4,7 @@ import configModule from "../adapters/mobile/bridge-runtime/lib/config.cjs";
4
4
  import {
5
5
  createExtensionNetworkObserver
6
6
  } from "./adapters/extension/network-observer.js";
7
+ import { getAdapterSurface } from "./adapters/surface.js";
7
8
  import {
8
9
  indexArtifactManifest,
9
10
  writeContainedArtifact
@@ -14,7 +15,8 @@ const { brokerSocketPath, createBrokerClient } = brokerModule;
14
15
  const { resolvePort } = configModule;
15
16
  const sessions = /* @__PURE__ */ new Map();
16
17
  async function startRunNetworkObservation(adapter, target, artifactsDir, env, ports = {}) {
17
- if (adapter === "core") return void 0;
18
+ const network = getAdapterSurface(adapter).observation?.network;
19
+ if (!network) return void 0;
18
20
  const runtimeEnv = {
19
21
  ...env,
20
22
  ...ports.cdpPort ? { CDP_PORT: ports.cdpPort, RECIPE_CDP_PORT: ports.cdpPort } : {},
@@ -29,7 +31,7 @@ async function startRunNetworkObservation(adapter, target, artifactsDir, env, po
29
31
  };
30
32
  sessions.set(key, session);
31
33
  try {
32
- session.backend = adapter === "mobile" ? await createMobileNetworkBackend(target, runtimeEnv) : await createExtensionNetworkObserver(extensionCdpPort(runtimeEnv), key);
34
+ session.backend = await network.backend(target, runtimeEnv, key);
33
35
  } catch (error) {
34
36
  session.setupError = boundedError(error);
35
37
  }
@@ -76,7 +78,7 @@ async function startRunNetworkObservation(adapter, target, artifactsDir, env, po
76
78
  };
77
79
  }
78
80
  async function handleRunNetworkAction(platform, action, node, context) {
79
- if (platform !== "extension") return null;
81
+ if (!getAdapterSurface(platform).observation?.network?.actions) return null;
80
82
  if (action === "app.network_assert") {
81
83
  const assertion = await import("../library/actions/shared/app/network-assert.mjs");
82
84
  return {
@@ -261,6 +263,9 @@ async function indexArtifact(artifactManifestPath, relativePath, label) {
261
263
  ]
262
264
  );
263
265
  }
266
+ async function createExtensionNetworkBackend(env, artifactsDir) {
267
+ return createExtensionNetworkObserver(extensionCdpPort(env), artifactsDir);
268
+ }
264
269
  function extensionCdpPort(env) {
265
270
  const port = Number(
266
271
  env.CDP_PORT ?? env.RECIPE_CDP_PORT ?? process.env.CDP_PORT
@@ -278,6 +283,8 @@ function asRecord(value) {
278
283
  }
279
284
  export {
280
285
  connectMobileBroker,
286
+ createExtensionNetworkBackend,
287
+ createMobileNetworkBackend,
281
288
  handleRunNetworkAction,
282
289
  startRunNetworkObservation
283
290
  };
package/dist/paths.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { adapterRegistry } from "./adapters/registry.js";
4
5
  const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
5
6
  const pathDefaults = readPathDefaults();
6
7
  const DEFAULT_RECIPE_RUNTIME_DIR = pathDefaults.recipeRuntimeDir;
@@ -88,8 +89,13 @@ function isProtocolRoot(candidate) {
88
89
  return fs.existsSync(path.join(candidate, "packages/recipe-harness/package.json")) && fs.existsSync(path.join(candidate, "packages/protocol/package.json"));
89
90
  }
90
91
  function assertAdapter(adapter) {
91
- if (adapter !== "mobile" && adapter !== "extension" && adapter !== "core" && adapter !== "terminal") {
92
- throw new Error("Adapter must be mobile, extension, core, or terminal.");
92
+ const ids = adapterRegistry.list();
93
+ if (ids.length === 0) {
94
+ throw new Error("internal: no adapters are registered yet; import src/adapters/surface.ts before validating an adapter");
95
+ }
96
+ if (typeof adapter !== "string" || !adapterRegistry.has(adapter)) {
97
+ const choices = ids.length > 1 ? `${ids.slice(0, -1).join(", ")}, or ${ids.at(-1)}` : ids.join("");
98
+ throw new Error(`Adapter must be ${choices}.`);
93
99
  }
94
100
  }
95
101
  function manifestPath(adapter) {
@@ -1,10 +1,9 @@
1
1
  import path from "node:path";
2
2
  import { promisify } from "node:util";
3
3
  import { gzip } from "node:zlib";
4
- import {
5
- createMobilePerformanceBackend
6
- } from "./adapters/mobile/performance-observer.js";
4
+ import { getAdapterSurface } from "./adapters/surface.js";
7
5
  import { createExtensionPerformanceBackend } from "./adapters/extension/performance-observer.js";
6
+ import { TRACE_KINDS } from "./adapters/performance/cdp-trace.js";
8
7
  import { summarizeFrames } from "./adapters/mobile/frame-metrics.js";
9
8
  import { summarizeJavaScriptTasks } from "./adapters/performance/js-task-metrics.js";
10
9
  import {
@@ -17,10 +16,11 @@ const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1e3;
17
16
  const MAX_DURATION_MS = 60 * 60 * 1e3;
18
17
  const gzipAsync = promisify(gzip);
19
18
  function startRunPerformanceObservation(adapter, target, artifactsDir, env, ports = {}) {
20
- if (adapter === "core") return void 0;
19
+ const observer = getAdapterSurface(adapter).observation?.performance;
20
+ if (!observer) return void 0;
21
21
  const key = path.resolve(artifactsDir);
22
22
  const session = {
23
- adapter,
23
+ observer,
24
24
  artifactsDir: key,
25
25
  env: {
26
26
  ...env,
@@ -84,7 +84,7 @@ function startRunPerformanceObservation(adapter, target, artifactsDir, env, port
84
84
  };
85
85
  }
86
86
  async function handleRunPerformanceAction(platform, action, node, context) {
87
- if (platform === "core") return null;
87
+ if (!getAdapterSurface(platform).observation?.performance) return null;
88
88
  if (action === "app.performance_assert") {
89
89
  return { output: await assertPerformance(node, context) };
90
90
  }
@@ -154,10 +154,7 @@ async function performanceBackend(session) {
154
154
  if (session.backend) return session.backend;
155
155
  if (session.backendError) throw new Error(session.backendError);
156
156
  try {
157
- session.backend = session.adapter === "mobile" ? await createMobilePerformanceBackend(session.target, session.env) : await createExtensionPerformanceBackend(
158
- extensionCdpPort(session),
159
- session.target
160
- );
157
+ session.backend = await session.observer.backend(session.target, session.env, session.ports);
161
158
  return session.backend;
162
159
  } catch (error) {
163
160
  session.backendError = errorMessage(error);
@@ -182,7 +179,7 @@ function finishCaptureWindow(session, window) {
182
179
  return window.result;
183
180
  }
184
181
  function buildPerformanceArtifact(window, endedAtEpochMs, result, allEvents) {
185
- const javascriptTasksApplicable = result.platform === "extension";
182
+ const javascriptTasksApplicable = TRACE_KINDS[result.platform].javascriptTasks;
186
183
  const javascript = javascriptTasksApplicable ? result.javascript : {
187
184
  ...result.javascript,
188
185
  applicability: "not_applicable",
@@ -436,7 +433,7 @@ function chromeTracePath(jsonPath) {
436
433
  return jsonPath.endsWith(".json") ? `${jsonPath.slice(0, -".json".length)}-trace.json.gz` : `${jsonPath}-trace.json.gz`;
437
434
  }
438
435
  function renderPerformanceHtml(artifact) {
439
- const javascriptTasksApplicable = artifact.platform === "extension";
436
+ const javascriptTasksApplicable = TRACE_KINDS[artifact.platform].javascriptTasks;
440
437
  const iosCadence = artifact.platform === "ios";
441
438
  const maxNodeDurationMs = Math.max(
442
439
  1,
@@ -503,7 +500,7 @@ function unavailableCapture(session, window, reason) {
503
500
  window,
504
501
  Date.now(),
505
502
  {
506
- platform: resolveSessionPlatform(session),
503
+ platform: session.observer.platform(session.env),
507
504
  javascript: unavailableJavaScript,
508
505
  nativeUi: unavailableNativeUi,
509
506
  trace: {
@@ -520,17 +517,12 @@ function unavailableCapture(session, window, reason) {
520
517
  session.nodeEvents
521
518
  );
522
519
  }
523
- function resolveSessionPlatform(session) {
524
- if (session.adapter === "extension") return "extension";
525
- const explicit = String(
526
- session.env.MM_HARNESS_EXPLICIT_PLATFORM ?? session.env.RECIPE_HARNESS_PLATFORM ?? ""
527
- ).toLowerCase();
528
- if (explicit === "android" || explicit === "ios") return explicit;
529
- return session.env.ADB_SERIAL || session.env.ANDROID_SERIAL || session.env.ANDROID_DEVICE ? "android" : "ios";
520
+ async function createExtensionPerformanceObserver(target, env, ports) {
521
+ return createExtensionPerformanceBackend(extensionCdpPort(ports, env), target);
530
522
  }
531
- function extensionCdpPort(session) {
523
+ function extensionCdpPort(ports, env) {
532
524
  const port = Number(
533
- session.ports.cdpPort ?? session.env.CDP_PORT ?? session.env.RECIPE_CDP_PORT
525
+ ports.cdpPort ?? env.CDP_PORT ?? env.RECIPE_CDP_PORT
534
526
  );
535
527
  if (!Number.isInteger(port) || port <= 0) {
536
528
  throw new Error("Extension performance observation requires CDP_PORT.");
@@ -572,6 +564,7 @@ function errorMessage(error) {
572
564
  }
573
565
  export {
574
566
  buildPerformanceArtifact,
567
+ createExtensionPerformanceObserver,
575
568
  handleRunPerformanceAction,
576
569
  renderPerformanceHtml,
577
570
  startRunPerformanceObservation
@@ -37,6 +37,8 @@ function countRecipeNodes(recipe) {
37
37
  if (Array.isArray(record.steps)) return record.steps.length;
38
38
  const workflow = record.workflow;
39
39
  if (!workflow || typeof workflow !== "object") return 0;
40
+ const graph = workflow.nodes;
41
+ if (graph && typeof graph === "object" && !Array.isArray(graph)) return Object.keys(graph).length;
40
42
  return Object.values(workflow).reduce(
41
43
  (total, value) => total + (Array.isArray(value) ? value.length : 0),
42
44
  0
@@ -47,7 +49,7 @@ function recipeSection(file) {
47
49
  const nodes = countRecipeNodes(recipe);
48
50
  const record = recipe && typeof recipe === "object" ? recipe : {};
49
51
  const title = typeof record.title === "string" ? record.title : typeof record.description === "string" ? record.description : "";
50
- const summary = escapeHtml(`recipe.json (${nodes} steps${title ? ` \u2014 ${title.slice(0, 120)}` : ""})`);
52
+ const summary = escapeHtml(`recipe.json (${nodes} nodes${title ? ` \u2014 ${title.slice(0, 120)}` : ""})`);
51
53
  return {
52
54
  nodes,
53
55
  body: [`<details><summary>${summary}</summary>`, "", fenced(JSON.stringify(recipe, null, 2), "json"), "</details>"].join("\n")
@@ -6,45 +6,10 @@ const IOS_SIMULATOR_RECORDING_TARGET_PREFIX = "ios-simulator:";
6
6
  function createMetaMaskRecordingTargetProvider(adapter) {
7
7
  return {
8
8
  async resolveRecordingTarget(context) {
9
- if (adapter === "extension") {
10
- const pid = resolveExtensionBrowserPid(
11
- context.projectRoot,
12
- context.artifactsDir,
13
- context.env.CDP_PORT ?? context.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT
14
- );
15
- if (!pid) {
16
- throw new Error(
17
- "Could not resolve the extension browser PID for --record-video. Ensure the slot browser is running and CDP_PORT is set."
18
- );
19
- }
20
- return { kind: "pid", pid };
21
- }
22
- if (adapter === "mobile") {
23
- const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? "ios").trim().toLowerCase();
24
- const androidSerial = resolveAndroidDeviceSerial(context.env);
25
- if (platform === "android" || androidSerial) {
26
- if (!androidSerial) {
27
- throw new Error(
28
- "Could not resolve an Android device for --record-video. Set ADB_SERIAL or pass --device to mm-harness run."
29
- );
30
- }
31
- return {
32
- kind: "android-device",
33
- serial: androidSerial
34
- };
35
- }
36
- const simulator = resolveMobileIosSimulatorName(context.env);
37
- if (!simulator) {
38
- throw new Error(
39
- "Could not resolve an iOS simulator name for --record-video. Set IOS_SIMULATOR or pass --simulator to the recipe hook."
40
- );
41
- }
42
- return {
43
- kind: "window-id",
44
- windowId: `${IOS_SIMULATOR_RECORDING_TARGET_PREFIX}${simulator}`
45
- };
46
- }
47
- throw new Error(`--record-video is not implemented for the ${adapter} adapter.`);
9
+ const { getAdapterSurface } = await import("./adapters/surface.js");
10
+ const recording = getAdapterSurface(adapter).recording;
11
+ if (!recording) throw new Error(`--record-video is not implemented for the ${adapter} adapter.`);
12
+ return recording.target(context);
48
13
  }
49
14
  };
50
15
  }