@deeeed/metamask-harness 0.50.2 → 0.50.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.50.4 - 2026-09-10
6
+
7
+ - Distinguish Extension startup/provider absence from attempted RPC failures and preserve diagnosed failed tabs during verification.
8
+ - Bind Mobile native Perps observations to a stable development navigation route when available, and include route/screen context in surface errors.
9
+ - Add an interactive workflow map and sourced template previews to the human tutorial, connecting tasks, skills, checklists, recipes, evidence, and optional Farmslot coordination.
10
+
11
+ ## 0.50.3 - 2026-09-09
12
+
13
+ - Recognize enabled form controls through recipe-harness 0.16.1; report Extension startup crashes without reload-based RPC probes or automatic relaunch recovery.
14
+ - Color human recipe help while keeping JSON plain; clarify recipe composition, Extension timing capture, acceptance criteria, and team-library updates.
15
+ - Make architecture topics expandable and correct cheatsheet platform, recording, and recipe examples.
16
+
5
17
  ## 0.50.2 - 2026-09-08
6
18
 
7
19
  - Remove Mobile receipt actions that require unpublished app/SDK APIs; reject stale receipt-based assertion and cleanup inputs before execution.
package/README.md CHANGED
@@ -158,6 +158,10 @@ mm-harness run --list
158
158
  Shared libraries hold durable actions and composable recipes. Task acceptance
159
159
  criteria remain task-local. See [Recipes](docs/RECIPES.md).
160
160
 
161
+ Teams maintain their library checkouts. Update them before starting a task;
162
+ the harness records local revisions and recipe digests but does not fetch or
163
+ check for upstream updates. Keep the selected library unchanged during a run.
164
+
161
165
  ## Recover
162
166
 
163
167
  ```bash
@@ -148,7 +148,7 @@ async function ensureExtensionReady(target, options) {
148
148
  }
149
149
  };
150
150
  let health = await checkHealth();
151
- if (action === "pruned" && (health.status !== "PASS" || after !== 1)) {
151
+ if (action === "pruned" && !health.errorCode && (health.status !== "PASS" || after !== 1)) {
152
152
  const listing = await jsonList(cdpPort);
153
153
  if (listing.ok) {
154
154
  const existingHomeIds = new Set(
@@ -441,6 +441,16 @@ async function decideExtensionReadiness(target, options = {}) {
441
441
  reasons: ["Build is fresh and the extension runtime is healthy over CDP."],
442
442
  actions: []
443
443
  },
444
+ {
445
+ when: cdp.errorCode === "EXTENSION_BACKGROUND_UNRESPONSIVE" || cdp.errorCode === "EXTENSION_STARTUP_UNVERIFIED",
446
+ decision: "blocked",
447
+ reasonCode: "background-unresponsive",
448
+ reasons: [
449
+ "Extension startup failed; inspect the background error before relaunching or resetting wallet state.",
450
+ ...cdp.findings?.slice(0, 3) ?? []
451
+ ],
452
+ actions: []
453
+ },
444
454
  {
445
455
  when: cdp.errorCode === "EVM_RPC_EXECUTION_FAILED",
446
456
  decision: "blocked",
@@ -492,6 +502,13 @@ async function decideExtensionReadiness(target, options = {}) {
492
502
  reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact and healthy over CDP.`],
493
503
  actions: []
494
504
  },
505
+ {
506
+ when: cdp.errorCode === "EXTENSION_BACKGROUND_UNRESPONSIVE" || cdp.errorCode === "EXTENSION_STARTUP_UNVERIFIED",
507
+ decision: "blocked",
508
+ reasonCode: "background-unresponsive",
509
+ reasons: ["Extension startup failed; inspect the background error before relaunching or resetting wallet state.", ...cdp.findings?.slice(0, 3) ?? []],
510
+ actions: []
511
+ },
495
512
  {
496
513
  when: cdp.errorCode === "EVM_RPC_EXECUTION_FAILED",
497
514
  decision: "blocked",
@@ -51,7 +51,7 @@ async function assertHealthyExtensionRuntime(options) {
51
51
  pageMode: options.pageMode
52
52
  });
