@deeeed/metamask-harness 0.37.0 → 0.38.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 (113) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +13 -3
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +2 -0
  5. package/adapters/extension/live.sh +1 -1
  6. package/adapters/extension/readiness.mjs +1 -1
  7. package/adapters/manifest.json +8 -0
  8. package/dist/adapters/extension/runtime-decision.js +4 -1
  9. package/dist/adapters/extension/runtime.js +42 -3
  10. package/dist/adapters/mobile/provision.js +38 -3
  11. package/dist/adapters.js +68 -3
  12. package/dist/app-lifecycle.js +4 -3
  13. package/dist/commands/call.js +4 -1
  14. package/dist/commands/launch/extension.js +21 -5
  15. package/dist/commands/launch/index.js +3 -0
  16. package/dist/commands/run-engine.js +40 -29
  17. package/dist/commands/run.js +1 -1
  18. package/dist/recipe-security.js +40 -0
  19. package/dist/runner.js +3 -0
  20. package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
  21. package/library/actions/extension/assets/finish_send.mjs +128 -0
  22. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  23. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  24. package/library/actions/extension/assets/read_details.mjs +57 -0
  25. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  26. package/library/actions/extension/assets/review_send.mjs +23 -0
  27. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  28. package/library/actions/extension/assets/send.mjs +104 -0
  29. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  30. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  31. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  32. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  33. package/library/actions/extension/networks/add_custom.mjs +123 -0
  34. package/library/actions/extension/networks/custom_network.mjs +103 -0
  35. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  36. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  37. package/library/actions/extension/perps/close_orders.mjs +1 -0
  38. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  39. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  40. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  41. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  42. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  43. package/library/actions/extension/perps/perps.mjs +194 -2
  44. package/library/actions/extension/perps/place_order.mjs +5 -2
  45. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  46. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  47. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  48. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  49. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  50. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  51. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  52. package/library/actions/extension/platform/cdp.mjs +104 -1
  53. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  54. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  55. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  56. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  57. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  58. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  59. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  60. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  61. package/library/actions/extension/ui/navigate.mjs +264 -8
  62. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  63. package/library/actions/extension/wallet/lock.mjs +35 -0
  64. package/library/actions/extension/wallet/select_account.mjs +71 -7
  65. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  66. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  67. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  68. package/library/actions/mobile/platform/observe-ui.mjs +5 -3
  69. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  70. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  71. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  72. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  73. package/library/actions/mobile/ui/native-navigation.mjs +45 -5
  74. package/library/actions/mobile/ui/navigate.mjs +3 -1
  75. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  76. package/library/actions/mobile/wallet/native-ui.mjs +21 -2
  77. package/library/actions/shared/assets/visible-state.mjs +176 -0
  78. package/library/actions/shared/networks/visible-state.mjs +70 -0
  79. package/library/actions/shared/perps/visible-state.mjs +354 -0
  80. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  81. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  82. package/library/manifests/extension.action-manifest.json +1255 -189
  83. package/library/manifests/mobile.action-manifest.json +219 -1
  84. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  85. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  86. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  87. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  88. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  89. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  90. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  91. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  92. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  93. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  94. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  95. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  96. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  97. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  98. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  99. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  100. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  101. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  102. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  103. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  104. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  105. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  106. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  107. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  108. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  109. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  110. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  111. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  112. package/package.json +6 -5
  113. package/scripts/validate-release-report.mjs +319 -0
