@deeeed/metamask-harness 0.74.0 → 0.75.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 (60) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/dist/action-values.js +15 -0
  5. package/dist/adapters/core/actions.js +20 -0
  6. package/dist/adapters/core/live-script-env.js +36 -0
  7. package/dist/adapters/core/run.js +54 -0
  8. package/dist/adapters/core/surface.js +5 -1
  9. package/dist/adapters/extension/action-inputs.js +206 -0
  10. package/dist/adapters/extension/actions.js +51 -0
  11. package/dist/adapters/extension/console-capture.js +13 -1
  12. package/dist/adapters/extension/launch.js +315 -0
  13. package/dist/adapters/extension/launched-browser.js +51 -0
  14. package/dist/adapters/extension/run.js +105 -0
  15. package/dist/adapters/extension/surface.js +9 -2
  16. package/dist/adapters/mobile/actions.js +54 -0
  17. package/dist/adapters/mobile/launch.js +192 -0
  18. package/dist/adapters/mobile/lifecycle.js +300 -0
  19. package/dist/adapters/mobile/run.js +156 -0
  20. package/dist/adapters/mobile/runtime-identity.js +34 -0
  21. package/dist/adapters/mobile/surface.js +11 -2
  22. package/dist/adapters/mobile/ui.js +701 -0
  23. package/dist/adapters/performance/cdp-trace.js +24 -6
  24. package/dist/adapters/performance/device-platform.js +10 -0
  25. package/dist/adapters/registry.js +1 -1
  26. package/dist/adapters/terminal/actions.js +43 -0
  27. package/dist/adapters/terminal/console-capture.js +14 -1
  28. package/dist/adapters/terminal/run.js +30 -0
  29. package/dist/adapters/terminal/surface.js +6 -1
  30. package/dist/adapters.js +32 -824
  31. package/dist/cli-commands.js +1 -0
  32. package/dist/command-contract.js +3 -0
  33. package/dist/commands/call.js +2 -5
  34. package/dist/commands/launch/device.js +2 -6
  35. package/dist/commands/launch/extension.js +3 -303
  36. package/dist/commands/launch/index.js +1 -1
  37. package/dist/commands/launch/mobile.js +2 -190
  38. package/dist/commands/launch/quote.js +7 -0
  39. package/dist/commands/pin.js +226 -0
  40. package/dist/commands/run-engine.js +38 -138
  41. package/dist/commands/run-report.js +4 -48
  42. package/dist/commands/run.js +6 -48
  43. package/dist/doctor.js +3 -1
  44. package/dist/execution-provenance.js +3 -2
  45. package/dist/harness-pin.js +10 -0
  46. package/dist/heal-bounds.js +3 -2
  47. package/dist/live-adapter-contract.js +8 -34
  48. package/dist/manifest.js +3 -5
  49. package/dist/metamask-action-validation.js +5 -196
  50. package/dist/mm-harness-cli.js +27 -2
  51. package/dist/network-observation.js +10 -3
  52. package/dist/performance-observation.js +15 -22
  53. package/dist/pr-body/render.js +3 -1
  54. package/dist/recording-target.js +4 -39
  55. package/dist/run-diagnostics.js +21 -60
  56. package/dist/run-recording.js +5 -4
  57. package/dist/runner.js +20 -368
  58. package/docs/CONTRIBUTING.md +19 -7
  59. package/package.json +2 -1
  60. package/scripts/completions.sh +2 -1
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import fs, { constants as fsConstants } from "node:fs";
4
4
  import path from "node:path";
5
- import { mobileSourceFingerprint } from "./adapters/mobile/source-freshness.js";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
6
6
  import { indexArtifactManifest } from "./artifact-files.js";
7
7
  import { importRecipeProtocol, runnerDir } from "./paths.js";
8
8
  const gitContexts = /* @__PURE__ */ new Map();
@@ -156,10 +156,11 @@ function sourceSnapshot(root, adapter, excludedRoots = [], includedRoots = []) {
156
156
  ...pathspecs
157
157
  ]);
158
158
  const head = gitBuffer(git.topLevel, ["rev-parse", "HEAD"]);
159
+ const platformFingerprint = adapter ? getAdapterSurface(adapter).sourceFingerprint : void 0;
159
160
  return {
160
161
  head: head.toString("utf8").trim() || null,
161
162
  status,
162
- sourceFingerprint: adapter === "mobile" && path.resolve(root) === git.topLevel ? mobileSourceFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots, head)
163
+ sourceFingerprint: platformFingerprint && path.resolve(root) === git.topLevel ? platformFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots, head)
163
164
  };
164
165
  }
165
166
  function nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots, head) {
@@ -0,0 +1,10 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const PIN_MARKER = ".mm-harness-pin.json";
4
+ function isPin(root) {
5
+ return fs.existsSync(path.join(root, PIN_MARKER));
6
+ }
7
+ export {
8
+ PIN_MARKER,
9
+ isPin
10
+ };
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { getAdapterSurface } from "./adapters/surface.js";
4
5
  import { handleHarness } from "./harness.js";
5
6
  import { recipeHarnessPath, recipeRuntimePath } from "./paths.js";
6
7
  import { EXIT } from "./commands/shared.js";
@@ -28,7 +29,7 @@ function overlayPresent(target, adapter) {
28
29
  }
29
30
  }
30
31
  function overlayDelegateValid(target, adapter) {
31
- if (adapter === "core") return true;
32
+ if (getAdapterSurface(adapter).headless) return true;
32
33
  const pointer = path.join(recipeHarnessPath(target, adapter), "runner", ".runner-source");
33
34
  if (!fs.existsSync(pointer)) return true;
34
35
  const runnerPath = fs.readFileSync(pointer, "utf8").trim();
@@ -36,7 +37,7 @@ function overlayDelegateValid(target, adapter) {
36
37
  return fs.existsSync(path.join(runnerPath, "bin", "mm-harness"));
37
38
  }
38
39
  async function ensureOverlay(adapter, target, heal, state, json) {
39
- if (adapter === "core") return { ok: true };
40
+ if (getAdapterSurface(adapter).headless) return { ok: true };
40
41
  if (overlayPresent(target, adapter) && overlayDelegateValid(target, adapter)) return { ok: true };
41
42
  if (heal === "off") return { ok: true };
42
43
  const installBin = process.env.MM_HARNESS_INSTALL_BIN;
@@ -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) {
@@ -1088,6 +1110,9 @@ for (const command of REAL) {
1088
1110
  if (command.name === "update") {
1089
1111
  process.exit(await handleUpdate(rawArgv.slice(1)));
1090
1112
  }
1113
+ if (command.name === "pin") {
1114
+ process.exit(handlePin(rawArgv.slice(1)));
1115
+ }
1091
1116
  if (command.name === "help") {
1092
1117
  process.exitCode = handleHelp(rawArgv.slice(1), { packageRoot, harnessVersion: pkgVersion });
1093
1118
  return;
@@ -1116,7 +1141,7 @@ program.command("completions").description("Install/print bundled shell tab-comp
1116
1141
  const result = spawnSync("bash", [script, ...rawArgv.slice(1)], { stdio: "inherit" });
1117
1142
  process.exit(result.status ?? 1);
1118
1143
  });
1119
- const NUDGE_SKIP = ["help", "tutorial", "setup-base", "update", "completions", "completion-candidates"];
1144
+ const NUDGE_SKIP = ["help", "tutorial", "setup-base", "update", "pin", "completions", "completion-candidates"];
1120
1145
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
1121
1146
  maybeNudge();
1122
1147
  }
@@ -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
  };
@@ -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")