@deeeed/metamask-harness 0.47.4 → 0.48.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 (146) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -2
  3. package/adapters/extension/lib/macos-focus.cjs +2 -2
  4. package/adapters/extension/live.sh +6 -8
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +1 -0
  6. package/adapters/shared/ensure-runner-deps.sh +6 -0
  7. package/dist/adapters/extension/runtime-decision.js +18 -1
  8. package/dist/adapters/extension/runtime.js +5 -5
  9. package/dist/adapters/extension/surface.js +1 -0
  10. package/dist/app-lifecycle.js +3 -1
  11. package/dist/command-contract.js +2 -0
  12. package/dist/commands/call.js +1 -8
  13. package/dist/commands/device-target.js +12 -3
  14. package/dist/commands/doctor.js +7 -5
  15. package/dist/commands/fixtures.js +1 -1
  16. package/dist/commands/launch/index.js +10 -0
  17. package/dist/commands/manifest.js +3 -1
  18. package/dist/commands/mobile-device-view.js +13 -3
  19. package/dist/commands/parse-args.js +2 -0
  20. package/dist/commands/run-engine.js +29 -4
  21. package/dist/commands/run.js +1 -1
  22. package/dist/commands/runtime-launch.js +10 -1
  23. package/dist/commands/status.js +8 -4
  24. package/dist/devices.js +31 -9
  25. package/dist/execution-provenance.js +8 -7
  26. package/dist/funding-execution-context.js +43 -13
  27. package/dist/heal-bounds.js +3 -3
  28. package/dist/live-adapter-contract.js +4 -0
  29. package/dist/manifest.js +2 -13
  30. package/dist/mm-harness-cli.js +2 -1
  31. package/dist/runner.js +14 -3
  32. package/docs/RECIPES.md +133 -22
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +3 -2
  34. package/library/actions/core/perps/_controller.mjs +10 -0
  35. package/library/actions/core/perps/close_orders.mjs +13 -17
  36. package/library/actions/extension/assets/home-token-identity.mjs +30 -0
  37. package/library/actions/extension/assets/import_custom_token.mjs +61 -79
  38. package/library/actions/extension/assets/open_details.mjs +18 -7
  39. package/library/actions/extension/assets/read_details.mjs +61 -29
  40. package/library/actions/extension/assets/read_visible_state.mjs +4 -1
  41. package/library/actions/extension/assets/select_network_scope.mjs +16 -9
  42. package/library/actions/extension/assets/set_token_visibility.mjs +84 -17
  43. package/library/actions/extension/assets/verify_sorting.mjs +62 -14
  44. package/library/actions/extension/networks/add_custom.mjs +19 -17
  45. package/library/actions/extension/networks/custom_network.mjs +8 -3
  46. package/library/actions/extension/networks/read_visible_state.mjs +1 -0
  47. package/library/actions/extension/performance/_navigation-memory.mjs +35 -6
  48. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +3 -2
  49. package/library/actions/extension/performance/measure_detached_dom.mjs +3 -2
  50. package/library/actions/extension/performance/measure_navigation_memory.mjs +3 -2
  51. package/library/actions/extension/perps/assert_visible_consistency.mjs +9 -4
  52. package/library/actions/extension/perps/close_visible_position.mjs +3 -3
  53. package/library/actions/extension/perps/edit_margin.mjs +1 -1
  54. package/library/actions/extension/perps/open_balance_action.mjs +16 -7
  55. package/library/actions/extension/perps/perps.mjs +150 -45
  56. package/library/actions/extension/perps/read_funds_confirmation.mjs +8 -0
  57. package/library/actions/extension/perps/read_snapshot.mjs +2 -1
  58. package/library/actions/extension/perps/read_visible_state.mjs +10 -6
  59. package/library/actions/extension/perps/search_markets.mjs +7 -2
  60. package/library/actions/extension/perps/select_activity_filter.mjs +1 -1
  61. package/library/actions/extension/perps/select_market_filter.mjs +74 -45
  62. package/library/actions/extension/perps/set_market_favorite.mjs +6 -3
  63. package/library/actions/extension/perps/state.mjs +1 -0
  64. package/library/actions/extension/perps/update_position_tpsl.mjs +1 -1
  65. package/library/actions/extension/perps/visible-mutation-identity.mjs +4 -2
  66. package/library/actions/extension/platform/cdp.mjs +23 -142
  67. package/library/actions/extension/swap_bridge/quote-state.mjs +54 -0
  68. package/library/actions/extension/swap_bridge/read_visible_state.mjs +4 -1
  69. package/library/actions/extension/swap_bridge/select_assets.mjs +11 -1
  70. package/library/actions/extension/swap_bridge/set_amount.mjs +10 -8
  71. package/library/actions/extension/swap_bridge/set_max_amount.mjs +17 -18
  72. package/library/actions/extension/swap_bridge/set_slippage.mjs +0 -9
  73. package/library/actions/extension/ui/navigate.mjs +58 -14
  74. package/library/actions/extension/wallet/lock.mjs +2 -1
  75. package/library/actions/mobile/assets/import_custom_token.mjs +1 -0
  76. package/library/actions/mobile/assets/read_visible_state.mjs +3 -1
  77. package/library/actions/mobile/assets/set_token_visibility.mjs +13 -9
  78. package/library/actions/mobile/networks/network-management.mjs +22 -15
  79. package/library/actions/mobile/networks/remove_custom.mjs +6 -2
  80. package/library/actions/mobile/perps/perps.mjs +3 -0
  81. package/library/actions/mobile/perps/read-visible-state-loop.mjs +1 -0
  82. package/library/actions/mobile/perps/set_market_favorite.mjs +15 -19
  83. package/library/actions/mobile/platform/native-session.mjs +26 -17
  84. package/library/actions/mobile/platform/observe-ui.mjs +2 -2
  85. package/library/actions/mobile/ui/navigate.mjs +1 -1
  86. package/library/actions/shared/perps/visible-state.mjs +14 -3
  87. package/library/manifests/core.action-manifest.json +14 -2
  88. package/library/manifests/extension.action-manifest.json +49 -16
  89. package/library/manifests/mobile.action-manifest.json +8 -7
  90. package/library/recipes/core/perps/smoke.recipe.json +23 -0
  91. package/library/recipes/core/perps/snapshot.recipe.json +26 -1
  92. package/library/recipes/extension/assets/release-custom-network-token-persistence.recipe.json +25 -8
  93. package/library/recipes/extension/performance/navigation-memory-suite.recipe.json +219 -0
  94. package/library/recipes/extension/performance/navigation-memory.recipe.json +61 -156
  95. package/library/recipes/extension/wallet/smoke.recipe.json +17 -8
  96. package/library/recipes/mobile/app/lifecycle.android-smoke.recipe.json +23 -4
  97. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +31 -6
  98. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +5 -4
  99. package/library/recipes/mobile/wallet/smoke.recipe.json +17 -8
  100. package/library/recipes/{assets → shared/assets}/release-token-address-import.recipe.json +7 -0
  101. package/library/recipes/{assets → shared/assets}/release-token-details.recipe.json +2 -2
  102. package/library/recipes/shared/assets/release-token-hide-readd.recipe.json +91 -0
  103. package/library/recipes/{assets → shared/assets}/release-token-persistence.recipe.json +3 -0
  104. package/library/recipes/{assets → shared/assets}/release-token-search-import.recipe.json +4 -1
  105. package/library/recipes/{assets → shared/assets}/release-token-sorting.recipe.json +13 -2
  106. package/library/recipes/{swap-bridge → shared/swap-bridge}/release-quote.recipe.json +29 -3
  107. package/package.json +3 -3
  108. package/site/assets/help-recipes.json +63 -23
  109. package/library/recipes/assets/release-token-hide-readd.recipe.json +0 -57
  110. package/library/recipes/core/perps/clean-market-testnet.recipe.json +0 -44
  111. package/library/recipes/core/perps/order-lifecycle.recipe.json +0 -82
  112. package/library/recipes/core/perps/read-markets.recipe.json +0 -36
  113. package/library/recipes/core/perps/trading-lifecycle.recipe.json +0 -80
  114. package/library/recipes/extension/perps/release-activity-filters.recipe.json +0 -51
  115. package/library/recipes/extension/perps/release-funds-flows.recipe.json +0 -76
  116. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +0 -109
  117. package/library/recipes/extension/perps/release-market-filters.recipe.json +0 -97
  118. package/library/recipes/extension/perps/release-order-entry.recipe.json +0 -158
  119. package/library/recipes/extension/perps/release-order-validation.recipe.json +0 -259
  120. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +0 -47
  121. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +0 -76
  122. package/library/recipes/mobile/perps/chase-assert-running.recipe.json +0 -110
  123. package/library/recipes/mobile/perps/chase-place.recipe.json +0 -145
  124. package/library/recipes/mobile/perps/chase-terminate.recipe.json +0 -98
  125. package/library/recipes/mobile/perps/performance.recipe.json +0 -722
  126. package/library/recipes/mobile/perps/pro-order-setup.recipe.json +0 -111
  127. package/library/recipes/mobile/perps/pro-order-start-state.recipe.json +0 -53
  128. package/library/recipes/mobile/perps/scale-assert-orders.recipe.json +0 -126
  129. package/library/recipes/mobile/perps/scale-place.recipe.json +0 -186
  130. package/library/recipes/mobile/perps/twap-assert-active.recipe.json +0 -97
  131. package/library/recipes/mobile/perps/twap-place.recipe.json +0 -146
  132. package/library/recipes/mobile/runner/action-validation.recipe.json +0 -336
  133. package/library/recipes/perps/clean-market-testnet.recipe.json +0 -49
  134. package/library/recipes/perps/lifecycle.recipe.json +0 -144
  135. package/library/recipes/perps/release-live-market-order.recipe.json +0 -139
  136. package/library/recipes/perps/release-watchlist.recipe.json +0 -111
  137. package/library/recipes/swap-bridge/release-max-quote.recipe.json +0 -72
  138. /package/library/recipes/{assets → shared/assets}/release-token-list.recipe.json +0 -0
  139. /package/library/recipes/{perps → shared/perps}/release-market-details.recipe.json +0 -0
  140. /package/library/recipes/{perps → shared/perps}/release-market-search.recipe.json +0 -0
  141. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-custom-slippage.recipe.json +0 -0
  142. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-live-max-swap.recipe.json +0 -0
  143. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-live-swap.recipe.json +0 -0
  144. /package/library/recipes/{swap-bridge → shared/swap-bridge}/release-surface.recipe.json +0 -0
  145. /package/library/recipes/{wallet → shared/wallet}/import.recipe.json +0 -0
  146. /package/library/recipes/{wallet → shared/wallet}/reset-import.recipe.json +0 -0
