@deeeed/metamask-harness 0.38.1 → 0.40.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 (104) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/adapters/extension/lib/playwright-cdp.cjs +17 -1
  3. package/adapters/extension/sidepanel-toggle.sh +135 -13
  4. package/dist/adapters/mobile/release-artifact.js +65 -0
  5. package/dist/adapters.js +27 -17
  6. package/dist/command-contract.js +10 -2
  7. package/dist/commands/call.js +7 -4
  8. package/dist/commands/launch/index.js +55 -3
  9. package/dist/commands/parse-args.js +6 -0
  10. package/dist/commands/run-engine.js +75 -39
  11. package/dist/funding-execution-context.js +1386 -0
  12. package/dist/live-adapter-contract.js +3 -1
  13. package/dist/metamask-action-validation.js +170 -3
  14. package/dist/mm-harness-cli.js +8 -2
  15. package/dist/recipe-security.js +5 -12
  16. package/dist/runner.js +102 -6
  17. package/docs/RECIPES.md +74 -1
  18. package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
  19. package/library/actions/extension/assets/open_details.mjs +30 -0
  20. package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
  21. package/library/actions/extension/networks/add_chainlist.mjs +4 -0
  22. package/library/actions/extension/networks/add_custom.mjs +4 -0
  23. package/library/actions/extension/networks/custom_network.mjs +48 -1
  24. package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
  25. package/library/actions/extension/perps/close_visible_position.mjs +299 -37
  26. package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
  27. package/library/actions/extension/perps/edit_margin.mjs +285 -44
  28. package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
  29. package/library/actions/extension/perps/perps.mjs +1029 -363
  30. package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
  31. package/library/actions/extension/perps/read_snapshot.mjs +124 -3
  32. package/library/actions/extension/perps/read_visible_state.mjs +63 -5
  33. package/library/actions/extension/perps/search_markets.mjs +32 -0
  34. package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
  35. package/library/actions/extension/perps/select_market_filter.mjs +48 -4
  36. package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
  37. package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
  38. package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
  39. package/library/actions/extension/platform/cdp.mjs +88 -9
  40. package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
  41. package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
  42. package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
  43. package/library/actions/extension/ui/navigate.mjs +45 -5
  44. package/library/actions/extension/wallet/import.mjs +70 -29
  45. package/library/actions/extension/wallet/lock.mjs +62 -5
  46. package/library/actions/extension/wallet/visible-session.mjs +218 -0
  47. package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
  48. package/library/actions/mobile/assets/open_details.mjs +43 -0
  49. package/library/actions/mobile/assets/read_details.mjs +96 -0
  50. package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
  51. package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
  52. package/library/actions/mobile/networks/add_custom.mjs +125 -0
  53. package/library/actions/mobile/networks/network-management.mjs +132 -0
  54. package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
  55. package/library/actions/mobile/networks/remove_custom.mjs +100 -0
  56. package/library/actions/mobile/perps/search_markets.mjs +46 -0
  57. package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
  58. package/library/actions/mobile/platform/native-session-name.mjs +14 -0
  59. package/library/actions/mobile/platform/native-session.mjs +59 -0
  60. package/library/actions/mobile/platform/observe-ui.mjs +9 -1
  61. package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
  62. package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
  63. package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
  64. package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
  65. package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
  66. package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
  67. package/library/actions/mobile/ui/native-navigation.mjs +120 -23
  68. package/library/actions/mobile/ui/navigate.mjs +13 -2
  69. package/library/actions/mobile/wallet/import.mjs +17 -10
  70. package/library/actions/mobile/wallet/lock.mjs +14 -0
  71. package/library/actions/mobile/wallet/native-ui.mjs +88 -15
  72. package/library/actions/shared/perps/visible-state.mjs +33 -11
  73. package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
  74. package/library/manifests/extension.action-manifest.json +1707 -1241
  75. package/library/manifests/mobile.action-manifest.json +308 -1
  76. package/library/recipes/assets/release-token-details.recipe.json +5 -4
  77. package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
  78. package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
  79. package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
  80. package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
  81. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
  82. package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
  83. package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
  84. package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
  85. package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
  86. package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -0
  87. package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
  88. package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
  89. package/library/recipes/perps/release-live-market-order.recipe.json +30 -13
  90. package/library/recipes/perps/release-market-details.recipe.json +1 -1
  91. package/library/recipes/perps/release-market-search.recipe.json +8 -4
  92. package/library/recipes/perps/release-watchlist.recipe.json +31 -19
  93. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
  94. package/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
  95. package/library/recipes/wallet/import.recipe.json +4 -14
  96. package/package.json +1 -1
  97. package/library/actions/extension/assets/finish_send.mjs +0 -128
  98. package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
  99. package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
  100. package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
  101. package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
  102. package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
  103. package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
  104. package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