@@ -0,0 +1,129 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ const OPTIONS = dataTestId('asset-list-control-bar-action-button');
4
+ const MANAGE = dataTestId('manageTokens__button');
5
+ const PAGE = dataTestId('token-management-page');
6
+ const SEARCH = dataTestId('token-management-search-input');
7
+ const BACK = dataTestId('token-management-header-back-button');
8
+ const LOW_VALUE = dataTestId('low-value-assets-toggle');
9
+
10
+ async function visible(page, selector) {
11
+ return page.evaluate(`(() => {
12
+ const element = document.querySelector(${JSON.stringify(selector)});
13
+ if (!element) return false;
14
+ const style = getComputedStyle(element);
15
+ const rect = element.getBoundingClientRect();
16
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
17
+ })()`);
18
+ }
19
+
20
+ async function waitFor(page, predicate, message, timeoutMs) {
21
+ const deadline = Date.now() + timeoutMs;
22
+ while (Date.now() < deadline) {
23
+ const value = await predicate();
24
+ if (value) return value;
25
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 150))');
26
+ }
27
+ throw new Error(message);
28
+ }
29
+
30
+ async function matchingToggle(page, query) {
31
+ return page.evaluate(`(() => {
32
+ const query = ${JSON.stringify(query.toLowerCase())};
33
+ const candidates = [...document.querySelectorAll('[data-testid^="token-management-cell-search-"][data-testid$="-toggle-control"]')];
34
+ for (const element of candidates) {
35
+ const testId = element.getAttribute('data-testid');
36
+ const row = document.querySelector('[data-testid="' + testId.slice(0, -'-toggle-control'.length) + '"]');
37
+ const label = String(row?.innerText || element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
38
+ const words = label.toLowerCase().split(/[^a-z0-9]+/u).filter(Boolean);
39
+ if (!words.includes(query) && !label.toLowerCase().includes(query)) continue;
40
+ const style = getComputedStyle(element);
41
+ const rect = element.getBoundingClientRect();
42
+ if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
43
+ return {
44
+ testId,
45
+ label,
46
+ enabled: Boolean(element.querySelector('.toggle-button--on')),
47
+ };
48
+ }
49
+ return null;
50
+ })()`);
51
+ }
52
+
53
+ async function homeToken(page, query) {
54
+ return page.evaluate(`(() => {
55
+ const query = ${JSON.stringify(query.toLowerCase())};
56
+ for (const row of document.querySelectorAll('[data-testid="multichain-token-list-button"]')) {
57
+ const name = String(row.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
58
+ const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
59
+ const symbol = amount.match(/\\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1] || '';
60
+ if (symbol.toLowerCase() === query || name.toLowerCase().includes(query)) {
61
+ return { name, symbol: symbol.toUpperCase(), amount };
62
+ }
63
+ }
64
+ return null;
65
+ })()`);
66
+ }
67
+
68
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
69
+ const query = String(input.node?.query || '').trim();
70
+ if (!query || query.length > 50) {
71
+ throw new Error('metamask.assets.set_token_visibility requires query with 1-50 characters.');
72
+ }
73
+ if (typeof input.node?.visible !== 'boolean') {
74
+ throw new Error('metamask.assets.set_token_visibility requires visible=true or visible=false.');
75
+ }
76
+ const requested = input.node.visible;
77
+ const requireUnchanged = input.node?.require_unchanged === true;
78
+ const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
79
+
80
+ if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
81
+ await waitFor(page, () => visible(page, MANAGE), 'Manage tokens did not become visible.', timeoutMs);
82
+ await page.click(MANAGE);
83
+ await waitFor(page, () => visible(page, PAGE), 'Token management did not open.', timeoutMs);
84
+ await page.setInput(SEARCH, query);
85
+ const before = await waitFor(
86
+ page,
87
+ () => matchingToggle(page, query),
88
+ `Token management returned no visible result for ${query}.`,
89
+ timeoutMs,
90
+ );
91
+ if (requireUnchanged && before.enabled !== requested) {
92
+ throw new Error(`${query} token visibility did not persist as ${requested ? 'enabled' : 'disabled'}.`);
93
+ }
94
+ if (before.enabled !== requested) {
95
+ await page.click(dataTestId(before.testId));
96
+ }
97
+ const after = await waitFor(
98
+ page,
99
+ async () => {
100
+ const state = await matchingToggle(page, query);
101
+ return state?.enabled === requested ? state : null;
102
+ },
103
+ `${query} token visibility did not become ${requested ? 'enabled' : 'disabled'}.`,
104
+ timeoutMs,
105
+ );
106
+ await page.click(BACK);
107
+ await waitFor(page, () => visible(page, OPTIONS), 'Token list did not reopen.', timeoutMs);
108
+
109
+ if (requested && (await visible(page, LOW_VALUE))) {
110
+ const expanded = await page.evaluate(`document.querySelector(${JSON.stringify(LOW_VALUE)})?.getAttribute('aria-expanded') === 'true'`);
111
+ if (!expanded) await page.click(LOW_VALUE);
112
+ }
113
+ const token = requested
114
+ ? await waitFor(page, () => homeToken(page, query), `${query} did not appear in the token list.`, timeoutMs)
115
+ : await homeToken(page, query);
116
+ if (!requested && token) throw new Error(`${query} remained visible after disabling it.`);
117
+
118
+ return {
119
+ action: input.action,
120
+ query,
121
+ requested,
122
+ requireUnchanged,
123
+ changed: before.enabled !== after.enabled,
124
+ searchResult: after.label,
125
+ toggleTestId: after.testId,
126
+ token,
127
+ proofPath: 'trusted-pointer-visible-ui',
128
+ };
129
+ }));
@@ -0,0 +1,89 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+
3
+ const TOGGLE = dataTestId('sort-by-popover-toggle');
4
+ const ALPHABETICAL = dataTestId('sortByAlphabetically__button');
5
+ const DECLINING = dataTestId('sortByDecliningBalance__button');
6
+
7
+ async function readRows(page) {
8
+ return page.evaluate(`(() => [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')]
9
+ .filter((element) => {
10
+ const style = getComputedStyle(element);
11
+ const rect = element.getBoundingClientRect();
12
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
13
+ })
14
+ .map((element) => ({
15
+ name: String(element.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText || '').replace(/\\s+/gu, ' ').trim(),
16
+ fiat: String(element.querySelector('[data-testid="multichain-token-list-item-secondary-value"]')?.innerText || '').replace(/\\s+/gu, ' ').trim(),
17
+ })))()`);
18
+ }
19
+
20
+ function fiatNumber(value) {
21
+ const sanitized = String(value).replace(/[^0-9.-]/gu, '');
22
+ if (!/\d/u.test(sanitized)) return null;
23
+ const parsed = Number(sanitized);
24
+ return Number.isFinite(parsed) ? parsed : null;
25
+ }
26
+
27
+ function assertAlphabetical(rows) {
28
+ const names = rows.map((row) => row.name).filter(Boolean);
29
+ if (names.length < 2) {
30
+ throw new Error(`Asset A-Z sorting requires at least two named rows, but observed ${names.length}.`);
31
+ }
32
+ const expected = [...names].sort((left, right) => left.localeCompare(right, 'en', { sensitivity: 'base' }));
33
+ if (names.some((name, index) => name !== expected[index])) {
34
+ throw new Error(`Asset names are not sorted A-Z: ${names.join(', ')}.`);
35
+ }
36
+ }
37
+
38
+ function assertDeclining(rows) {
39
+ const values = rows.map((row) => fiatNumber(row.fiat)).filter((value) => value !== null);
40
+ if (values.length < 2) {
41
+ throw new Error(`Asset balance sorting requires at least two comparable fiat rows, but observed ${values.length}.`);
42
+ }
43
+ if (values.some((value, index) => index > 0 && values[index - 1] < value)) {
44
+ throw new Error(`Asset fiat values are not sorted high-low: ${values.join(', ')}.`);
45
+ }
46
+ }
47
+
48
+ async function choose(page, selector) {
49
+ const isVisible = async () => page.evaluate(`(() => {
50
+ const element = document.querySelector(${JSON.stringify(selector)});
51
+ if (!element) return false;
52
+ const style = getComputedStyle(element);
53
+ const rect = element.getBoundingClientRect();
54
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
55
+ })()`);
56
+ if (!(await isVisible())) await page.click(TOGGLE);
57
+ const deadline = Date.now() + 5_000;
58
+ while (!(await isVisible())) {
59
+ if (Date.now() >= deadline) throw new Error('Asset sort menu did not become visible.');
60
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
61
+ }
62
+ await page.click(selector);
63
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 500))');
64
+ }
65
+
66
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
67
+ if (!String(await page.evaluate('location.hash')).startsWith('#/')) {
68
+ throw new Error('metamask.assets.verify_sorting requires the wallet home token list.');
69
+ }
70
+
71
+ await choose(page, ALPHABETICAL);
72
+ const alphabetical = await readRows(page);
73
+ assertAlphabetical(alphabetical);
74
+
75
+ await choose(page, DECLINING);
76
+ const declining = await readRows(page);
77
+ assertDeclining(declining);
78
+
79
+ return {
80
+ action: input.action,
81
+ source: 'visible-ui',
82
+ platform: 'extension',
83
+ surface: 'token-list',
84
+ alphabetical: alphabetical.map((row) => row.name),
85
+ decliningFiat: declining.map((row) => row.fiat),
86
+ restoredSort: 'declining-balance',
87
+ proofPath: 'trusted-pointer-visible-ui',
88
+ };
89
+ }));
@@ -0,0 +1,94 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ findNetworkItem,
4
+ openNetworkManager,
5
+ selectHomeNetwork,
6
+ waitFor,
7
+ } from './custom_network.mjs';
8
+
9
+ const ADD_CUSTOM = dataTestId('networks-page-add-custom-network-button');
10
+ const ADD_CHAINLIST = dataTestId('network-form-add-from-chainlist');
11
+ const CHAINLIST_SEARCH = dataTestId('networks-page-chainlist-search');
12
+ const CHAINLIST_ROW = '[data-testid="networks-page-chainlist-network"]';
13
+ const SAVE = dataTestId('page-container-footer-next');
14
+
15
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
16
+ const network = String(input.node?.network || '').trim();
17
+ const query = String(input.node?.query || network).trim();
18
+ const chainId = Number(input.node?.chain_id);
19
+ const symbol = String(input.node?.symbol || '').trim().toUpperCase();
20
+ const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
21
+ if (!network || !query || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
22
+ throw new Error('metamask.networks.add_chainlist requires network, query, positive chain_id, and symbol.');
23
+ }
24
+
25
+ const chainlistOpen = await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(CHAINLIST_SEARCH)}))`);
26
+ if (!chainlistOpen) await openNetworkManager(page, timeoutMs);
27
+ let item = chainlistOpen ? null : await findNetworkItem(page, network, chainId);
28
+ let added = false;
29
+ if (!item) {
30
+ if (!chainlistOpen) {
31
+ await page.click(ADD_CUSTOM);
32
+ await waitFor(page, () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(ADD_CHAINLIST)}))`), 'Add from ChainList did not become visible.', timeoutMs);
33
+ }
34
+ for (let attempt = 0; attempt < 3; attempt += 1) {
35
+ if (await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(CHAINLIST_SEARCH)}))`)) break;
36
+ if (await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(ADD_CHAINLIST)}))`)) {
37
+ await page.click(ADD_CHAINLIST);
38
+ }
39
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 500))');
40
+ }
41
+ await waitFor(page, () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(CHAINLIST_SEARCH)}))`), 'ChainList network search did not become visible.', timeoutMs);
42
+ await page.setInput(`${CHAINLIST_SEARCH} input`, query);
43
+ const row = await waitFor(page, () => page.evaluate(`(() => {
44
+ const expectedName = ${JSON.stringify(network.toLowerCase())};
45
+ const expectedSymbol = ${JSON.stringify(symbol.toLowerCase())};
46
+ const expectedId = ${JSON.stringify(String(chainId))};
47
+ for (const element of document.querySelectorAll(${JSON.stringify(CHAINLIST_ROW)})) {
48
+ const text = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
49
+ const normalized = text.toLowerCase();
50
+ if (!normalized.includes(expectedName) || !normalized.includes('symbol: ' + expectedSymbol) || !normalized.includes('id: ' + expectedId)) continue;
51
+ element.setAttribute('data-mm-harness-chainlist-row', 'true');
52
+ return { selector: '[data-mm-harness-chainlist-row="true"]', text: text.slice(0, 500) };
53
+ }
54
+ return null;
55
+ })()`), `${network} (ID ${chainId}) did not appear in ChainList results.`, timeoutMs);
56
+ await page.click(row.selector);
57
+ let enabledSince = 0;
58
+ await waitFor(page, async () => {
59
+ const enabled = await page.evaluate(`(() => {
60
+ const button = document.querySelector(${JSON.stringify(SAVE)});
61
+ return Boolean(button && !button.disabled && button.getAttribute('aria-disabled') !== 'true');
62
+ })()`);
63
+ if (!enabled) {
64
+ enabledSince = 0;
65
+ return false;
66
+ }
67
+ if (enabledSince === 0) enabledSince = Date.now();
68
+ return Date.now() - enabledSince >= 1500;
69
+ }, 'ChainList network Save did not remain enabled after prefilling.', timeoutMs);
70
+ await waitFor(page, async () => {
71
+ const stillOpen = await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(SAVE)}))`);
72
+ if (!stillOpen) return true;
73
+ await page.click(SAVE);
74
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 500))');
75
+ return false;
76
+ }, 'ChainList network Save did not close the prefilled form.', timeoutMs);
77
+ item = await waitFor(page, () => findNetworkItem(page, network, chainId), `${network} did not appear after the ChainList save.`, timeoutMs);
78
+ added = true;
79
+ }
80
+
81
+ await page.click(dataTestId('page-header-back-button'));
82
+ await waitFor(page, () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(dataTestId('sort-by-networks'))}))`), 'Wallet home did not reopen after ChainList network management.', timeoutMs);
83
+ await selectHomeNetwork(page, network, chainId, timeoutMs);
84
+ return {
85
+ action: input.action,
86
+ network,
87
+ chainId,
88
+ symbol,
89
+ query,
90
+ added,
91
+ item,
92
+ proofPath: 'trusted-pointer-chainlist-ui',
93
+ };
94
+ }));
@@ -0,0 +1,123 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ findNetworkItem,
4
+ openNetworkManager,
5
+ selectHomeNetwork,
6
+ semanticButton,
7
+ semanticButtonOrNull,
8
+ waitFor,
9
+ } from './custom_network.mjs';
10
+
11
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
12
+ const network = String(input.node?.network || '').trim();
13
+ const rpcUrl = String(input.node?.rpc_url || '').trim();
14
+ const chainId = Number(input.node?.chain_id);
15
+ const symbol = String(input.node?.symbol || '').trim();
16
+ const explorerUrl = String(input.node?.explorer_url || '').trim();
17
+ const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
18
+ if (!network || !/^https?:\/\//u.test(rpcUrl) || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
19
+ throw new Error('metamask.networks.add_custom requires network, rpc_url, positive chain_id, and symbol.');
20
+ }
21
+
22
+ await openNetworkManager(page, timeoutMs);
23
+ let item = await findNetworkItem(page, network, chainId);
24
+ let added = false;
25
+ if (!item) {
26
+ await page.click(dataTestId('networks-page-add-custom-network-button'));
27
+ await waitFor(
28
+ page,
29
+ () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(dataTestId('network-form-network-name'))}))`),
30
+ 'Custom network form did not open.',
31
+ timeoutMs,
32
+ );
33
+ await page.setInput(dataTestId('network-form-network-name'), network);
34
+ await page.setInput(dataTestId('network-form-chain-id'), chainId);
35
+ await page.setInput(dataTestId('network-form-ticker-input'), symbol);
36
+
37
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1000))');
38
+ let addRpc;
39
+ for (let attempt = 0; attempt < 3 && !addRpc; attempt += 1) {
40
+ await page.click(dataTestId('test-add-rpc-drop-down'));
41
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
42
+ addRpc = await semanticButtonOrNull(page, 'Add RPC URL');
43
+ }
44
+ if (!addRpc) throw new Error('Visible button "Add RPC URL" was not found after opening the RPC menu.');
45
+ await page.click(addRpc);
46
+ await page.setInput(dataTestId('rpc-url-input-test'), rpcUrl);
47
+ await page.click(dataTestId('page-container-footer-next'));
48
+ await waitFor(
49
+ page,
50
+ () => page.evaluate(`!document.querySelector(${JSON.stringify(dataTestId('rpc-url-input-test'))})`),
51
+ 'RPC URL form did not close after adding the URL.',
52
+ timeoutMs,
53
+ );
54
+
55
+ if (explorerUrl) {
56
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 1000))');
57
+ let addExplorer;
58
+ for (let attempt = 0; attempt < 3 && !addExplorer; attempt += 1) {
59
+ await page.click(dataTestId('test-explorer-drop-down'));
60
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
61
+ addExplorer = await semanticButtonOrNull(page, 'Add a block explorer URL');
62
+ }
63
+ if (!addExplorer) throw new Error('Visible button "Add a block explorer URL" was not found after opening the explorer menu.');
64
+ await page.click(addExplorer);
65
+ await page.setInput(dataTestId('explorer-url-input'), explorerUrl);
66
+ await page.click(await semanticButton(page, 'Add URL'));
67
+ await waitFor(
68
+ page,
69
+ () => page.evaluate(`!document.querySelector(${JSON.stringify(dataTestId('explorer-url-input'))})`),
70
+ 'Block explorer form did not close after adding the URL.',
71
+ timeoutMs,
72
+ );
73
+ }
74
+
75
+ let enabledSince = 0;
76
+ await waitFor(page, async () => {
77
+ const enabled = await page.evaluate(`(() => {
78
+ for (const element of document.querySelectorAll('button')) {
79
+ const text = String(element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
80
+ const style = getComputedStyle(element);
81
+ const rect = element.getBoundingClientRect();
82
+ if (text === 'Save' && !element.disabled && element.getAttribute('aria-disabled') !== 'true' && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) return true;
83
+ }
84
+ return false;
85
+ })()`);
86
+ if (!enabled) {
87
+ enabledSince = 0;
88
+ return false;
89
+ }
90
+ if (enabledSince === 0) enabledSince = Date.now();
91
+ return Date.now() - enabledSince >= 1500;
92
+ }, 'Custom network save did not remain enabled after RPC validation.', timeoutMs);
93
+ const save = await semanticButton(page, 'Save');
94
+ await page.click(save);
95
+ item = await waitFor(
96
+ page,
97
+ () => findNetworkItem(page, network, chainId),
98
+ `${network} did not appear in the network manager after save.`,
99
+ timeoutMs,
100
+ );
101
+ added = true;
102
+ }
103
+
104
+ await page.click(dataTestId('page-header-back-button'));
105
+ await waitFor(
106
+ page,
107
+ () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(dataTestId('sort-by-networks'))}))`),
108
+ 'Wallet home did not reopen after network management.',
109
+ timeoutMs,
110
+ );
111
+ await selectHomeNetwork(page, network, chainId, timeoutMs);
112
+ return {
113
+ action: input.action,
114
+ network,
115
+ chainId,
116
+ symbol,
117
+ rpcUrl,
118
+ explorerUrl: explorerUrl || null,
119
+ added,
120
+ item,
121
+ proofPath: 'trusted-pointer-visible-ui',
122
+ };
123
+ }));
@@ -0,0 +1,103 @@
1
+ import { dataTestId } from '../platform/cdp.mjs';
2
+
3
+ async function visible(page, selector) {
4
+ return page.evaluate(`(() => {
5
+ const element = document.querySelector(${JSON.stringify(selector)});
6
+ if (!element) return false;
7
+ const style = getComputedStyle(element);
8
+ const rect = element.getBoundingClientRect();
9
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
10
+ })()`);
11
+ }
12
+
13
+ export async function waitFor(page, predicate, message, timeoutMs) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ while (Date.now() < deadline) {
16
+ const value = await predicate();
17
+ if (value) return value;
18
+ await page.evaluate('new Promise((resolve) => setTimeout(resolve, 150))');
19
+ }
20
+ throw new Error(message);
21
+ }
22
+
23
+ export async function semanticButtonOrNull(page, label) {
24
+ const marker = `mm-harness-${Date.now()}-${Math.random().toString(16).slice(2)}`;
25
+ const found = await page.evaluate(`(() => {
26
+ const label = ${JSON.stringify(label)};
27
+ const marker = ${JSON.stringify(marker)};
28
+ for (const element of document.querySelectorAll('button')) {
29
+ const text = String(element.getAttribute('aria-label') || element.innerText || element.textContent || '').replace(/\\s+/gu, ' ').trim();
30
+ const style = getComputedStyle(element);
31
+ const rect = element.getBoundingClientRect();
32
+ if (text === label && !element.disabled && element.getAttribute('aria-disabled') !== 'true' && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) {
33
+ element.setAttribute('data-mm-harness-target', marker);
34
+ return true;
35
+ }
36
+ }
37
+ return false;
38
+ })()`);
39
+ return found ? `[data-mm-harness-target="${marker}"]` : null;
40
+ }
41
+
42
+ export async function semanticButton(page, label) {
43
+ const selector = await semanticButtonOrNull(page, label);
44
+ if (!selector) throw new Error(`Visible button ${JSON.stringify(label)} was not found.`);
45
+ return selector;
46
+ }
47
+
48
+ export async function openNetworkManager(page, timeoutMs) {
49
+ const menu = dataTestId('account-options-menu-button');
50
+ const networks = dataTestId('global-menu-networks');
51
+ await page.click(menu);
52
+ await waitFor(page, () => visible(page, networks), 'Networks did not become visible in the account menu.', timeoutMs);
53
+ await page.click(networks);
54
+ await waitFor(
55
+ page,
56
+ () => visible(page, dataTestId('networks-page-add-custom-network-button')),
57
+ 'Network manager did not open.',
58
+ timeoutMs,
59
+ );
60
+ }
61
+
62
+ export async function findNetworkItem(page, network, chainId) {
63
+ return page.evaluate(`(() => {
64
+ const network = ${JSON.stringify(network.toLowerCase())};
65
+ const chainIds = new Set(${JSON.stringify([
66
+ String(chainId),
67
+ `0x${chainId.toString(16)}`,
68
+ `eip155:${chainId}`,
69
+ ].map((value) => value.toLowerCase()))});
70
+ for (const element of document.querySelectorAll('[data-testid^="network-list-item-"]')) {
71
+ const testId = String(element.getAttribute('data-testid') || '');
72
+ if (testId.includes('-options-')) continue;
73
+ const suffix = testId.slice('network-list-item-'.length).toLowerCase();
74
+ const label = String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500);
75
+ const nameMatches = [...element.querySelectorAll('[data-testid]')]
76
+ .some((child) => String(child.getAttribute('data-testid') || '').toLowerCase() === network);
77
+ if (chainIds.has(suffix) && nameMatches) return { testId, label, suffix };
78
+ }
79
+ return null;
80
+ })()`);
81
+ }
82
+
83
+ export async function selectHomeNetwork(page, network, chainId, timeoutMs) {
84
+ const sort = dataTestId('sort-by-networks');
85
+ const chainHex = `0x${chainId.toString(16)}`;
86
+ const choices = [
87
+ dataTestId(`home-network-filter-network-${chainHex}`),
88
+ dataTestId(`home-network-filter-custom-${chainHex}`),
89
+ dataTestId(`home-network-filter-additional-${chainHex}`),
90
+ ];
91
+ await page.click(sort);
92
+ const choice = await waitFor(page, async () => {
93
+ for (const candidate of choices) {
94
+ if (await visible(page, candidate)) return candidate;
95
+ }
96
+ return null;
97
+ }, `${network} did not appear in the home network filter.`, timeoutMs);
98
+ await page.click(choice);
99
+ await waitFor(page, async () => {
100
+ const label = await page.evaluate(`String(document.querySelector(${JSON.stringify(sort)})?.innerText || '')`);
101
+ return label.toLowerCase().includes(network.toLowerCase());
102
+ }, `${network} did not become the selected home network.`, timeoutMs);
103
+ }
@@ -0,0 +1,50 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ normalizeVisibleNetworkState,
4
+ resolveVisibleNetworkOptions,
5
+ } from '../../shared/networks/visible-state.mjs';
6
+
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+
11
+ async function capture(page, maximum) {
12
+ return page.evaluate(`(() => {
13
+ const rows = [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')]
14
+ .filter((element) => {
15
+ const style = getComputedStyle(element);
16
+ const rect = element.getBoundingClientRect();
17
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
18
+ })
19
+ .slice(0, ${JSON.stringify(maximum)})
20
+ .map((element) => {
21
+ const change = element.querySelector('[data-testid^="token-increase-decrease-percentage-"]');
22
+ return {
23
+ testId: element.getAttribute('data-testid'),
24
+ label: String(element.getAttribute('aria-label') || element.innerText || '').replace(/\\s+/gu, ' ').trim(),
25
+ name: element.querySelector('[data-testid="multichain-token-list-item-token-name"]')?.innerText,
26
+ fiat: element.querySelector('[data-testid="multichain-token-list-item-secondary-value"]')?.innerText,
27
+ amount: element.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText,
28
+ priceChange: change?.innerText,
29
+ };
30
+ });
31
+ return {
32
+ network: document.querySelector('[data-testid="sort-by-networks"]')?.innerText,
33
+ errorText: document.body?.innerText,
34
+ items: rows,
35
+ truncated: document.querySelectorAll('[data-testid="multichain-token-list-button"]').length > rows.length,
36
+ };
37
+ })()`);
38
+ }
39
+
40
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
41
+ const options = resolveVisibleNetworkOptions(input.node);
42
+ const before = await capture(page, options.assets.maxItems);
43
+ if (options.settleMs > 0) await sleep(options.settleMs);
44
+ const after = options.settleMs > 0 ? await capture(page, options.assets.maxItems) : before;
45
+ return {
46
+ action: input.action,
47
+ ...normalizeVisibleNetworkState(before, after, options, 'extension'),
48
+ proofPath: 'visible-dom-accessibility',
49
+ };
50
+ }));
@@ -0,0 +1,68 @@
1
+ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ findNetworkItem,
4
+ openNetworkManager,
5
+ selectHomeNetwork,
6
+ semanticButtonOrNull,
7
+ waitFor,
8
+ } from './custom_network.mjs';
9
+
10
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
11
+ const network = String(input.node?.network || '').trim();
12
+ const chainId = Number(input.node?.chain_id);
13
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
14
+ if (!network || !Number.isInteger(chainId) || chainId <= 0) {
15
+ throw new Error('metamask.networks.remove_custom requires network and positive chain_id.');
16
+ }
17
+ await selectHomeNetwork(page, 'Ethereum', 1, timeoutMs);
18
+ await openNetworkManager(page, timeoutMs);
19
+ const item = await findNetworkItem(page, network, chainId);
20
+ if (!item) throw new Error(`${network} (${chainId}) is not present for removal.`);
21
+ await page.click(dataTestId(`network-list-item-options-button-${item.suffix}`));
22
+ const remove = await waitFor(
23
+ page,
24
+ () => semanticButtonOrNull(page, 'Delete'),
25
+ 'Custom network Delete action did not become visible.',
26
+ timeoutMs,
27
+ );
28
+ await page.click(remove);
29
+ await waitFor(
30
+ page,
31
+ () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(dataTestId('confirm-delete-network-modal'))}))`),
32
+ 'Custom network delete confirmation did not appear.',
33
+ timeoutMs,
34
+ );
35
+ await page.click(dataTestId('confirm-delete-network-modal-delete-button'));
36
+ await waitFor(
37
+ page,
38
+ () => page.evaluate(`!document.querySelector(${JSON.stringify(dataTestId('confirm-delete-network-modal'))})`),
39
+ 'Custom network delete confirmation did not close.',
40
+ timeoutMs,
41
+ );
42
+ await waitFor(
43
+ page,
44
+ async () => !(await findNetworkItem(page, network, chainId)),
45
+ `${network} remained in the network manager after deletion.`,
46
+ timeoutMs,
47
+ );
48
+ const back = await waitFor(
49
+ page,
50
+ () => semanticButtonOrNull(page, 'Back'),
51
+ 'Network manager Back button did not become visible.',
52
+ timeoutMs,
53
+ );
54
+ await page.click(back);
55
+ await waitFor(
56
+ page,
57
+ () => page.evaluate(`Boolean(document.querySelector(${JSON.stringify(dataTestId('sort-by-networks'))}))`),
58
+ 'Wallet home did not reopen after network deletion.',
59
+ timeoutMs,
60
+ );
61
+ return {
62
+ action: input.action,
63
+ network,
64
+ chainId,
65
+ removed: true,
66
+ proofPath: 'trusted-pointer-visible-ui',
67
+ };
68
+ }));
@@ -3,6 +3,7 @@ import { assertOrders, closeOrders } from './perps.mjs';
3
3
 
4
4
  runAdapter(async (input) => {
5
5
  const close = await closeOrders(input);
6
+ if (close.proofPath === 'visible-product-controls') return close;
6
7
  const assertion = await assertOrders(input, false);
7
8
  return { ...assertion, close };
8
9
  });