@@ -1,10 +1,7 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
 
3
3
  import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
4
- import {
5
- normalizeVisiblePerpsState,
6
- resolveVisibleStateOptions,
7
- } from '../../shared/perps/visible-state.mjs';
4
+ import { readVisiblePerpsState } from './read_visible_state.mjs';
8
5
 
9
6
  const FILTERS = new Map([
10
7
  ['all', 'all'],
@@ -34,21 +31,7 @@ async function visibleLabel(page, selector) {
34
31
  })()`);
35
32
  }
36
33
 
37
- export async function selectMarketFilter(page, input, pollOptions = {}) {
38
- const filter = String(input.node?.filter ?? '').trim().toLowerCase();
39
- const option = FILTERS.get(filter);
40
- if (!option) {
41
- throw new Error(`metamask.perps.select_market_filter received unsupported filter ${JSON.stringify(filter)}.`);
42
- }
43
- const options = resolveVisibleStateOptions({
44
- surface: 'market-list',
45
- include_offscreen: true,
46
- max_items: input.node?.max_items ?? 100,
47
- minimum_market_count: input.node?.minimum_market_count ?? 1,
48
- maximum_market_count: input.node?.maximum_market_count ?? 200,
49
- });
50
- const timeoutMs = pollOptions.timeoutMs ?? input.node?.timeout_ms ?? 10_000;
51
- const pollIntervalMs = pollOptions.pollIntervalMs ?? 100;
34
+ async function selectDropdownFilter(page, filter, option, timeoutMs, pollIntervalMs) {
52
35
  await page.click(dataTestId('filter-select-button'));
53
36
  const optionSelector = dataTestId(`filter-select-option-${option}`);
54
37
  try {
@@ -76,37 +59,83 @@ export async function selectMarketFilter(page, input, pollOptions = {}) {
76
59
  }
77
60
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
78
61
  }
79
- const raw = await page.evaluate(`(() => {
80
- const maximum = ${JSON.stringify(options.maxItems)};
81
- const items = [];
82
- let matched = 0;
83
- for (const element of document.querySelectorAll('[data-testid]')) {
84
- const testId = element.getAttribute('data-testid');
85
- if (!testId || !/^(?:market-list-view|market-row-|perps-token-logo-|filter-select-button)/u.test(testId)) continue;
86
- const style = getComputedStyle(element);
87
- const rect = element.getBoundingClientRect();
88
- if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
89
- matched += 1;
90
- if (items.length >= maximum) break;
91
- items.push({
92
- testId,
93
- label: String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
94
- .replace(/\\s+/gu, ' ')
95
- .trim()
96
- .slice(0, 500),
97
- role: element.getAttribute('role') || element.tagName.toLowerCase(),
98
- enabled: !element.disabled && element.getAttribute('aria-disabled') !== 'true',
99
- visible: rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth,
100
- });
62
+ return { selectedLabel: selectedState.label, controlKind: 'dropdown', alreadySelected: false };
63
+ }
64
+
65
+ async function categoryRailState(page) {
66
+ return page.evaluate(`(() => ({
67
+ active: [...document.querySelectorAll('[data-testid^="market-list-categories-pill-"][aria-pressed="true"]')].map((element) => ({
68
+ id: element.getAttribute('data-testid'),
69
+ option: element.getAttribute('data-testid').slice('market-list-categories-pill-'.length),
70
+ label: String(element.innerText || '').trim(),
71
+ })),
72
+ watchlist: document.querySelector('[data-testid="market-list-watchlist-toggle"]')?.getAttribute('aria-pressed') === 'true',
73
+ }))()`);
74
+ }
75
+
76
+ async function selectCategoryRail(page, option, timeoutMs, pollIntervalMs) {
77
+ const deadline = Date.now() + timeoutMs;
78
+ const matches = (state) => !state.watchlist && (option === 'all'
79
+ ? state.active.length === 0
80
+ : state.active.length === 1 && state.active[0].option === option);
81
+ const initial = await categoryRailState(page);
82
+ const alreadySelected = matches(initial);
83
+ if (!alreadySelected) {
84
+ if (option === 'all') {
85
+ if (initial.watchlist) await page.click(dataTestId('market-list-watchlist-toggle'));
86
+ for (const selected of initial.active) await page.click(dataTestId(selected.id));
87
+ } else {
88
+ let selector = dataTestId(`market-list-categories-pill-${option}`);
89
+ if (!(await visibleLabel(page, selector)).visible) {
90
+ const more = dataTestId('market-list-categories-more-button');
91
+ selector = dataTestId(`market-list-categories-more-option-${option}`);
92
+ if (!(await visibleLabel(page, selector)).visible) {
93
+ if (!(await visibleLabel(page, more)).visible) {
94
+ throw new Error(`Perps market filter ${JSON.stringify(option)} is not available in this release.`);
95
+ }
96
+ await page.click(more);
97
+ }
98
+ await page.waitForSelector(selector, { timeoutMs: Math.max(1, deadline - Date.now()) });
99
+ }
100
+ await page.click(selector);
101
101
  }
102
- return { route: location.hash, title: document.title, items, offscreenItems: [], truncated: matched > items.length };
103
- })()`);
102
+ }
103
+ let selected = await categoryRailState(page);
104
+ while (!matches(selected)) {
105
+ if (Date.now() >= deadline) {
106
+ throw new Error(`Perps category ${JSON.stringify(option)} did not become selected: ${JSON.stringify(selected)}.`);
107
+ }
108
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
109
+ selected = await categoryRailState(page);
110
+ }
111
+ return { selectedLabel: selected.active[0]?.label ?? null, controlKind: 'category-rail', alreadySelected };
112
+ }
113
+
114
+ export async function selectMarketFilter(page, input, pollOptions = {}) {
115
+ const filter = String(input.node?.filter ?? '').trim().toLowerCase();
116
+ const option = FILTERS.get(filter);
117
+ if (!option) {
118
+ throw new Error(`metamask.perps.select_market_filter received unsupported filter ${JSON.stringify(filter)}.`);
119
+ }
120
+ const timeoutMs = pollOptions.timeoutMs ?? input.node?.timeout_ms ?? 10_000;
121
+ const pollIntervalMs = pollOptions.pollIntervalMs ?? 100;
122
+ const rail = await visibleLabel(page, dataTestId('market-list-categories'));
123
+ const selection = rail.visible
124
+ ? await selectCategoryRail(page, option, timeoutMs, pollIntervalMs)
125
+ : await selectDropdownFilter(page, filter, option, timeoutMs, pollIntervalMs);
126
+ const state = await readVisiblePerpsState(page, {
127
+ surface: 'market-list',
128
+ include_offscreen: true,
129
+ max_items: input.node?.max_items ?? 100,
130
+ minimum_market_count: input.node?.minimum_market_count ?? 1,
131
+ maximum_market_count: input.node?.maximum_market_count ?? 200,
132
+ });
104
133
  return {
105
134
  action: input.action,
106
135
  filter,
107
136
  option,
108
- selectedLabel: selectedState.label,
109
- ...normalizeVisiblePerpsState(raw, options, 'extension'),
137
+ ...selection,
138
+ ...state,
110
139
  proofPath: 'trusted-pointer-visible-ui',
111
140
  };
112
141
  }
@@ -20,9 +20,15 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
20
20
  const button = document.querySelector(${JSON.stringify(selector)});
21
21
  if (!button) throw new Error('Perps favorite control is not visible.');
22
22
  const label = String(button.getAttribute('aria-label') || button.innerText || '').trim();
23
+ if (!/^(?:add to|remove from) favorites$/iu.test(label)) {
24
+ throw new Error('Perps favorite control has an unsupported label: ' + JSON.stringify(label));
25
+ }
23
26
  return { label, favorite: /^remove from favorites$/iu.test(label) };
24
27
  })()`);
25
28
  const before = await readState();
29
+ if (requireUnchanged && before.favorite !== desired) {
30
+ throw new Error(`Perps favorite state for ${market} did not persist across navigation; no change was made.`);
31
+ }
26
32
  if (before.favorite !== desired) {
27
33
  await page.click(selector);
28
34
  }
@@ -37,9 +43,6 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
37
43
  throw new Error(`Perps favorite state did not become ${desired}.`);
38
44
  }
39
45
  const changed = before.favorite !== after.favorite;
40
- if (requireUnchanged && changed) {
41
- throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
42
- }
43
46
  return {
44
47
  action: input.action,
45
48
  market,
@@ -11,6 +11,7 @@ export function readPerpsRuntimeStateExpression() {
11
11
  return {
12
12
  available: typeof request === 'function',
13
13
  activeProvider: metamask.activeProvider || nestedPerps.activeProvider || 'hyperliquid',
14
+ providerKnown: typeof (metamask.activeProvider ?? nestedPerps.activeProvider) === 'string',
14
15
  networkKnown: typeof metamask.isTestnet === 'boolean' || typeof nestedPerps.isTestnet === 'boolean',
15
16
  isTestnet: Boolean(metamask.isTestnet ?? nestedPerps.isTestnet),
16
17
  initializationState: metamask.initializationState || nestedPerps.initializationState || null,
@@ -229,7 +229,6 @@ export async function updatePositionTpsl(input) {
229
229
  let dispatchTarget;
230
230
  try {
231
231
  await requireCurrentVisibleFundingBalance(input, page, timeoutMs, fundingBalance.observedBaseUnits);
232
- await requirePerpsTradingContext(input, page);
233
232
  assertTrustedExternalMutationTarget(input, page, identity);
234
233
  const current = await readPosition(page, market);
235
234
  if (current.size !== before.size || current.direction !== before.direction
@@ -246,6 +245,7 @@ export async function updatePositionTpsl(input) {
246
245
  const rect = element.getBoundingClientRect();
247
246
  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
248
247
  })()`);
248
+ await requirePerpsTradingContext(input, page);
249
249
  assertTrustedExternalMutationTarget(input, page, identity);
250
250
  } catch (error) {
251
251
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
@@ -129,10 +129,12 @@ export async function waitForStableGlobalPerpsHome(page, options = {}) {
129
129
  return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
130
130
  };
131
131
  const visibleCount = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
132
- const balance = document.querySelector('[data-testid="perps-balance-dropdown-balance"]');
132
+ const balance = document.querySelector('[data-testid="perps-balance-actions-available-value"]')
133
+ ?? document.querySelector('[data-testid="perps-balance-dropdown-balance"]');
133
134
  return {
134
135
  route: String(location.hash),
135
- viewVisible: visible(document.querySelector('[data-testid="perps-view"]')),
136
+ viewVisible: visible(document.querySelector('[data-testid="perps-home-page"]')
137
+ ?? document.querySelector('[data-testid="perps-view"]')),
136
138
  loading: visibleCount('[data-testid$="-skeleton"], [data-testid*="loading"]') > 0,
137
139
  marketCount: visibleCount('[data-testid="perps-explore-markets-row"]'),
138
140
  positionCount: visibleCount('[data-testid^="position-card-"]:not([data-testid^="position-card-roe-"])'),
@@ -240,32 +240,31 @@ async function captureHelperBrowserPid(context, port) {
240
240
  );
241
241
  }
242
242
 
243
- async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
243
+ async function captureCdpSnapshot(page, context, relPath, metadata, captureHelperError = null) {
244
244
  const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
245
245
  const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
246
246
  let result;
247
247
  try {
248
- const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
249
- result = await Promise.race([
250
- page.session.call('Page.captureScreenshot', {
251
- format: 'png',
252
- fromSurface: true,
253
- captureBeyondViewport: false,
254
- }),
255
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Chrome Page.captureScreenshot timed out after ${timeoutMs}ms.`)), timeoutMs)),
256
- ]);
257
- if (typeof result?.data !== 'string' || result.data.length === 0) {
248
+ const timeoutMs = Number(metadata?.cdpTimeoutMs ?? metadata?.timeoutMs ?? 5000);
249
+ result = await CdpWebPage.prototype.screenshot.call(page, undefined, undefined, {
250
+ ...metadata,
251
+ timeoutMs,
252
+ });
253
+ if (typeof result !== 'string' || result.length === 0) {
258
254
  throw new Error('Chrome Page.captureScreenshot returned no image data.');
259
255
  }
260
256
  } catch (error) {
261
257
  await cleanupEvidenceStaging(destination.stagingDir);
262
258
  const cdpError = error instanceof Error ? error.message : String(error);
263
- return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
259
+ throw new Error(
260
+ `Extension screenshot capture failed: ${captureHelperError ? 'native=' + captureHelperError + '; ' : ''}CDP=${cdpError}.\n` +
261
+ 'Next: mm-harness doctor --adapter extension --json',
262
+ );
264
263
  }