@@ -59,12 +59,13 @@ function cdpPort(input) {
59
59
  return port;
60
60
  }
61
61
 
62
- export function extensionTarget(targets, extensionId = null) {
62
+ export function extensionTarget(targets, extensionId = null, cdpTargetId = null) {
63
63
  const candidates = (Array.isArray(targets) ? targets : []).filter((target) => {
64
64
  if (target?.type !== 'page') return false;
65
65
  if (!String(target?.url ?? '').startsWith('chrome-extension://')) return false;
66
66
  if (!String(target.url).includes('/home.html') && !String(target.url).includes('/sidepanel.html')) return false;
67
67
  if (extensionId && extensionIdFromTarget(target) !== extensionId) return false;
68
+ if (cdpTargetId && target.id !== cdpTargetId) return false;
68
69
  return Boolean(target.webSocketDebuggerUrl);
69
70
  });
70
71
  const home = candidates.find((target) =>
@@ -663,15 +664,24 @@ async function openExtensionHomePage(port, extensionId) {
663
664
 
664
665
  async function ensureExtensionTarget(input, port) {
665
666
  let launch = null;
667
+ const trustedTargetId = input.context?.trustedMutation?.cdpTargetId ?? null;
668
+ if (trustedTargetId !== null && !/^[A-Za-z0-9._:-]{1,128}$/u.test(String(trustedTargetId))) {
669
+ throw new Error('Funded Extension action has no valid trusted CDP target id.');
670
+ }
666
671
  try {
667
672
  const targets = await jsonGet(`http://127.0.0.1:${port}/json/list`);
668
673
  const expectedExtensionId = await extensionIdFromFile(input.context.projectRoot);
669
674
  const target =
670
- (expectedExtensionId ? extensionTarget(Array.isArray(targets) ? targets : [], expectedExtensionId) : null) ??
671
- extensionTarget(Array.isArray(targets) ? targets : []);
675
+ (expectedExtensionId
676
+ ? extensionTarget(Array.isArray(targets) ? targets : [], expectedExtensionId, trustedTargetId)
677
+ : null) ??
678
+ extensionTarget(Array.isArray(targets) ? targets : [], null, trustedTargetId);
672
679
  if (target?.webSocketDebuggerUrl) return { target, launch };
680
+ if (trustedTargetId) {
681
+ throw new Error(`Trusted Extension CDP target ${trustedTargetId} is unavailable on port ${port}.`);
682
+ }
673
683
  } catch (error) {
674
- if (!autolaunchEnabled(input)) throw error;
684
+ if (trustedTargetId || !autolaunchEnabled(input)) throw error;
675
685
  }
676
686
  if (!autolaunchEnabled(input)) {
677
687
  throw new Error(`No extension page target found on CDP port ${port}. Launch the extension runtime first, or set METAMASK_RECIPE_EXTENSION_AUTOLAUNCH=1 to launch an existing dist/chrome.`);
@@ -707,29 +717,98 @@ export async function writeOutput(input, output) {
707
717
  await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
708
718
  }
709
719
 
720
+ export function extensionActionOutput(result, { port, target, launch, browserVersion }) {
721
+ const identity = extensionRuntimeIdentity({ port, target, browserVersion });
722
+ return {
723
+ ...result,
724
+ cdpPort: identity.cdpPort,
725
+ targetUrl: target.url,
726
+ runtimeLaunch: launch,
727
+ cdpTargetId: identity.cdpTargetId,
728
+ runtimeSessionId: identity.runtimeSessionId,
729
+ };
730
+ }
731
+
732
+ export function extensionRuntimeIdentity({ port, target, browserVersion }) {
733
+ const cdpTargetId = String(target?.id ?? '');
734
+ if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(cdpTargetId)) {
735
+ throw new Error('Selected Extension CDP target has no valid stable target id.');
736
+ }
737
+ let runtimeSessionId = '';
738
+ try {
739
+ const browserSocket = new URL(String(browserVersion?.webSocketDebuggerUrl ?? ''));
740
+ if (!['ws:', 'wss:'].includes(browserSocket.protocol)) throw new Error('invalid protocol');
741
+ const match = /^\/devtools\/browser\/([A-Za-z0-9._:-]{1,128})$/u.exec(browserSocket.pathname);
742
+ runtimeSessionId = match?.[1] ?? '';
743
+ } catch {
744
+ runtimeSessionId = '';
745
+ }
746
+ if (!runtimeSessionId) {
747
+ throw new Error('Extension browser CDP endpoint has no valid stable runtime session id.');
748
+ }
749
+ return {
750
+ cdpPort: port,
751
+ cdpTargetId,
752
+ runtimeSessionId,
753
+ };
754
+ }
755
+
710
756
  export async function withExtensionPage(input, callback) {
711
757
  const port = cdpPort(input);
712
758
  const { target, launch } = await ensureExtensionTarget(input, port);
759
+ const browserVersion = await jsonGet(`http://127.0.0.1:${port}/json/version`);
713
760
  const session = await CdpSession.connect(target.webSocketDebuggerUrl);
714
761
  try {
715
762
  const extensionId = extensionIdFromTarget(target);
716
763
  const origin = `chrome-extension://${extensionId}`;
717
- const page = new ExtensionPage(session, origin, target, port);
718
- await session.call('Runtime.enable');
719
- await session.call('Page.enable');
764
+ const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
765
+ const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
766
+ const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
767
+ if (recoverVisibleWallet) {
768
+ try {
769
+ await session.call('Runtime.enable', {}, { timeoutMs: 1000 });
770
+ } catch {
771
+ let route = '<unavailable>';
772
+ try {
773
+ await session.call('Page.enable', {}, { timeoutMs: 1000 });
774
+ const history = await session.call('Page.getNavigationHistory', {}, { timeoutMs: 1000 });
775
+ const currentUrl = new URL(String(history.entries?.[history.currentIndex]?.url ?? ''));
776
+ route = currentUrl.hash.split('?')[0]
777
+ .replace(/0x[0-9a-f]{40}/giu, '<redacted-address>')
778
+ .slice(0, 200);
779
+ if (currentUrl.origin !== origin || !['/home.html', '/sidepanel.html'].includes(currentUrl.pathname)) {
780
+ throw new Error('unexpected visible wallet target');
781
+ }
782
+ const navigation = await session.call('Page.navigate', { url: `${origin}${currentUrl.pathname}#/` }, { timeoutMs: 1000 });
783
+ if (navigation.errorText) throw new Error('visible wallet Home reload failed');
784
+ await sleep(100);
785
+ await session.call('Runtime.enable', {}, { timeoutMs: 5000 });
786
+ } catch {
787
+ throw new Error(`Visible wallet Runtime did not recover after the same-target Home reload (route ${JSON.stringify(route)}).`);
788
+ }
789
+ }
790
+ await session.call('Page.enable', {}, { timeoutMs: 1000 });
791
+ } else {
792
+ await session.call('Runtime.enable');
793
+ await session.call('Page.enable');
794
+ }
720
795
  const result = await callback(page);
721
- return { ...result, cdpPort: port, targetUrl: target.url, runtimeLaunch: launch };
796
+ return extensionActionOutput(result, { port, target, launch, browserVersion });
722
797
  } finally {
723
798
  session.close();
724
799
  }
725
800
  }
726
801
 
727
802
  export class ExtensionPage extends CdpWebPage {
728
- constructor(session, origin, target, port) {
803
+ constructor(session, origin, target, port, runtimeIdentity) {
729
804
  super(session);
730
805
  this.origin = origin;
731
806
  this.target = target;
732
807
  this.port = port;
808
+ this.runtimeIdentity = Object.freeze({
809
+ ...runtimeIdentity,
810
+ extensionOrigin: origin,
811
+ });
733
812
  }
734
813
 
735
814
  async click(selector) {
@@ -27,11 +27,24 @@ async function waitForState(page, enabled, timeoutMs = 15_000) {
27
27
  throw new Error(`Basic Functionality did not become ${enabled ? 'enabled' : 'disabled'}.`);
28
28
  }
29
29
 
30
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
31
- if (typeof input.node?.enabled !== 'boolean') {
32
- throw new Error('metamask.settings.set_basic_functionality requires enabled=true or enabled=false.');
30
+ async function waitForEnabledControl(page, selector, timeoutMs = 15_000) {
31
+ const deadline = Date.now() + timeoutMs;
32
+ while (Date.now() <= deadline) {
33
+ const enabled = await page.evaluate(`(() => {
34
+ const element = document.querySelector(${JSON.stringify(selector)});
35
+ if (!element) return false;
36
+ const style = getComputedStyle(element);
37
+ const rect = element.getBoundingClientRect();
38
+ return !element.disabled && element.getAttribute('aria-disabled') !== 'true' &&
39
+ style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
40
+ })()`);
41
+ if (enabled) return;
42
+ await new Promise((resolve) => setTimeout(resolve, 100));
33
43
  }
34
- const requested = input.node.enabled;
44
+ throw new Error(`Basic Functionality confirmation did not become enabled: ${selector}.`);
45
+ }
46
+
47
+ export async function setBasicFunctionality(page, requested, timeoutMs = 15_000) {
35
48
  await page.navigateHash('#/settings/privacy');
36
49
  await page.waitForSelector(TOGGLE_LABEL_SELECTOR);
37
50
  const before = await readState(page);
@@ -42,17 +55,32 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
42
55
  if (!requested) {
43
56
  await page.waitForSelector(dataTestId('basic-configuration-checkbox'));
44
57
  await page.click(dataTestId('basic-configuration-checkbox'));
45
- await page.click(dataTestId('basic-configuration-modal-toggle-button'));
58
+ const confirm = dataTestId('basic-configuration-modal-toggle-button');
59
+ await waitForEnabledControl(page, confirm, timeoutMs);
60
+ await page.click(confirm);
46
61
  }
47
62
  }
48
63
 
49
- const after = await waitForState(page, requested);
64
+ const after = await waitForState(page, requested, timeoutMs);
50
65
  return {
51
- action: input.action,
52
66
  requested,
53
67
  changed: before.enabled !== after.enabled,
54
68
  before,
55
69
  after,
56
70
  proofPath: 'trusted-pointer-visible-ui',
57
71
  };
72
+ }
73
+
74
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
75
+ if (typeof input.node?.enabled !== 'boolean') {
76
+ throw new Error('metamask.settings.set_basic_functionality requires enabled=true or enabled=false.');
77
+ }
78
+ return {
79
+ action: input.action,
80
+ ...await setBasicFunctionality(
81
+ page,
82
+ input.node.enabled,
83
+ Number(input.node?.timeout_ms ?? 15_000),
84
+ ),
85
+ };
58
86
  }));
@@ -13,6 +13,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
13
13
  'bridge-source-button',
14
14
  'bridge-destination-button',
15
15
  'bridge__header-settings-button',
16
+ 'slippage-edit-button',
16
17
  'prepare-swap-page-error',
17
18
  'bridge-missing-price-banner',
18
19
  ];
@@ -39,6 +40,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
39
40
  route: location.hash,
40
41
  title: document.title,
41
42
  items,
43
+ slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
42
44
  quote: {
43
45
  slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
44
46
  networkFee: text('[data-testid="network-fees-sponsored"], [data-testid="network-fees-included"], [data-testid="network-fees"]') || null,
@@ -52,16 +54,10 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
52
54
  })()`);
53
55
  const state = normalizeVisibleSwapBridgeState(raw, options, 'extension');
54
56
  const requireQuote = input.node?.require_quote === true;
55
- const expectedSlippage = input.node?.expected_slippage == null
56
- ? null
57
- : Number(input.node.expected_slippage);
58
57
  if (requireQuote && (!raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
59
58
  || !/^(?:Swap|Bridge)$/iu.test(String(raw.quote.cta)))) {
60
59
  throw new Error('Swap quote is not ready for review.');
61
60
  }
62
- if (expectedSlippage !== null && raw.quote.slippage !== `${expectedSlippage}%`) {
63
- throw new Error(`Swap slippage mismatch: expected ${expectedSlippage}%, observed ${raw.quote.slippage ?? 'none'}.`);
64
- }
65
61
  return {
66
62
  action: input.action,
67
63
  ...state,
@@ -7,6 +7,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
7
7
  await page.setInput(source, options.amount);
8
8
  const deadline = Date.now() + options.timeoutMs;
9
9
  let observed;
10
+ let lastRefreshAt = 0;
10
11
  do {
11
12
  observed = await page.evaluate(`(() => {
12
13
  const value = (testId) => {
@@ -23,13 +24,21 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
23
24
  noQuote: document.body.innerText.includes("This trade route isn't available right now"),
24
25
  };
25
26
  })()`);
26
- if (!options.waitForQuote || observed.noQuote || (Number(observed.destinationAmount) > 0 && observed.cta && observed.ctaEnabled)) break;
27
+ 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
+ }
33
+ if (Number(observed.destinationAmount) > 0 && observed.ctaEnabled
34
+ && /^(?:Swap|Bridge)$/iu.test(String(observed.cta))) break;
27
35
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
28
36
  } while (Date.now() < deadline);
29
37
  if (observed.sourceAmount !== options.amount) {
30
38
  throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
31
39
  }
32
- if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0 && observed.cta && observed.ctaEnabled)) {
40
+ if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
41
+ && observed.ctaEnabled && /^(?:Swap|Bridge)$/iu.test(String(observed.cta)))) {
33
42
  throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
34
43
  }
35
44
  return { action: input.action, ...observed, proofPath: 'visible-ui-amount' };
@@ -21,6 +21,8 @@ const BACK_CONTROLS = [
21
21
  'page-header-back-button',
22
22
  'back-button',
23
23
  ].map(dataTestId).concat('button[aria-label="Back"]');
24
+ const UI_POLL_MS = 200;
25
+ const PERPS_HOME_SETTLE_MS = 1000;
24
26
 
25
27
  function text(value) {
26
28
  return typeof value === 'string' && value.length > 0 ? value : undefined;
@@ -73,7 +75,7 @@ async function hasVisibleSelector(page, selector) {
73
75
  }
74
76
 
75
77
  async function pauseForUi(page) {
76
- await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
78
+ await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${UI_POLL_MS}))`);
77
79
  }
78
80
 
79
81
  async function waitForFirstVisible(page, selectors, deadline) {
@@ -180,15 +182,26 @@ export async function openPerpsHome(page, timeoutMs) {
180
182
  const deadline = Date.now() + timeoutMs;
181
183
  let pendingActivation;
182
184
  let perpsActivation;
185
+ let perpsHomeVisibleAt;
183
186
 
184
187
  while (Date.now() < deadline) {
185
188
  if (await hasVisibleSelector(page, tutorialSkip)) {
186
189
  await activateSemanticControl(page, tutorialSkip);
187
190
  steps.push(tutorialSkip);
191
+ perpsHomeVisibleAt = undefined;
188
192
  await pauseForUi(page);
189
193
  continue;
190
194
  }
191
195
  if (await hasVisibleSelector(page, perpsHome)) {
196
+ perpsHomeVisibleAt ??= Date.now();
197
+ const settleUntil = Math.min(
198
+ perpsHomeVisibleAt + PERPS_HOME_SETTLE_MS,
199
+ deadline - UI_POLL_MS,
200
+ );
201
+ if (Date.now() + UI_POLL_MS <= settleUntil) {
202
+ await pauseForUi(page);
203
+ continue;
204
+ }
192
205
  return {
193
206
  ...(perpsActivation ? { navigation: perpsActivation, selector: bottomNav } : {}),
194
207
  method: 'visible-ui',
@@ -196,6 +209,7 @@ export async function openPerpsHome(page, timeoutMs) {
196
209
  steps,
197
210
  };
198
211
  }
212
+ perpsHomeVisibleAt = undefined;
199
213
  if (await hasVisibleSelector(page, bottomNav)) {
200
214
  if (!perpsActivation) {
201
215
  perpsActivation = await activateSemanticControl(page, bottomNav);
@@ -347,15 +361,41 @@ export async function openPerpsMarketList(page, timeoutMs) {
347
361
  }
348
362
  const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
349
363
  const explore = dataTestId('perps-explore-markets-row');
350
- await page.waitForSelector(explore, { timeoutMs: Math.max(1, deadline - Date.now()) });
351
- const navigation = await activateSemanticControl(page, explore);
352
- await page.waitForSelector(marketList, { timeoutMs: Math.max(1, deadline - Date.now()) });
364
+ const tutorialSkip = dataTestId('perps-tutorial-skip-button');
365
+ if (!(await waitForFirstVisible(page, [explore], deadline))) {
366
+ throw new Error('Perps Explore Markets did not become visible before timeout.');
367
+ }
368
+ let navigation = await activateSemanticControl(page, explore);
369
+ const destination = await waitForFirstVisible(page, [marketList, tutorialSkip], deadline);
370
+ const steps = [...home.steps, explore];
371
+ if (destination === tutorialSkip) {
372
+ await activateSemanticControl(page, tutorialSkip);
373
+ steps.push(tutorialSkip);
374
+ let exploreReady = false;
375
+ while (Date.now() < deadline) {
376
+ if (!(await hasVisibleSelector(page, tutorialSkip)) && await hasVisibleSelector(page, explore)) {
377
+ exploreReady = true;
378
+ break;
379
+ }
380
+ await pauseForUi(page);
381
+ }
382
+ if (!exploreReady) {
383
+ throw new Error('Perps Explore Markets did not become visible after dismissing the tutorial.');
384
+ }
385
+ navigation = await activateSemanticControl(page, explore);
386
+ steps.push(explore);
387
+ if (!(await waitForFirstVisible(page, [marketList], deadline))) {
388
+ throw new Error('Perps market list did not become visible after dismissing the tutorial.');
389
+ }
390
+ } else if (destination !== marketList) {
391
+ throw new Error('Perps market list did not become visible before timeout.');
392
+ }
353
393
  return {
354
394
  navigation,
355
395
  method: 'visible-ui',
356
396
  href: await currentHref(page),
357
397
  selector: explore,
358
- steps: [...home.steps, explore],
398
+ steps,
359
399
  };
360
400
  }
361
401
 
@@ -2,6 +2,10 @@ import { resolveWalletImportCredentials, validateWalletImportOptions, walletImpo
2
2
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
3
  import { pasteSecretRecoveryPhrase, setSecretInput } from './secret-input.mjs';
4
4
  import { readWalletStateExpression } from './state.mjs';
5
+ import {
6
+ dismissVisibleOnboardingCompletion,
7
+ ensureVisibleSelectedAccount,
8
+ } from './visible-session.mjs';
5
9
 
6
10
  const SELECTORS = {
7
11
  importWallet: '[data-testid="onboarding-import-wallet"]',
@@ -16,6 +20,8 @@ const SELECTORS = {
16
20
  metricsCheckbox: '[data-testid="metametrics-checkbox"]',
17
21
  metricsContinue: '[data-testid="metametrics-i-agree"]',
18
22
  complete: '[data-testid="onboarding-complete-done"]',
23
+ unlockPassword: '[data-testid="unlock-password"]',
24
+ unlockSubmit: '[data-testid="unlock-submit"]',
19
25
  };
20
26
 
21
27
  async function hasSelector(page, selector) {
@@ -26,6 +32,20 @@ async function hasSelector(page, selector) {
26
32
  }
27
33
  }
28
34
 
35
+ async function hasVisibleSelector(page, selector) {
36
+ try {
37
+ return await page.evaluate(`(() => {
38
+ const element = document.querySelector(${JSON.stringify(selector)});
39
+ if (!element) return false;
40
+ const style = getComputedStyle(element);
41
+ const rect = element.getBoundingClientRect();
42
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
43
+ })()`);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
29
49
  async function waitForSelector(page, selector, timeoutMs) {
30
50
  const deadline = Date.now() + timeoutMs;
31
51
  while (Date.now() <= deadline) {
@@ -73,9 +93,9 @@ async function readState(page) {
73
93
  }
74
94
 
75
95
  async function unlockProfile(page, credentials, timeoutMs) {
76
- await setSecretInput(page, 'input[type="password"]', credentials.password);
77
- await page.click('button[type="submit"]');
78
- await waitForSelectorToDisappear(page, 'input[type="password"]', timeoutMs);
96
+ await setSecretInput(page, SELECTORS.unlockPassword, credentials.password);
97
+ await page.click(SELECTORS.unlockSubmit);
98
+ await waitForSelectorToDisappear(page, SELECTORS.unlockPassword, timeoutMs);
79
99
  }
80
100
 
81
101
  async function importThroughUi(page, input, timeoutMs) {
@@ -127,38 +147,57 @@ async function importThroughUi(page, input, timeoutMs) {
127
147
  await waitForSelector(page, SELECTORS.complete, timeoutMs);
128
148
  await page.click(SELECTORS.complete);
129
149
 
130
- const deadline = Date.now() + timeoutMs;
131
- let state = await readState(page);
132
- while (
133
- Date.now() <= deadline &&
134
- (!state.completedOnboarding || !state.selectedAccount?.address)
135
- ) {
136
- await new Promise((resolve) => setTimeout(resolve, 100));
137
- state = await readState(page);
138
- }
139
- if (!state.completedOnboarding || !state.selectedAccount?.address) {
140
- throw new Error('Extension wallet import did not complete onboarding with a selected account.');
141
- }
142
- if (state.metametricsOptedIn !== metametricsEnabled) {
143
- throw new Error(`Extension MetaMetrics preference was ${String(state.metametricsOptedIn)}, expected ${String(metametricsEnabled)}.`);
144
- }
145
- const actualAddress = String(state.selectedAccount?.address ?? '').toLowerCase();
146
- if (actualAddress !== credentials.expectedAddress) {
147
- throw new Error(`Wallet import selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
148
- }
149
- return { credentials, state, metametricsEnabled };
150
+ const visibleSession = await ensureVisibleSelectedAccount(page, credentials.expectedAddress, timeoutMs);
151
+ return { credentials, visibleSession, metametricsEnabled };
150
152
  }
151
153
 
152
154
  runAdapter((input) => withExtensionPage(input, async (page) => {
153
155
  validateWalletImportOptions('extension', input.node);
154
156
  const method = walletImportMethod(input);
155
157
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
156
- const before = await readState(page);
157
- const onboarding = String(before.href ?? '').includes('onboarding');
158
+ const onboardingRoute = await page.evaluate(`(() => String(location.hash || '').includes('onboarding'))()`);
159
+ const onboarding = onboardingRoute && (
160
+ await hasVisibleSelector(page, SELECTORS.importWallet) ||
161
+ await hasVisibleSelector(page, SELECTORS.importWithSrp) ||
162
+ await hasVisibleSelector(page, SELECTORS.srp)
163
+ );
158
164
 
159
- if (method === 'ui' && !onboarding) {
160
- throw new Error('metamask.wallet.import method=ui requires a fresh Extension onboarding profile.');
165
+ if (method === 'ui') {
166
+ if (!onboarding) {
167
+ const credentials = await resolveWalletImportCredentials(input);
168
+ const completionTriggered = await dismissVisibleOnboardingCompletion(page);
169
+ const locked = await hasVisibleSelector(page, SELECTORS.unlockPassword);
170
+ if (locked) await unlockProfile(page, credentials, timeoutMs);
171
+ const visibleSession = await ensureVisibleSelectedAccount(page, credentials.expectedAddress, timeoutMs);
172
+ return {
173
+ action: input.action,
174
+ method: 'ui-visible-session',
175
+ credentialSource: credentials.source,
176
+ credentialSourceName: credentials.sourceName,
177
+ completedOnboarding: true,
178
+ completionTriggered,
179
+ ...visibleSession,
180
+ proofPath: 'extension-visible-wallet-session',
181
+ };
182
+ }
183
+ const imported = await importThroughUi(page, input, timeoutMs);
184
+ return {
185
+ action: input.action,
186
+ method: 'ui-import',
187
+ credentialSource: imported.credentials.source,
188
+ credentialSourceName: imported.credentials.sourceName,
189
+ selectedIdentitySha256: imported.visibleSession.selectedIdentitySha256,
190
+ selectedAddressMatched: imported.visibleSession.selectedAddressMatched,
191
+ selectionPerformed: imported.visibleSession.selectionPerformed,
192
+ completedOnboarding: true,
193
+ metametricsEnabled: imported.metametricsEnabled,
194
+ interests: [],
195
+ redacted: true,
196
+ proofPath: 'extension-visible-onboarding-import',
197
+ };
161
198
  }
199
+
200
+ const before = await readState(page);
162
201
  if (onboarding) {
163
202
  if (method === 'profile') {
164
203
  throw new Error('metamask.wallet.import method=profile found a fresh Extension onboarding profile.');
@@ -169,8 +208,10 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
169
208
  method: 'ui-import',
170
209
  credentialSource: imported.credentials.source,
171
210
  credentialSourceName: imported.credentials.sourceName,
172
- selectedAddress: imported.state.selectedAccount.address,
173
- completedOnboarding: imported.state.completedOnboarding,
211
+ selectedIdentitySha256: imported.visibleSession.selectedIdentitySha256,
212
+ selectedAddressMatched: imported.visibleSession.selectedAddressMatched,
213
+ selectionPerformed: imported.visibleSession.selectionPerformed,
214
+ completedOnboarding: true,
174
215
  metametricsEnabled: imported.metametricsEnabled,
175
216
  interests: [],
176
217
  redacted: true,
@@ -3,6 +3,7 @@ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
3
  const MENU = dataTestId('account-options-menu-button');
4
4
  const LOCK = dataTestId('global-menu-lock');
5
5
  const UNLOCK_PAGE = dataTestId('unlock-page');
6
+ const HOME = dataTestId('bottom-nav-home');
6
7
 
7
8
  async function visible(page, selector) {
8
9
  return page.evaluate(`(() => {
@@ -14,9 +15,61 @@ async function visible(page, selector) {
14
15
  })()`);
15
16
  }
16
17
 
17
- runAdapter(async (input) => withExtensionPage(input, async (page) => {
18
- if (!(await visible(page, LOCK))) await page.click(MENU);
19
- const deadline = Date.now() + Number(input.node?.timeout_ms ?? 15_000);
18
+ async function clickUncovered(page, selector, deadline) {
19
+ let point;
20
+ while (!point && Date.now() < deadline) {
21
+ point = await page.evaluate(`(() => {
22
+ const element = document.querySelector(${JSON.stringify(selector)});
23
+ if (!element) return null;
24
+ const style = getComputedStyle(element);
25
+ const rect = element.getBoundingClientRect();
26
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
27
+ const offsets = [[0.25, 0.25], [0.25, 0.5], [0.25, 0.75], [0.5, 0.75], [0.75, 0.75]];
28
+ for (const [xOffset, yOffset] of offsets) {
29
+ const x = rect.left + (rect.width * xOffset);
30
+ const y = rect.top + (rect.height * yOffset);
31
+ const hit = document.elementFromPoint(x, y);
32
+ if (hit === element || element.contains(hit)) return { x, y };
33
+ }
34
+ return null;
35
+ })()`);
36
+ if (!point) await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
37
+ }
38
+ if (!point) throw new Error(`Wallet control has no uncovered pointer target: ${selector}`);
39
+ await page.session.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
40
+ await page.session.call('Input.dispatchMouseEvent', {
41
+ type: 'mousePressed',
42
+ x: point.x,
43
+ y: point.y,
44
+ button: 'left',
45
+ buttons: 1,
46
+ clickCount: 1,
47
+ });
48
+ await page.session.call('Input.dispatchMouseEvent', {
49
+ type: 'mouseReleased',
50
+ x: point.x,
51
+ y: point.y,
52
+ button: 'left',
53
+ buttons: 0,
54
+ clickCount: 1,
55
+ });
56
+ }
57
+
58
+ export async function lockWallet(page, timeoutMs = 15_000) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ if (!(await visible(page, LOCK))) {
61
+ if (!(await visible(page, MENU))) {
62
+ if (!(await visible(page, HOME))) {
63
+ throw new Error('Wallet lock requires a visible Home control when the global menu is unavailable.');
64
+ }
65
+ await page.click(HOME);
66
+ while (!(await visible(page, MENU))) {
67
+ if (Date.now() >= deadline) throw new Error('Wallet Home did not expose the account options menu.');
68
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
69
+ }
70
+ }
71
+ await clickUncovered(page, MENU, deadline);
72
+ }
20
73
  while (!(await visible(page, LOCK))) {
21
74
  if (Date.now() >= deadline) throw new Error('Wallet lock control did not become visible.');
22
75
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
@@ -27,9 +80,13 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
27
80
  await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
28
81
  }
29
82
  return {
30
- action: input.action,
31
83
  locked: true,
32
84
  route: await page.evaluate('location.hash'),
33
85
  proofPath: 'trusted-pointer-visible-ui',
34
86
  };
35
- }));
87
+ }
88
+
89
+ runAdapter(async (input) => withExtensionPage(input, async (page) => ({
90
+ action: input.action,
91
+ ...await lockWallet(page, Number(input.node?.timeout_ms ?? 15_000)),
92
+ })));