53
53
  if (lastReport.status === "PASS") return lastReport;
54
- if (lastReport.errorCode === "UI_COMPOSITOR_SUSPENDED") {
54
+ if (lastReport.errorCode === "UI_COMPOSITOR_SUSPENDED" || lastReport.errorCode === "EXTENSION_BACKGROUND_UNRESPONSIVE") {
55
55
  throw new Error(formatHealthFailure(lastReport, options.projectRoot));
56
56
  }
57
57
  await sleep(500);
@@ -151,26 +151,43 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
151
151
  };
152
152
  }
153
153
  const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
154
+ if (runtime.backgroundUnresponsive === true || runtime.hasPageContent === false) {
155
+ const code = runtime.backgroundUnresponsive === true ? "EXTENSION_BACKGROUND_UNRESPONSIVE" : "EXTENSION_STARTUP_UNVERIFIED";
156
+ return {
157
+ status: "FAIL",
158
+ errorCode: code,
159
+ userAction: `mm-harness logs --target ${shellQuote(projectRoot)} --full # inspect the startup error before restarting or resetting an authorized dev fixture`,
160
+ cdpPort,
161
+ targetUrl: target.url,
162
+ extensionId: safeExtensionId(target),
163
+ extensionPageTargets: extensionTargets.length,
164
+ findings: [`${code}: ${runtime.backgroundUnresponsive === true ? "Extension UI reports a startup failure or unresponsive background" : "Extension page has not rendered its UI"}; RPC readiness cannot be established.`],
165
+ details: {
166
+ projectRoot,
167
+ targetUrls: targets.map((entry) => entry.url).filter(Boolean),
168
+ runtime
169
+ }
170
+ };
171
+ }
154
172
  if (runtime.hasSubmitRequest !== true) {
155
173
  const providerProbe = await probeUiEthereumProvider(session, cdpCallTimeoutMs);
156
174
  runtime.evmRpcProbeOk = providerProbe.ok;
157
175
  runtime.evmRpcProbeError = providerProbe.error;
158
176
  runtime.evmRpcProbeSource = providerProbe.source;
177
+ runtime.evmRpcProbeAttempted = providerProbe.requestAttempted;
159
178
  }
160
179
  if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
161
180
  findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
162
181
  }
163
- if (runtime.backgroundUnresponsive === true) {
164
- findings.push("Extension UI reports background connection unresponsive.");
165
- }
166
182
  if (runtime.ethereumConnectionUnavailable === true) {
167
183
  findings.push('Extension UI reports "Unable to connect to Ethereum".');
168
184
  }
169
185
  const rpcExecutionFailed = runtime.evmRpcProbeOk !== true && /Failed to execute 'fetch' on 'WorkerGlobalScope': Illegal invocation/u.test(String(runtime.evmRpcProbeError));
186
+ const startupUnverified = runtime.evmRpcProbeOk !== true && runtime.evmRpcProbeAttempted === false;
170
187
  if (runtime.evmRpcProbeOk !== true) {
171
188
  const probeError = String(runtime.evmRpcProbeError ?? "unknown error").replace(/[.\s]+$/u, "");
172
189
  findings.push(
173
- rpcExecutionFailed ? `EVM_RPC_EXECUTION_FAILED: background RPC fetch threw before an HTTP response: ${probeError}.` : `EVM RPC readiness probe failed: ${probeError}. Infura configured \u2260 RPC reachable.`
190
+ startupUnverified ? `EXTENSION_STARTUP_UNVERIFIED: no completed provider probe established RPC readiness: ${probeError}.` : rpcExecutionFailed ? `EVM_RPC_EXECUTION_FAILED: background RPC fetch threw before an HTTP response: ${probeError}.` : `EVM RPC readiness probe failed: ${probeError}. Infura configured \u2260 RPC reachable.`
174
191
  );
175
192
  }