265
264
  try {
266
265
  await writeFile(
267
266
  destination.stagedPath,
268
- Buffer.from(result.data, 'base64'),
267
+ Buffer.from(result, 'base64'),
269
268
  { flag: 'wx', mode: 0o600 },
270
269
  );
271
270
  } catch (error) {
@@ -283,89 +282,15 @@ async function captureCdpViewportSnapshot(page, context, relPath, metadata, capt
283
282
  metadata: {
284
283
  provider: 'cdp',
285
284
  mode: 'Page.captureScreenshot',
285
+ fullPage: metadata?.fullPage === true,
286
286
  ...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
287
287
  },
288
288
  };
289
289
  }
290
290
 
291
- async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
292
- const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
293
- const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
294
- const dataUrlPromise = page.evaluate(`(async () => {
295
- const width = Math.max(1, window.innerWidth);
296
- const height = Math.max(1, window.innerHeight);
297
- const source = document.documentElement;
298
- const clone = source.cloneNode(true);
299
- const sourceNodes = [source, ...source.querySelectorAll('*')];
300
- const cloneNodes = [clone, ...clone.querySelectorAll('*')];
301
- for (let index = 0; index < sourceNodes.length; index += 1) {
302
- const sourceNode = sourceNodes[index];
303
- const cloneNode = cloneNodes[index];
304
- if (!(sourceNode instanceof Element) || !(cloneNode instanceof Element)) continue;
305
- const computed = getComputedStyle(sourceNode);
306
- cloneNode.setAttribute('style', Array.from(computed).map((name) => name + ':' + computed.getPropertyValue(name) + ';').join(''));
307
- if ('value' in sourceNode && typeof sourceNode.value === 'string') cloneNode.setAttribute('value', sourceNode.value);
308
- if (sourceNode instanceof HTMLCanvasElement) {
309
- const image = document.createElement('img');
310
- try { image.src = sourceNode.toDataURL('image/png'); } catch { image.alt = 'canvas'; }
311
- image.setAttribute('style', cloneNode.getAttribute('style') || '');
312
- cloneNode.replaceWith(image);
313
- }
314
- }
315
- clone.querySelectorAll('script').forEach((node) => node.remove());
316
- clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
317
- clone.style.width = width + 'px';
318
- clone.style.height = height + 'px';
319
- clone.style.overflow = 'hidden';
320
- const serialized = new XMLSerializer().serializeToString(clone);
321
- const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '"><foreignObject width="100%" height="100%">' + serialized + '</foreignObject></svg>';
322
- const image = new Image();
323
- const loaded = new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error('DOM raster image load failed.')); });
324
- image.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
325
- await loaded;
326
- const canvas = document.createElement('canvas');
327
- canvas.width = width;
328
- canvas.height = height;
329
- const context = canvas.getContext('2d');
330
- context.drawImage(image, 0, 0, width, height);
331
- return canvas.toDataURL('image/png');
332
- })()`);
333
- let dataUrl;
334
- try {
335
- dataUrl = await dataUrlPromise;
336
- if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
337
- throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
338
- }
339
- await writeFile(
340
- destination.stagedPath,
341
- Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'),
342
- { flag: 'wx', mode: 0o600 },
343
- );
344
- } catch (error) {
345
- await cleanupEvidenceStaging(destination.stagingDir);
346
- throw error;
347
- }
348
- await publishPrivateEvidence(destination);
349
- return {
350
- path: relative,
351
- type: 'screenshot',
352
- nodeId: context.nodeId,
353
- label: metadata?.label ?? `${context.nodeId} screenshot`,
354
- category: metadata?.category ?? 'evidence',
355
- mimeType: 'image/png',
356
- metadata: {
357
- provider: 'dom-raster',
358
- mode: 'computed-style-viewport',
359
- fallbackFrom: captureHelperError ? 'capture-helper+cdp' : 'cdp',
360
- ...(captureHelperError ? { captureHelperError } : {}),
361
- cdpError,
362
- },
363
- };
364
- }
365
-
366
291
  export async function captureHelperSnapshot(page, context, relPath, metadata) {
367
- if (process.platform !== 'darwin') {
368
- return captureCdpViewportSnapshot(page, context, relPath, metadata);
292
+ if (metadata?.fullPage === true || process.platform !== 'darwin') {
293
+ return captureCdpSnapshot(page, context, relPath, metadata);
369
294
  }
370
295
  const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
371
296
  const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
@@ -388,7 +313,7 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
388
313
  parsePositivePid(process.env.METAMASK_RECIPE_EXTENSION_ACTIVE_RECORDING_PID) === pid
389
314
  ) {
390
315
  await cleanupEvidenceStaging(destination.stagingDir);
391
- return captureCdpViewportSnapshot(
316
+ return captureCdpSnapshot(
392
317
  page,
393
318
  context,
394
319
  relPath,
@@ -414,7 +339,7 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
414
339
  } catch (error) {
415
340
  await cleanupEvidenceStaging(destination.stagingDir);
416
341
  const message = error instanceof Error ? error.message : String(error);
417
- return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
342
+ return captureCdpSnapshot(page, context, relPath, metadata, message);
418
343
  }
419
344
  await publishPrivateEvidence(destination);
420
345
  return {
@@ -811,47 +736,6 @@ export class ExtensionPage extends CdpWebPage {
811
736
  });
812
737
  }
813
738
 
814
- async click(selector) {
815
- const target = await this.evaluate(`(() => {
816
- const selector = ${JSON.stringify(selector)};
817
- const element = document.querySelector(selector);
818
- if (!element) throw new Error('Clickable element not found: ' + selector);
819
- element.scrollIntoView({ block: 'center', inline: 'center' });
820
- const rect = element.getBoundingClientRect();
821
- const style = getComputedStyle(element);
822
- if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) {
823
- throw new Error('Clickable element is not visible: ' + selector);
824
- }
825
- return {
826
- x: rect.left + (rect.width / 2),
827
- y: rect.top + (rect.height / 2),
828
- tagName: element.tagName,
829
- };
830
- })()`);
831
- await this.session.call('Input.dispatchMouseEvent', {
832
- type: 'mouseMoved',
833
- x: target.x,
834
- y: target.y,
835
- });
836
- await this.session.call('Input.dispatchMouseEvent', {
837
- type: 'mousePressed',
838
- x: target.x,
839
- y: target.y,
840
- button: 'left',
841
- buttons: 1,
842
- clickCount: 1,
843
- });
844
- await this.session.call('Input.dispatchMouseEvent', {
845
- type: 'mouseReleased',
846
- x: target.x,
847
- y: target.y,
848
- button: 'left',
849
- buttons: 0,
850
- clickCount: 1,
851
- });
852
- return { clicked: true, selector, tagName: target.tagName };
853
- }
854
-
855
739
  async setInput(selector, value) {
856
740
  const input = String(value ?? '');
857
741
  const target = await this.evaluate(`(() => {
@@ -947,22 +831,19 @@ export class ExtensionPage extends CdpWebPage {
947
831
  }
948
832
 
949
833
  async screenshot(contextOrInput, relPath, metadata = {}) {
950
- if (contextOrInput?.context) {
951
- const options = {
834
+ const context = contextOrInput?.context ?? contextOrInput;
835
+ const options = contextOrInput?.context
836
+ ? {
952
837
  label: contextOrInput.node?.description || `${contextOrInput.action} screenshot`,
953
838
  category: 'evidence',
954
839
  timeoutMs: contextOrInput.node?.timeout_ms,
955
840
  ...metadata,
956
- };
957
- if (contextOrInput.node?.screenshot_mode === 'dom_raster' || process.env.METAMASK_RECIPE_EXTENSION_SCREENSHOT_MODE === 'dom-raster') {
958
- throw new Error('Extension ui.screenshot no longer supports DOM raster screenshots. Use capture-helper snapshot evidence.');
959
841
  }
960
- return captureHelperSnapshot(this, contextOrInput.context, relPath, options);
961
- }
962
- if (process.env.METAMASK_RECIPE_EXTENSION_SCREENSHOT_MODE === 'dom-raster') {
842
+ : metadata;
843
+ if (contextOrInput?.node?.screenshot_mode === 'dom_raster' || process.env.METAMASK_RECIPE_EXTENSION_SCREENSHOT_MODE === 'dom-raster') {
963
844
  throw new Error('Extension screenshots no longer support DOM raster mode. Use capture-helper snapshot evidence.');
964
845
  }
965
- return captureHelperSnapshot(this, contextOrInput, relPath, metadata);
846
+ return captureHelperSnapshot(this, context, relPath, options);
966
847
  }
967
848
  }
968
849
 
@@ -0,0 +1,54 @@
1
+ export async function readQuoteInputIdentity(page) {
2
+ return page.evaluate(`(() => {
3
+ const state = globalThis.stateHooks?.store?.getState?.();
4
+ if (!state?.bridge || !state?.metamask) {
5
+ throw new Error('Swap quote identity requires exposed bridge quote state on this build; UI amounts alone cannot bind a quote to its request.');
6
+ }
7
+ const value = (id) => {
8
+ const root = document.querySelector('[data-testid="' + id + '"]');
9
+ return String((root?.matches('input,textarea') ? root : root?.querySelector('input,textarea'))?.value ?? '').trim();
10
+ };
11
+ const amount = value('from-amount');
12
+ const destinationAmount = value('to-amount');
13
+ const from = state.bridge.fromToken, to = state.bridge.toToken;
14
+ const decimals = from?.decimals;
15
+ if (!from?.assetId || !to?.assetId || !Number.isInteger(decimals) || decimals < 0 || decimals > 36 || !/^\\d+(?:\\.\\d*)?$/u.test(amount)) {
16
+ return { status: 'unavailable', reason: 'Exact token-unit input and asset identities are required.' };
17
+ }
18
+ const [integer, fraction = ''] = amount.split('.');
19
+ if (fraction.length > decimals) return { status: 'unavailable', reason: 'Input exceeds token precision.' };
20
+ const sourceBaseUnits = BigInt(integer + fraction.padEnd(decimals, '0')).toString();
21
+ const request = state.metamask.quoteRequest?.[0];
22
+ const quotes = state.metamask.quotes ?? [];
23
+ const sameAsset = (left, right) => left === right || (
24
+ /^eip155:/u.test(String(left)) && String(left).toLowerCase() === String(right).toLowerCase()
25
+ );
26
+ const matches = quotes.length > 0 && quotes.every(({ quote }) =>
27
+ quote?.requestId && quote.src?.amount === sourceBaseUnits &&
28
+ sameAsset(quote.src?.asset?.assetId, from.assetId) &&
29
+ sameAsset(quote.dest?.asset?.assetId, to.assetId)
30
+ );
31
+ const destinationMatches = /^\\d+(?:\\.\\d*)?$/u.test(destinationAmount) && quotes.some(({ quote }) => {
32
+ const precision = quote?.dest?.asset?.decimals;
33
+ if (!Number.isInteger(precision) || precision < 0 || precision > 36 || !/^\\d+$/u.test(String(quote?.dest?.amount))) return false;
34
+ const [whole, part = ''] = destinationAmount.split('.');
35
+ if (part.length > precision) return false;
36
+ const displayed = BigInt(whole + part.padEnd(precision, '0'));
37
+ const quoted = BigInt(quote.dest.amount);
38
+ const difference = displayed > quoted ? displayed - quoted : quoted - displayed;
39
+ const displayUnit = 10n ** BigInt(precision - part.length);
40
+ return displayed > 0n && difference * 2n <= displayUnit;
41
+ });
42
+ return {
43
+ status: state.metamask.quotesLoadingStatus === 0 ? 'loading'
44
+ : request?.srcTokenAmount === sourceBaseUnits && matches && destinationMatches ? 'matching' : 'stale',
45
+ sourceAmount: amount,
46
+ destinationAmount,
47
+ destinationMatches,
48
+ sourceBaseUnits,
49
+ sourceAssetId: from.assetId,
50
+ destinationAssetId: to.assetId,
51
+ quoteCount: quotes.length,
52
+ };
53
+ })()`);
54
+ }
@@ -1,4 +1,5 @@
1
1
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { readQuoteInputIdentity } from './quote-state.mjs';
2
3
  import {
3
4
  normalizeVisibleSwapBridgeState,
4
5
  resolveVisibleSwapBridgeOptions,
@@ -54,7 +55,8 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
54
55
  })()`);
55
56
  const state = normalizeVisibleSwapBridgeState(raw, options, 'extension');
56
57
  const requireQuote = input.node?.require_quote === true;
57
- if (requireQuote && (!raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
58
+ const quoteIdentity = requireQuote ? await readQuoteInputIdentity(page) : undefined;
59
+ if (requireQuote && (quoteIdentity.status !== 'matching' || quoteIdentity.sourceAmount !== state.sourceAmount || quoteIdentity.destinationAmount !== state.destinationAmount || !raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
58
60
  || !/^(?:Swap|Bridge)$/iu.test(String(raw.quote.cta)))) {
59
61
  throw new Error('Swap quote is not ready for review.');
60
62
  }
@@ -62,6 +64,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
62
64
  action: input.action,
63
65
  ...state,
64
66
  quote: raw.quote,
67
+ ...(quoteIdentity ? { quoteIdentity } : {}),
65
68
  proofPath: 'visible-dom-accessibility',
66
69
  };
67
70
  }));
@@ -57,7 +57,17 @@ async function selectAsset(page, role, asset, deadline) {
57
57
  if (!(await isVisible(page, search))) {
58
58
  throw new Error(`Swap ${role} asset picker did not open.`);
59
59
  }
60
- await page.setInput(search, asset.query);
60
+ const existing = await page.evaluate(`(() => {
61
+ const prefix = ${JSON.stringify(`bridge-asset--${asset.chain}/`)};
62
+ const exactId = ${JSON.stringify(asset.assetId ? `bridge-asset--${asset.assetId}` : null)};
63
+ return [...document.querySelectorAll('[data-testid^="bridge-asset--"]')]
64
+ .filter((row) => exactId
65
+ ? row.getAttribute('data-testid') === exactId
66
+ : String(row.getAttribute('data-testid') || '').startsWith(prefix)
67
+ && String(row.querySelector('[data-testid="bridge-asset-symbol"]')?.textContent || '').trim().toUpperCase() === ${JSON.stringify(asset.symbol)})
68
+ .map((row) => row.getAttribute('data-testid'));
69
+ })()`);
70
+ if (existing.length === 0) await page.setInput(search, asset.query);
61
71
  const testId = await findAsset(page, asset, deadline);
62
72
  await page.click(dataTestId(testId));
63
73
  while (Date.now() < deadline) {
@@ -1,5 +1,6 @@
1
1
  import { runAdapter, withExtensionPage, dataTestId } from '../platform/cdp.mjs';
2
2
  import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+ import { readQuoteInputIdentity } from './quote-state.mjs';
3
4
 
4
5
  runAdapter(async (input) => withExtensionPage(input, async (page) => {
5
6
  const options = resolveAmountOptions(input.node);
@@ -7,7 +8,6 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
7
8
  await page.setInput(source, options.amount);
8
9
  const deadline = Date.now() + options.timeoutMs;
9
10
  let observed;
10
- let lastRefreshAt = 0;
11
11
  do {
12
12
  observed = await page.evaluate(`(() => {
13
13
  const value = (testId) => {
@@ -24,22 +24,24 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
24
24
  noQuote: document.body.innerText.includes("This trade route isn't available right now"),
25
25
  };
26
26
  })()`);
27
+ if (options.waitForQuote) {
28
+ observed.quoteIdentity = await readQuoteInputIdentity(page);
29
+ if (observed.quoteIdentity.sourceAmount !== observed.sourceAmount || observed.quoteIdentity.destinationAmount !== observed.destinationAmount) observed.quoteIdentity.status = 'stale';
30
+ }
27
31
  if (!options.waitForQuote || observed.noQuote) break;
28
- if (/^(?:Get new quote|Refresh quote)$/iu.test(String(observed.cta))
29
- && observed.ctaEnabled && Date.now() - lastRefreshAt >= 1000) {
30
- await page.click(dataTestId('bridge-cta-button'));
31
- lastRefreshAt = Date.now();
32
+ if (observed.quoteIdentity.status === 'matching' && !observed.ctaEnabled && /^Insufficient funds$/iu.test(String(observed.cta))) {
33
+ throw new Error(`Swap quote matches the input but cannot be submitted: ${JSON.stringify(observed)}.`);
32
34
  }
33
- if (Number(observed.destinationAmount) > 0 && observed.ctaEnabled
35
+ if (observed.quoteIdentity?.status === 'matching' && Number(observed.destinationAmount) > 0 && observed.ctaEnabled
34
36
  && /^(?:Swap|Bridge)$/iu.test(String(observed.cta))) break;
35
37
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
36
38
  } while (Date.now() < deadline);
37
39
  if (observed.sourceAmount !== options.amount) {
38
40
  throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
39
41
  }
40
- if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
42
+ if (options.waitForQuote && !observed.noQuote && !(observed.quoteIdentity?.status === 'matching' && Number(observed.destinationAmount) > 0
41
43
  && observed.ctaEnabled && /^(?:Swap|Bridge)$/iu.test(String(observed.cta)))) {
42
- throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
44
+ throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms: ${JSON.stringify(observed)}.\nNext: mm-harness call metamask.swap_bridge.read_visible_state`);
43
45
  }
44
46
  return { action: input.action, ...observed, proofPath: 'visible-ui-amount' };
45
47
  }));