@deeeed/metamask-harness 0.47.0 → 0.47.2

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,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.47.2 - 2026-09-05
6
+
7
+ ### Changed
8
+
9
+ - Teach recipe and action discovery, bounded setup probes, source-based diagnosis, and honest blocked-proof reporting through version-matched CLI help.
10
+
11
+ ### Fixed
12
+
13
+ - Flush machine-readable command output before exit so large JSON documents remain complete when piped to another process.
14
+ - Keep `metamask.perps.set_market_favorite` in the current mode while waiting for a temporarily absent favorite control.
15
+ - Preserve the current Mobile Perps mode when `ui.navigate` omits `mode`, including opaque Android navigation.
16
+ - Prefer an exact visible Mobile Perps market row or recently-viewed tile before falling back to search.
17
+ - Preserve Mobile Perps teardown navigation when embedded and fallback bridges use different wording for a missing scrollable target.
18
+
19
+ ## 0.47.1 - 2026-09-04
20
+
21
+ ### Fixed
22
+
23
+ - Open the tutorial on the protected MetaMask GitHub Pages site instead of the public unpkg CDN.
24
+ - Stop shipping the static HTML site in the public npm package; retain only the recipe-help JSON required by the CLI.
25
+
5
26
  ## 0.47.0 - 2026-09-04
6
27
 
7
28
  ### Added
@@ -18,6 +18,10 @@ const BRIDGE_ERROR_CODES = {
18
18
  METRO_UNREACHABLE: 'METRO_UNREACHABLE',
19
19
  };
20
20
 
21
+ const BRIDGE_RESULT_ERROR_CODES = {
22
+ SCROLLABLE_NOT_FOUND: 'SCROLLABLE_NOT_FOUND',
23
+ };
24
+
21
25
  // Process exit code the bridge returns per failure code so a caller that only
22
26
  // sees the child's exit status (no stderr) can still recover the code. Kept
23
27
  // clear of exit 1 (unknown/uncoded) and 2 (usage).
@@ -78,6 +82,15 @@ function classifyBridgeErrorMessage(message) {
78
82
  return null;
79
83
  }
80
84
 
85
+ function classifyBridgeResultError(command, result) {
86
+ if (command !== 'scroll-view' || result?.ok !== false) return null;
87
+ return /^No scrollable(?: found)? near testID=/u.test(
88
+ String(result.error ?? ''),
89
+ )
90
+ ? BRIDGE_RESULT_ERROR_CODES.SCROLLABLE_NOT_FOUND
91
+ : null;
92
+ }
93
+
81
94
  // Attach a code to an error at its throw site (source classification).