176
193
  if (runtime.hasSubmitRequest === true && runtime.backgroundProbeOk !== true) {
@@ -181,8 +198,8 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
181
198
  return {
182
199
  status: findings.length === 0 ? "PASS" : "FAIL",
183
200
  ...evmRpcUnreachable ? {
184
- errorCode: rpcExecutionFailed ? "EVM_RPC_EXECUTION_FAILED" : "EVM_RPC_UNREACHABLE",
185
- userAction: rpcExecutionFailed ? `mm-harness logs --target ${shellQuote(projectRoot)} --full # inspect the background fetch stack; credential resync is not indicated by this error` : evmRpcRecoveryAction(projectRoot)
201
+ errorCode: startupUnverified ? "EXTENSION_STARTUP_UNVERIFIED" : rpcExecutionFailed ? "EVM_RPC_EXECUTION_FAILED" : "EVM_RPC_UNREACHABLE",
202
+ userAction: startupUnverified ? `mm-harness logs --target ${shellQuote(projectRoot)} --full # inspect Extension startup; upstream RPC failure has not been established` : rpcExecutionFailed ? `mm-harness logs --target ${shellQuote(projectRoot)} --full # inspect the background fetch stack; credential resync is not indicated by this error` : evmRpcRecoveryAction(projectRoot)
186
203
  } : {},
187
204
  warnings,
188
205
  cdpPort,
@@ -318,6 +335,7 @@ async function evaluateHealth(session, timeoutMs) {
318
335
  return {
319
336
  href: location.href,
320
337
  title: document.title,
338
+ hasPageContent: bodyText.trim().length > 0,
321
339
  hookKeys: Object.keys(hooks),
322
340
  hasSubmitRequest: typeof hooks.submitRequestToBackground === 'function',
323
341
  hasStore: Boolean(hooks.store),
@@ -339,6 +357,7 @@ async function evaluateHealth(session, timeoutMs) {
339
357
  new Promise((resolve) => setTimeout(() => resolve({
340
358
  href: location.href,
341
359
  title: document.title,
360
+ hasPageContent: bodyText.trim().length > 0,
342
361
  hookKeys: Object.keys(hooks),
343
362
  hasSubmitRequest: typeof hooks.submitRequestToBackground === 'function',
344
363
  hasStore: Boolean(hooks.store),
@@ -381,7 +400,7 @@ async function probeUiEthereumProvider(session, timeoutMs) {
381
400
  value: {
382
401
  async probe() {
383
402
  if (!provider || typeof provider.request !== 'function') {
384
- return { ok: false, error: 'Ethereum provider is unavailable' };
403
+ return { ok: false, error: 'Ethereum provider is unavailable', requestAttempted: false };
385
404
  }
386
405
  try {
387
406
  const code = await provider.request({
@@ -391,10 +410,11 @@ async function probeUiEthereumProvider(session, timeoutMs) {
391
410
  const ok = typeof code === 'string' && /^0x[0-9a-f]*$/iu.test(code);
392
411
  return {
393
412
  ok,
413
+ requestAttempted: true,
394
414
  error: ok ? null : 'Ethereum provider returned invalid contract code',
395
415
  };
396
416
  } catch (error) {
397
- return { ok: false, error: String(error?.message || error) };
417
+ return { ok: false, error: String(error?.message || error), requestAttempted: true };
398
418
  }
399
419
  },
400
420
  cleanup() {
@@ -418,13 +438,15 @@ async function probeUiEthereumProvider(session, timeoutMs) {
418
438
  return {
419
439
  ok: false,
420
440
  error: "Ethereum provider preload was not installed",
421
- source: "ui-ethereum-provider"
441
+ source: "ui-ethereum-provider",
442
+ requestAttempted: false
422
443
  };
423
444
  }
424
445
  let outcome = {
425
446
  ok: false,
426
447
  error: "Ethereum provider is unavailable",
427
- source: "ui-ethereum-provider"
448
+ source: "ui-ethereum-provider",
449
+ requestAttempted: false
428
450
  };
429
451
  const cleanupErrors = [];
430
452
  try {
@@ -439,10 +461,11 @@ async function probeUiEthereumProvider(session, timeoutMs) {
439
461
  }, Math.max(1, deadline - Date.now()));
440
462
  const value = result.result?.value;
441
463
  if (value?.ok === true) {
442
- outcome = { ok: true, error: null, source: "ui-ethereum-provider" };
464
+ outcome = { ok: true, error: null, source: "ui-ethereum-provider", requestAttempted: true };
443
465
  break;
444
466
  }
445
467
  if (typeof value?.error === "string") outcome.error = value.error;
468
+ outcome.requestAttempted ||= value?.requestAttempted === true;
446
469
  } catch (error) {
447
470
  outcome.error = messageOf(error);
448
471
  }
@@ -450,7 +473,7 @@ async function probeUiEthereumProvider(session, timeoutMs) {
450
473
  await sleep(100);
451
474
  }
452
475
  } catch (error) {
453
- outcome = { ok: false, error: messageOf(error), source: "ui-ethereum-provider" };
476
+ outcome = { ...outcome, ok: false, error: messageOf(error) };
454
477
  } finally {
455
478
  try {
456
479
  const cleanup = await boundedCdpCall(session, "Runtime.evaluate", {
@@ -480,7 +503,8 @@ async function probeUiEthereumProvider(session, timeoutMs) {
480
503
  return cleanupErrors.length > 0 ? {
481
504
  ok: false,
482
505
  error: `Ethereum provider probe cleanup failed: ${cleanupErrors.join("; ")}`,
483
- source: "ui-ethereum-provider"
506
+ source: "ui-ethereum-provider",
507
+ requestAttempted: outcome.requestAttempted
484
508
  } : outcome;
485
509
  }
486
510
  function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { color } from "../cli-color.js";
3
4
  import { detectAdapter } from "../harness.js";
4
5
  import { optionString, parseArgs, targetPath } from "./parse-args.js";
5
6
  const UNSCOPED_NEXT = "cd into a MetaMask checkout or pass --adapter <mobile|extension|core>";
@@ -48,21 +49,22 @@ function recipeHelpPayload(topic, harnessVersion, adapter, target) {
48
49
  };
49
50
  }
50
51
  function renderRecipeHelp(topic, harnessVersion, adapter) {
52
+ const paint = (style, text) => color(style, text, { stream: process.stdout });
51
53
  const lines = [
52
- `mm-harness ${harnessVersion} recipe guide`,
53
- `Context: Recipe Protocol v${topic.recipeProtocolVersion}, adapter ${adapter}`,
54
+ paint("bold", `mm-harness ${harnessVersion} recipe guide`),
55
+ paint("dim", `Context: Recipe Protocol v${topic.recipeProtocolVersion}, adapter ${adapter}`),
54
56
  topic.summary,
55
57
  topic.capabilities,
56
- topic.scopeNotice,
57
- "Visual tutorial: mm-harness tutorial"
58
+ paint("warn", topic.scopeNotice),
59
+ `Visual tutorial: ${paint("cmd", "mm-harness tutorial")}`
58
60
  ];
59
- if (adapter === "unscoped") lines.push(`No MetaMask checkout detected. Next: ${UNSCOPED_NEXT}.`);
61
+ if (adapter === "unscoped") lines.push(paint("warn", `No MetaMask checkout detected. Next: ${UNSCOPED_NEXT}.`));
60
62
  for (const [index, section] of scopedSections(topic, adapter).entries()) {
61
- lines.push("", `${index + 1}. ${section.title}`, ` ${section.instruction}`);
63
+ lines.push("", paint("label", `${index + 1}. ${section.title}`), ` ${section.instruction}`);
62
64
  for (const detail of section.details) lines.push(` - ${detail}`);
63
- for (const command of section.commands) lines.push(` $ ${command}`);
65
+ for (const command of section.commands) lines.push(` $ ${paint("cmd", command)}`);
64
66
  }
65
- lines.push("", `Safety: ${topic.safety}`);
67
+ lines.push("", `${paint("warn", "Safety:")} ${topic.safety}`);
66
68
  return `${lines.join("\n")}
67
69
  `;
68
70
  }
@@ -310,6 +310,16 @@ async function handleLaunchLocked(argv, stream) {
310
310
  exitCode: EXIT.infra
311
311
  });
312
312
  }
313
+ if (adapter === "extension" && (attempt.output.includes("EXTENSION_BACKGROUND_UNRESPONSIVE:") || attempt.output.includes("EXTENSION_STARTUP_UNVERIFIED:"))) {
314
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
315
+ code: attempt.output.includes("EXTENSION_BACKGROUND_UNRESPONSIVE:") ? "EXTENSION_BACKGROUND_UNRESPONSIVE" : "EXTENSION_STARTUP_UNVERIFIED",
316
+ message: "Extension startup is not healthy; inspect the background error before restarting or resetting wallet state.",
317
+ recoverable: false,
318
+ userAction: `mm-harness logs --target ${shellQuote(target)} --full`,
319
+ exitCode: EXIT.runtime,
320
+ originalError: attempt.output.trim() || void 0
321
+ });
322
+ }
313
323
  if (adapter === "extension" && attempt.output.includes("EVM_RPC_EXECUTION_FAILED:")) {
314
324
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
315
325
  code: "EVM_RPC_EXECUTION_FAILED",
package/docs/QA.md CHANGED
@@ -56,8 +56,8 @@ mm-harness verify
56
56
  - [ ] Bad input fails before runtime work with a stable code and one exact next
57
57
  action.
58
58
  - [ ] `doctor --fix` is idempotent and does not choose a fixture or launch.
59
- - [ ] With `capture-helper` absent, Extension reports CDP screenshots and Mobile
60
- reports `simctl`/ADB screenshots; video is honestly unavailable.
59
+ - [ ] With `capture-helper` absent, Extension reports CDP screenshots and no
60
+ Extension video; Mobile reports its independent simctl/ADB capture readiness.
61
61
 
62
62
  Run the bounded multi-platform preflight when all three checkouts are available:
63
63
 
@@ -177,8 +177,8 @@ Before release:
177
177
  Core scenarios.
178
178
  - [ ] Independent review approves the exact final diff.
179
179
  - [ ] Known limits are explicit: Extension requires product Infura setup;
180
- Mobile requires its normal dev-client/device setup; video requires optional
181
- `capture-helper`; the harness never invents funded fixtures.
180
+ Mobile requires its normal dev-client/device setup; Extension video requires
181
+ `capture-helper`, while Mobile uses platform recorders; the harness never invents funded fixtures.
182
182
  - [ ] Existing [Farmslot](https://farmslot.io) slots remain compatible.
183
183
 
184
184
  When a slot manager is available, repeat `runner.smoke` in one existing managed
package/docs/RECIPES.md CHANGED
@@ -96,6 +96,23 @@ detached-DOM check, and idle-control comparison. A one-screen slope does not
96
96
  prove those broader claims. Check the suite's reference markers against the
97
97
  tested build before running it.
98
98
 
99
+ ### Extension timing observation
100
+
101
+ First inspect `app.performance_capture` and `app.network_capture`. When the
102
+ `recipe-performance` skill is installed, its
103
+ `scripts/capture-extension-window.mjs --help` describes capture around a single
104
+ command. Run that wrapper outside the recipe; a command node cannot start a
105
+ second `mm-harness run/call` while its parent owns the checkout sandbox.
106
+
107
+ For a custom observer blocked by LavaMoat, register a pre-boot script with
108
+ `Page.addScriptToEvaluateOnNewDocument` to retain only the required native API
109
+ reference, such as a bound `requestAnimationFrame`. Initialize it during explicit
110
+ setup, remove the preload after use, and clean up the observer afterward. Do not
111
+ disable scuttling, pause the debugger, or reload inside the measured window.
112
+ Record lifecycle identity and use the application's clock for UI timing; CLI
113
+ duration is a different measurement. Keep endpoint and readiness predicates in
114
+ the team recipe, not a new harness action.
115
+
99
116
  ### Extension Perps release recipes
100
117
 
101
118
  Discover these identifiers with `run --list --adapter extension --json` and
@@ -189,6 +206,10 @@ export RECIPE_LIBRARY_PATH="perps=/path/to/experimental-metamask-recipe-perps"
189
206
 
190
207
  `perps.pro-order-setup` preserves existing orders, positions, and strategies by
191
208
  default. Its Scale, Chase, and TWAP placement callers inherit that policy.
209
+ All three placement helpers require explicit `account` and `notional` inputs.
210
+ Scale also requires `start_price` and `end_price`; Chase requires `max_distance`.
211
+ Choose values for the current market and approved budget. Preserve JSON quotes
212
+ for string parameters, for example `'notional="15"'`; inspect `--describe` first.
192
213
  When a proof requires a clean testnet market, reserve the account/market and
193
214
  compose explicit setup with `trading_state=clean_selected_testnet_market`.
194
215
  This parameter never authorizes cleanup of another task's trades.
@@ -43,7 +43,7 @@ export async function readVisiblePerpsState(
43
43
  action: input.action,
44
44
  ...normalizeVisiblePerpsState({
45
45
  route: screen.route,
46
- title: screen.title,
46
+ title: screen.name,
47
47
  items: visible.items,
48
48
  offscreenItems: visible.hidden_or_offscreen,
49
49
  truncated: visible.truncated,
@@ -27,7 +27,24 @@ export async function observeNativeUi(payload, context) {
27
27
  if (supported.length === 0) return warnings.length ? { warnings } : {};
28
28
 
29
29
  try {
30
+ const env = { ...process.env, ...record(context?.env) };
31
+ const routeInput = { action: 'ui.observe', node: { ...record(payload?.node), bridge_timeout_ms: 2_000, cdp_timeout_ms: 2_000 }, context };
32
+ let routeBefore;
33
+ if (supported.includes('ui.screen') && !isOpaqueRuntime(env) && (env.METRO_PORT || env.WATCHER_PORT)) {
34
+ try {
35
+ routeBefore = await bridgeCommand(routeInput, ['get-route']);
36
+ } catch (error) {
37
+ warnings.push({ ref: 'ui.screen', message: `Navigation route unavailable: ${error.message}` });
38
+ }
39
+ }
30
40
  const hierarchy = await readNativeHierarchy(payload, context);
41
+ if (routeBefore?.name) {
42
+ const routeAfter = await bridgeCommand(routeInput, ['get-route']);
43
+ if (routeAfter?.name !== routeBefore.name || routeAfter?.key !== routeBefore.key) {
44
+ throw new Error('Navigation changed during native UI observation; the snapshot cannot be bound to one route.');
45
+ }
46
+ hierarchy.screen.route = routeAfter.name;
47
+ }
31
48
  const observations = {};
32
49
  for (const ref of supported) {
33
50
  observations[ref] = ref === 'ui.screen' ? hierarchy.screen : hierarchy.visible;
@@ -144,7 +144,15 @@ function marketFromTestId(testId) {
144
144
  return undefined;
145
145
  }
146
146
 
147
- function classifySurface(route, testIds) {
147
+ function classifySurface(route, testIds, platform) {
148
+ if (platform === 'mobile') {
149
+ const routes = {
150
+ PerpsMarketListView: 'home',
151
+ PerpsTrendingView: 'market-list',
152
+ PerpsMarketDetails: 'market-details',
153
+ };
154
+ if (Object.hasOwn(routes, route)) return routes[route];
155
+ }
148
156
  const joined = [...testIds].join('\n');
149
157
  if (/confirm-transaction\/.*[?&]goBackTo=%2Fperps-home(?:&|$)/u.test(route)) {
150
158
  return 'funds';
@@ -213,7 +221,7 @@ export function normalizeVisiblePerpsState(raw, options, platform) {
213
221
  ? /^remove from /iu.test(favoriteLabel) : null;
214
222
  const testIds = new Set(unique.map((item) => item.testId));
215
223
  const route = text(raw.route) ?? '';
216
- const surface = classifySurface(route, testIds);
224
+ const surface = classifySurface(route, testIds, platform);
217
225
  const marketItems = selected(
218
226
  unique,
219
227
  (item) => Boolean(marketFromTestId(item.testId)),
@@ -316,7 +324,7 @@ function assertVisibleState(state, options) {
316
324
  }
317
325
  if (options.surface !== 'auto' && state.surface !== options.surface) {
318
326
  throw new Error(
319
- `Expected visible Perps surface ${options.surface}, but observed ${state.surface}.`,
327
+ `Expected visible Perps surface ${options.surface}, but observed ${state.surface} (route: ${state.route ?? 'unavailable'}; title: ${state.title ?? 'unavailable'}).`,
320
328
  );
321
329
  }
322
330
  if (state.markets.length < options.minimumMarketCount) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.50.2",
3
+ "version": "0.50.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -24,7 +24,7 @@
24
24
  "@farmslot/expo-recipe": "0.9.0",
25
25
  "@farmslot/handoff": "^0.3.1",
26
26
  "@farmslot/protocol": "0.21.0",
27
- "@farmslot/recipe-harness": "0.16.0",
27
+ "@farmslot/recipe-harness": "0.16.1",
28
28
  "agent-device": "0.19.3",
29
29
  "commander": "^12.0.0",
30
30
  "es-module-lexer": "2.3.1",
@@ -94,6 +94,7 @@
94
94
  "instruction": "Use static call references for unchanged sub-journeys. Separate preparation, action, assertions, and evidence; add workflow.teardown when cleanup must run after success or failure.",
95
95
  "details": [
96
96
  "Starting conditions are ordinary nodes at the beginning of workflow, not a preconditions field. Use idempotent ensure actions and independently assert their postconditions.",
97
+ "Do not invoke mm-harness run/call from a command node on the same checkout: the outer run owns its sandbox. Compose actions or call child recipes instead. External measurement wrappers may invoke one root run, with preparation outside it; never bypass the lock.",
97
98
  "Inspect existing state before creating or cleaning fixtures. Establish only prerequisites required by the claim; a history or display check may already have usable records and need no trade or cleanup. Record the selected records and verify they still satisfy the proof boundary.",
98
99
  "Match history-query ranges to the dates of existing visible records before declaring fixtures missing. Sample only sections needed for change detection; fetch one-time corroborating history once, and count background calls as well as graph nodes.",
99
100
  "Actions translate UI, CDP, or controller operations. Recipes compose them. Product code owns business rules. Teams own specialized journeys in their libraries; the harness retains shared capabilities and a small set of composable examples.",
@@ -188,6 +189,10 @@
188
189
  ]
189
190
  },
190
191
  "Performance: define a measured window, exclude setup, and compare equivalent builds, devices, and lifecycle conditions; action duration alone is not app latency.",
192
+ {
193
+ "text": "For Extension timing, inspect app.performance_capture/app.network_capture and the installed recipe-performance capture-extension-window.mjs helper before writing a collector. If LavaMoat blocks an observation API, capture its reference before initialization via Page.addScriptToEvaluateOnNewDocument during explicit setup, not inside the measured window. Keep domain predicates in the team recipe; see docs/RECIPES.md#extension-timing-observation.",
194
+ "adapters": ["extension"]
195
+ },
191
196
  "Retain samples and declared limits when a measurement fails. For memory growth, compare an equal-duration idle control before attributing it to navigation; do not raise the budget to make the run pass.",
192
197
  "Analytics: verify the collection destination and existing consent before capture. Changing participation or marketing consent requires operator approval and restoration of the prior settings; a recipe's hardcoded opt-in is not permission. Missing collection prerequisites are not missing product events.",
193
198
  "Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",