82
95
  function coded(error, code) {
83
96
  if (error && typeof error === 'object') error.code = code;
@@ -99,10 +112,12 @@ function parseErrorMarker(text) {
99
112
 
100
113
  module.exports = {
101
114
  BRIDGE_ERROR_CODES,
115
+ BRIDGE_RESULT_ERROR_CODES,
102
116
  EXIT_CODE_BY_ERROR_CODE,
103
117
  ERROR_CODE_BY_EXIT_CODE,
104
118
  TEACHING_BY_ERROR_CODE,
105
119
  classifyBridgeErrorMessage,
120
+ classifyBridgeResultError,
106
121
  coded,
107
122
  formatErrorMarker,
108
123
  parseErrorMarker,
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { parseArgs } from "./parse-args.js";
3
- const PACKAGE_NAME = "@deeeed/metamask-harness";
4
- function recipeTutorialUrl(harnessVersion, runMode = process.env.MM_HARNESS_RUN_MODE) {
3
+ const PROTECTED_TUTORIAL_URL = "https://glowing-chainsaw-gwzrn8k.pages.github.io/recipes.html#agent-method";
4
+ function recipeTutorialUrl(runMode = process.env.MM_HARNESS_RUN_MODE) {
5
5
  if (runMode === "src") {
6
6
  return "http://127.0.0.1:8765/site/recipes.html#agent-method";
7
7
  }
8
- return `https://unpkg.com/${PACKAGE_NAME}@${harnessVersion}/site/recipes.html#agent-method`;
8
+ return PROTECTED_TUTORIAL_URL;
9
9
  }
10
10
  function openUrl(url) {
11
11
  const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
@@ -14,7 +14,7 @@ function openUrl(url) {
14
14
  }
15
15
  function handleTutorial(argv, context) {
16
16
  const parsed = parseArgs(argv, "tutorial");
17
- const url = recipeTutorialUrl(context.harnessVersion);
17
+ const url = recipeTutorialUrl();
18
18
  const noOpen = parsed.options.noOpen === true;
19
19
  const opened = noOpen ? false : (context.open ?? openUrl)(url);
20
20
  const payload = {
@@ -41,12 +41,12 @@ Example:
41
41
  },
42
42
  {
43
43
  name: "tutorial",
44
- summary: "Open the visual recipe tutorial for this harness version.",
44
+ summary: "Open the protected visual recipe tutorial.",
45
45
  example: "mm-harness tutorial",
46
46
  helpText: `mm-harness tutorial [flags]
47
47
 
48
48
  Source checkouts open the local tutorial on port 8765. Installed releases
49
- open the tutorial published with that exact harness version.
49
+ open the protected MetaMask GitHub Pages site.
50
50
 
51
51
  --no-open Print the tutorial URL without opening it
52
52
  --json Print version, URL, and open status as JSON
@@ -830,7 +830,7 @@ for (const command of REAL) {
830
830
  if (command.name === "tutorial") {
831
831
  process.exit(handleTutorial(rawArgv.slice(1), { harnessVersion: pkgVersion }));
832
832
  }
833
- process.exit(await withCommandJournal(command.name, rawArgv, () => delegate(rawArgv)));
833
+ process.exitCode = await withCommandJournal(command.name, rawArgv, () => delegate(rawArgv));
834
834
  });
835
835
  }
836
836
  const HIDDEN = [
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import {
3
+ MOBILE_BRIDGE_RESULT_ERROR_CODES,
3
4
  bridgeCommand,
4
5
  evalAsync,
5
6
  isTargetTransition,
@@ -915,29 +916,50 @@ async function readVisiblePerpsTestIds(input, deadline) {
915
916
  return new Set(visible.items.map((item) => String(item.test_id ?? '')));
916
917
  }
917
918
 
918
- async function waitForPerpsUiTarget(input, testId, deadline) {
919
+ async function waitForPerpsUiTargets(input, testIds, deadline) {
919
920
  let lastTestIds = [];
920
921
  while (Date.now() < deadline) {
921
- const testIds = await readVisiblePerpsTestIds(input, deadline);
922
- if (testIds.has(testId)) return testIds;
923
- lastTestIds = [...testIds].filter(Boolean).slice(0, 20);
922
+ const visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
923
+ const matchedTestId = testIds.find((testId) =>
924
+ visibleTestIds.has(testId),
925
+ );
926
+ if (matchedTestId) return { matchedTestId, visibleTestIds };
927
+ lastTestIds = [...visibleTestIds].filter(Boolean).slice(0, 20);
924
928
  await sleepBeforeDeadline(deadline, 250);
925
929
  }
930
+ const targetDescription =
931
+ testIds.length === 1
932
+ ? `control ${testIds[0]}`
933
+ : `controls ${testIds.join(' or ')}`;
926
934
  throw new Error(
927
- `Perps control ${testId} did not become visible before timeout. Observed: ${lastTestIds.join(', ') || 'none'}.`,
935
+ `Perps ${targetDescription} did not become visible before timeout. Observed: ${lastTestIds.join(', ') || 'none'}.`,
928
936
  );
929
937
  }
930
938
 
939
+ async function waitForPerpsUiTarget(input, testId, deadline) {
940
+ const { visibleTestIds } = await waitForPerpsUiTargets(
941
+ input,
942
+ [testId],
943
+ deadline,
944
+ );
945
+ return visibleTestIds;
946
+ }
947
+
931
948
  async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
932
949
  const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
933
950
  const deadline = deadlineFromTimeout(input, timeoutMs);
934
951
  const params = { market: { symbol } };
935
952
  const rowTestId = `perps-market-row-item-${symbol}`;
953
+ const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
954
+ const marketTestIds = [rowTestId, recentlyViewedTestId];
936
955
  const searchTestId = 'perps-market-list-search-bar';
937
956
  let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
957
+ let marketTestId = marketTestIds.find((testId) =>
958
+ visibleTestIds.has(testId),
959
+ );
938
960
  let usedSearch = false;
939
961
 
940
- if (!visibleTestIds.has(rowTestId)) {
962
+ if (!marketTestId) {
941
963
  if (!visibleTestIds.has(searchTestId)) {
942
964
  if (!visibleTestIds.has('perps-home-search-toggle')) {
943
965
  throw new Error(
@@ -959,17 +981,19 @@ async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
959
981
  searchTestId,
960
982
  symbol,
961
983
  ]);
962
- visibleTestIds = await waitForPerpsUiTarget(
984
+ const visibleMarketTarget = await waitForPerpsUiTargets(
963
985
  input,
964
- rowTestId,
986
+ marketTestIds,
965
987
  deadline,
966
988
  );
989
+ visibleTestIds = visibleMarketTarget.visibleTestIds;
990
+ marketTestId = visibleMarketTarget.matchedTestId;
967
991
  usedSearch = true;
968
992
  }
969
993
 
970
994
  await bridgeCommand(withRemainingDeadline(input, deadline), [
971
995
  'press-test-id',
972
- rowTestId,
996
+ marketTestId,
973
997
  ]);
974
998
  const currentRoute = await waitForRoute(
975
999
  withRemainingDeadline(input, deadline),
@@ -983,7 +1007,7 @@ async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
983
1007
  previousRoute,
984
1008
  currentRoute,
985
1009
  verifiedRoute: 'PerpsMarketDetails',
986
- visibleMarketSelection: { usedSearch },
1010
+ visibleMarketSelection: { usedSearch, testId: marketTestId },
987
1011
  };
988
1012
  }
989
1013
 
@@ -1033,7 +1057,12 @@ async function navigateFromMarketDetailsToHome(input, previousRoute) {
1033
1057
  backTestId = expectedBackTestId;
1034
1058
  break;
1035
1059
  } catch (error) {
1036
- if (!/No scrollable near testID=/u.test(String(error))) throw error;
1060
+ if (
1061
+ error?.code !==
1062
+ MOBILE_BRIDGE_RESULT_ERROR_CODES.SCROLLABLE_NOT_FOUND
1063
+ ) {
1064
+ throw error;
1065
+ }
1037
1066
  }
1038
1067
  }
1039
1068
  }
@@ -1079,7 +1108,7 @@ async function navigateFromMarketDetailsToHome(input, previousRoute) {
1079
1108
  };
1080
1109
  }
1081
1110
 
1082
- async function navigatePerps(input) {
1111
+ export async function navigatePerps(input) {
1083
1112
  const selected = String(
1084
1113
  input.node?.target ?? input.node?.destination ?? defaultNavigationTarget(input.node),
1085
1114
  ).toLowerCase();
@@ -51,17 +51,6 @@ runAdapter(async (input) => {
51
51
  throw new Error(`Visible Perps market ${JSON.stringify(subtitle)} does not match ${market}.`);
52
52
  }
53
53
  let control = favoriteControl(snapshot);
54
- if (!control && nativeNode(snapshot, 'perps-mode-toggle-lite')?.enabled !== false) {
55
- try {
56
- await session.execute('ui.press', {
57
- test_id: 'perps-mode-toggle-lite',
58
- timeout_ms: timeoutMs,
59
- settle: false,
60
- });
61
- } catch (error) {
62
- if (!/Selector did not match/iu.test(String(error?.message ?? error))) throw error;
63
- }
64
- }
65
54
  const controlDeadline = Date.now() + timeoutMs;
66
55
  let lastControls = [];
67
56
  while (Date.now() <= controlDeadline && !control) {
@@ -13,8 +13,10 @@ import { resolveMobileToolPath } from './tool-paths.mjs';
13
13
 
14
14
  const {
15
15
  BRIDGE_ERROR_CODES,
16
+ BRIDGE_RESULT_ERROR_CODES,
16
17
  ERROR_CODE_BY_EXIT_CODE,
17
18
  classifyBridgeErrorMessage,
19
+ classifyBridgeResultError,
18
20
  coded,
19
21
  parseErrorMarker,
20
22
  } = bridgeErrors;
@@ -24,6 +26,7 @@ const { resolvePort } = configModule;
24
26
  // Re-exported so the TS adapter classifies on the same code constants without a
25
27
  // second import path into the cjs bridge-runtime.
26
28
  export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
29
+ export const MOBILE_BRIDGE_RESULT_ERROR_CODES = BRIDGE_RESULT_ERROR_CODES;
27
30
 
28
31
  const execFileAsync = promisify(execFile);
29
32
  const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
@@ -536,9 +539,11 @@ export async function bridgeCommand(input, args) {
536
539
  try {
537
540
  const parsed = JSON.parse(result.stdout);
538
541
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.ok === false) {
539
- throw new Error(
542
+ const error = new Error(
540
543
  `Mobile CDP bridge command reported failure for ${redactBridgeArgs(args).join(' ')}: ${redactBridgeOutput(JSON.stringify(parsed), sensitiveBridgeArgs(args))}`,
541
544
  );
545
+ const code = classifyBridgeResultError(command, parsed);
546
+ throw code ? coded(error, code) : error;
542
547
  }
543
548
  return parsed;
544
549
  } catch (error) {
@@ -73,7 +73,9 @@ export async function navigateOpaqueMobile(input, destination) {
73
73
  artifactsDir: input.context.artifactsDir,
74
74
  env,
75
75
  };
76
- const perpsMode = destination.params?.mode === 'pro' ? 'pro' : 'lite';
76
+ const perpsMode = ['lite', 'pro'].includes(destination.params?.mode)
77
+ ? destination.params.mode
78
+ : undefined;
77
79
  const press = (testId) => transport.execute('ui.press', {
78
80
  test_id: testId,
79
81
  timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
@@ -542,11 +544,16 @@ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeout
542
544
  throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
543
545
  }
544
546
 
545
- async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode = 'lite') {
547
+ async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode) {
546
548
  const deadline = Date.now() + timeoutMs;
547
549
  while (Date.now() <= deadline) {
548
550
  const ids = await identifiers();
549
551
  if (ids.has(IDS.modeLite) || ids.has(IDS.modePro)) {
552
+ if (!perpsMode) {
553
+ throw new Error(
554
+ 'Opaque Mobile navigation requires mode=lite or mode=pro when the product requests a Perps mode choice.',
555
+ );
556
+ }
550
557
  await press(perpsMode === 'pro' ? IDS.modePro : IDS.modeLite);
551
558
  continue;
552
559
  }
@@ -23,8 +23,8 @@ function text(value) {
23
23
  function pageRoute(node) {
24
24
  const page = text(node?.page);
25
25
  if (!page) return undefined;
26
- const mode = text(node?.mode)?.toLowerCase() ?? 'lite';
27
- if (!['lite', 'pro'].includes(mode)) {
26
+ const mode = text(node?.mode)?.toLowerCase();
27
+ if (mode && !['lite', 'pro'].includes(mode)) {
28
28
  throw new Error('mobile ui.navigate mode must be lite or pro.');
29
29
  }
30
30
  if (PAGE_ROUTES[page]) {
@@ -32,13 +32,22 @@ function pageRoute(node) {
32
32
  return {
33
33
  page,
34
34
  ...route,
35
- params: page.startsWith('perps') ? { ...route.params, mode } : route.params,
35
+ params: page.startsWith('perps') && mode
36
+ ? { ...route.params, mode }
37
+ : route.params,
36
38
  };
37
39
  }
38
40
  if (page === 'perps-market') {
39
41
  const market = text(node.market) ?? text(node.symbol) ?? text(node.params?.market?.symbol);
40
42
  if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
41
- return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market }, mode } };
43
+ return {
44
+ page,
45
+ route: 'PerpsMarketDetails',
46
+ params: {
47
+ market: { symbol: market },
48
+ ...(mode ? { mode } : {}),
49
+ },
50
+ };
42
51
  }
43
52
  throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
44
53
  }
@@ -412,8 +412,7 @@
412
412
  "enum": [
413
413
  "lite",
414
414
  "pro"
415
- ],
416
- "default": "lite"
415
+ ]
417
416
  },
418
417
  "url": {
419
418
  "type": "string"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.47.0",
3
+ "version": "0.47.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -64,7 +64,7 @@
64
64
  "dist",
65
65
  "adapters",
66
66
  "library",
67
- "site",
67
+ "site/assets/help-recipes.json",
68
68
  "scripts/completions.sh",
69
69
  "scripts/install-completions.sh",
70
70
  "scripts/site-contrast.mjs",
@@ -26,7 +26,8 @@
26
26
  "instruction": "Search the installed recipe and action catalogs, inspect exact schemas, and select the closest recipe before writing anything new.",
27
27
  "details": [
28
28
  "A bundled recipe, including a Core recipe, is strong composable vocabulary, not authority for every future product version.",
29
- "Prefer an existing recipe, then existing actions, then read-only CDP or controller inspection. Add shared vocabulary only after repeated need is proven."
29
+ "Prefer an existing recipe, then existing actions, then read-only CDP or controller inspection. Add shared vocabulary only after repeated need is proven.",
30
+ "Before declaring a capability unsupported, inspect both catalogs and the closest recipe's composition. A missing dedicated action may already be composed from UI actions. Read the returned schema before filtering fields; do not treat guessed field names as empty capabilities."
30
31
  ],
31
32
  "commands": [
32
33
  "mm-harness actions --json",
@@ -61,6 +62,7 @@
61
62
  "instruction": "Probe each unfamiliar action with call or a tiny action-to-end recipe. Use read-only CDP inspection only when declared actions cannot reveal the needed authoring detail, then consolidate the final proof from manifest-declared actions.",
62
63
  "details": [
63
64
  "Plan after every structural change and preserve the first failing trace.",
65
+ "A plan validates structure only. Probe a reused setup boundary against the current build with a short explicit timeout before a full journey; a bundled journey timeout is not a discovery budget.",
64
66
  "A CDP observation can help find the current route or selector; it is not final evidence unless the recipe records an allowed action and assertion for that signal."
65
67
  ],
66
68
  "commands": [
@@ -84,6 +86,7 @@
84
86
  "instruction": "Run the complete graph once into an explicit artifact directory, then apply the built-in quality bar before assigning coverage or a verdict.",
85
87
  "details": [
86
88
  "Coverage: every criterion needs an executable path and assertion; manual, untestable, and environment-dependent gaps stay explicit.",
89
+ "A blocked required runtime claim is incomplete proof. Passing static checks or closing a checklist cannot make that claim validated or fixed.",
87
90
  "Graph: no unconditional pass, generic intent, hidden start state, or opaque node that collapses preparation, action, assertion, evidence, and teardown.",
88
91
  "Evidence: inspect the recipe resolution, summary, trace, manifest, logs, and actual visual evidence. A filename or passing node is not visual proof.",
89
92
  "Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",
@@ -101,6 +104,8 @@
101
104
  "instruction": "When a shared recipe fails, reproduce once and classify product regression, recipe drift, fixture/runtime drift, harness defect, or live dependency before changing anything.",
102
105
  "details": [
103
106
  "The graph and evidence contract can be deterministic while the product, provider, account, or network outcome is not. Honest external rejection remains a valid result, not a reason to seek a green retry.",
107
+ "Trace the first failure through the exact action source and test the smallest causal explanation before filing or fixing a harness issue. A selector name does not establish its cause. If a proposed fix outgrows the confirmed cause, preserve it separately and return to the minimal fix.",
108
+ "For blocked runtime proof, retain the target identity, build/app state, and first failing command and error. Reconcile later status snapshots with that evidence; they cannot establish that an earlier device was absent.",
104
109
  "For product-version drift, preserve the failed trace, copy the composed recipe into the task, keep working nodes, and repair the smallest owning node against the tested build.",
105
110
  "Prove the changed node in isolation, re-plan, then rerun the full task-local recipe and affected callers. Never weaken an assertion or change product code merely to turn stale proof green.",
106
111
  "Record the source recipe digest, tested product identity, repair, and fresh evidence. If the defect belongs to reusable harness code or bundled vocabulary, prepare a focused experimental-metamask-harness issue and open it when authorized.",