@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
@@ -0,0 +1,302 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ closeNativeSession,
4
+ createNativeSession,
5
+ nativeLabel,
6
+ nativeNode,
7
+ } from '../platform/native-session.mjs';
8
+
9
+ function descendants(snapshot, root) {
10
+ const byParent = new Map();
11
+ for (const node of snapshot.nodes) {
12
+ const children = byParent.get(node.parentIndex) ?? [];
13
+ children.push(node);
14
+ byParent.set(node.parentIndex, children);
15
+ }
16
+ const found = [];
17
+ const queue = [...(byParent.get(root.index) ?? [])];
18
+ while (queue.length > 0) {
19
+ const node = queue.shift();
20
+ found.push(node);
21
+ queue.push(...(byParent.get(node.index) ?? []));
22
+ }
23
+ return found;
24
+ }
25
+
26
+ function searchedToken(snapshot, query) {
27
+ const normalized = query.toUpperCase();
28
+ const rows = snapshot.nodes.filter((node) => node.identifier === 'searched-token-result');
29
+ for (const row of rows) {
30
+ const children = descendants(snapshot, row);
31
+ const label = children.map(nativeLabel).find((value) => {
32
+ const parts = value.split(',').map((part) => part.trim().toUpperCase());
33
+ return parts.includes(normalized);
34
+ });
35
+ if (label) {
36
+ return {
37
+ row,
38
+ label,
39
+ imported: row.enabled === false && children.some((node) => /CheckBox$/u.test(String(node.type))),
40
+ };
41
+ }
42
+ }
43
+ const imported = rows.find((row) =>
44
+ row.enabled === false && descendants(snapshot, row).some((node) => /CheckBox$/u.test(String(node.type)))
45
+ );
46
+ return imported ? { row: imported, label: query, imported: true } : null;
47
+ }
48
+
49
+ function visibleNode(snapshot, identifier) {
50
+ return snapshot.nodes.find((node) =>
51
+ node.identifier === identifier && node.visibleToUser !== false && node.hittable !== false
52
+ );
53
+ }
54
+
55
+ async function waitForSnapshot(session, timeoutMs, predicate, message) {
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (Date.now() <= deadline) {
58
+ const snapshot = await session.snapshot();
59
+ const value = predicate(snapshot);
60
+ if (value) return { snapshot, value };
61
+ await new Promise((resolve) => setTimeout(resolve, 200));
62
+ }
63
+ throw new Error(message);
64
+ }
65
+
66
+ async function openImport(session, timeoutMs) {
67
+ let snapshot = await session.snapshot();
68
+ if (visibleNode(snapshot, 'input-search-asset')) return;
69
+ if (!nativeNode(snapshot, 'import-token-button')) {
70
+ await session.execute('ui.press', {
71
+ test_id: 'homepage-section-title-tokens',
72
+ timeout_ms: timeoutMs,
73
+ settle: false,
74
+ });
75
+ snapshot = (await waitForSnapshot(
76
+ session,
77
+ timeoutMs,
78
+ (current) => nativeNode(current, 'import-token-button'),
79
+ 'The Mobile token list did not expose Import tokens.',
80
+ )).snapshot;
81
+ }
82
+ if (!visibleNode(snapshot, 'input-search-asset') && !nativeNode(snapshot, 'input-token-address')) {
83
+ await session.execute('ui.press', {
84
+ test_id: 'import-token-button',
85
+ timeout_ms: timeoutMs,
86
+ settle: false,
87
+ });
88
+ snapshot = (await waitForSnapshot(
89
+ session,
90
+ timeoutMs,
91
+ (current) => visibleNode(current, 'input-search-asset') ??
92
+ nativeNode(current, 'input-token-address') ??
93
+ current.nodes.find((node) => nativeLabel(node) === 'Search' && node.hittable === true),
94
+ 'The Mobile token import tabs did not become visible.',
95
+ )).snapshot;
96
+ }
97
+ if (!visibleNode(snapshot, 'input-search-asset')) {
98
+ await session.execute('ui.press', { text: 'Search', timeout_ms: timeoutMs, settle: false });
99
+ }
100
+ await waitForSnapshot(
101
+ session,
102
+ timeoutMs,
103
+ (current) => visibleNode(current, 'input-search-asset'),
104
+ 'The Mobile token search did not open.',
105
+ );
106
+ }
107
+
108
+ async function searchToken(session, query, timeoutMs) {
109
+ const snapshot = await session.snapshot();
110
+ if (nativeNode(snapshot, 'clear-search-bar')) {
111
+ await session.execute('ui.press', {
112
+ test_id: 'clear-search-bar',
113
+ timeout_ms: timeoutMs,
114
+ settle: false,
115
+ });
116
+ }
117
+ await session.execute('ui.press', {
118
+ test_id: 'input-search-asset',
119
+ timeout_ms: timeoutMs,
120
+ settle: false,
121
+ });
122
+ await session.client.interactions.type({
123
+ ...session.selection,
124
+ text: query,
125
+ delayMs: 50,
126
+ responseLevel: 'digest',
127
+ });
128
+ return (await waitForSnapshot(
129
+ session,
130
+ timeoutMs,
131
+ (snapshot) => searchedToken(snapshot, query),
132
+ `Mobile token search returned no exact result for ${query}.`,
133
+ )).value;
134
+ }
135
+
136
+ async function enableToken(session, query, requireUnchanged, requireChange, timeoutMs) {
137
+ await openImport(session, timeoutMs);
138
+ let match = await searchToken(session, query, timeoutMs);
139
+ const alreadyImported = match.imported;
140
+ if (requireUnchanged && !match.imported) {
141
+ throw new Error(`${query} token import did not persist.`);
142
+ }
143
+ if (requireChange && match.imported) {
144
+ throw new Error(`${query} was already imported before the requested visibility change.`);
145
+ }
146
+ if (!match.imported) {
147
+ await session.execute('ui.press', { text: match.label, timeout_ms: timeoutMs, settle: false });
148
+ await waitForSnapshot(
149
+ session,
150
+ timeoutMs,
151
+ (snapshot) => nativeNode(snapshot, 'token-import-next-button')?.enabled === true,
152
+ `Selecting ${query} did not enable the token import action.`,
153
+ );
154
+ await session.execute('ui.press', {
155
+ test_id: 'token-import-next-button',
156
+ timeout_ms: timeoutMs,
157
+ settle: false,
158
+ });
159
+ await waitForSnapshot(
160
+ session,
161
+ timeoutMs,
162
+ (snapshot) => nativeNode(snapshot, 'bottomsheetfooter-button-subsequent'),
163
+ `The ${query} import confirmation did not open.`,
164
+ );
165
+ await session.execute('ui.press', {
166
+ test_id: 'bottomsheetfooter-button-subsequent',
167
+ timeout_ms: timeoutMs,
168
+ settle: false,
169
+ });
170
+ await waitForSnapshot(
171
+ session,
172
+ timeoutMs,
173
+ (snapshot) => nativeNode(snapshot, 'import-token-button'),
174
+ `The ${query} import did not return to the token list.`,
175
+ );
176
+ await openImport(session, timeoutMs);
177
+ match = await searchToken(session, query, timeoutMs);
178
+ if (!match.imported) throw new Error(`${query} did not remain imported after confirmation.`);
179
+ }
180
+ await session.execute('ui.press', { test_id: 'button-icon', timeout_ms: timeoutMs, settle: false });
181
+ return {
182
+ changed: !alreadyImported,
183
+ imported: true,
184
+ proofPath: 'native-accessibility-token-search-import-state',
185
+ };
186
+ }
187
+
188
+ async function findVisibleAsset(session, query, timeoutMs) {
189
+ let snapshot = await session.snapshot();
190
+ if (!nativeNode(snapshot, 'token-list')) {
191
+ await session.execute('ui.press', {
192
+ test_id: 'homepage-section-title-tokens',
193
+ timeout_ms: timeoutMs,
194
+ settle: false,
195
+ });
196
+ snapshot = (await waitForSnapshot(
197
+ session,
198
+ timeoutMs,
199
+ (current) => nativeNode(current, 'token-list'),
200
+ 'The Mobile token list did not open.',
201
+ )).snapshot;
202
+ }
203
+ for (let attempt = 0; attempt < 8; attempt += 1) {
204
+ if (nativeNode(snapshot, `asset-${query.toUpperCase()}`)) return true;
205
+ await session.execute('ui.scroll', { direction: 'down', timeout_ms: timeoutMs, settle: false });
206
+ snapshot = await session.snapshot();
207
+ }
208
+ return false;
209
+ }
210
+
211
+ async function disableToken(session, query, requireUnchanged, requireChange, timeoutMs) {
212
+ const present = await findVisibleAsset(session, query, timeoutMs);
213
+ if (!present) {
214
+ if (requireChange) throw new Error(`${query} was not visible before the requested visibility change.`);
215
+ return {
216
+ changed: false,
217
+ imported: false,
218
+ proofPath: 'native-accessibility-token-row-absent',
219
+ };
220
+ }
221
+ if (requireUnchanged) throw new Error(`${query} remained visible instead of hidden.`);
222
+ await session.execute('ui.press', {
223
+ test_id: `asset-${query.toUpperCase()}`,
224
+ timeout_ms: timeoutMs,
225
+ settle: false,
226
+ });
227
+ await waitForSnapshot(
228
+ session,
229
+ timeoutMs,
230
+ (snapshot) => nativeNode(snapshot, 'token-more-button'),
231
+ `${query} token details did not expose More.`,
232
+ );
233
+ await session.execute('ui.press', { test_id: 'token-more-button', timeout_ms: timeoutMs, settle: false });
234
+ await waitForSnapshot(
235
+ session,
236
+ timeoutMs,
237
+ (snapshot) => nativeNode(snapshot, 'more-actions-remove-token'),
238
+ `${query} is not removable from the visible Mobile token details.`,
239
+ );
240
+ await session.execute('ui.press', {
241
+ test_id: 'more-actions-remove-token',
242
+ timeout_ms: timeoutMs,
243
+ settle: false,
244
+ });
245
+ const confirmation = await waitForSnapshot(
246
+ session,
247
+ timeoutMs,
248
+ (snapshot) => nativeNode(snapshot, 'remove-token-bottom-sheet-remove-button') ??
249
+ nativeNode(snapshot, 'asset-hide-confirmation-button') ??
250
+ snapshot.nodes.find((node) =>
251
+ node.visibleToUser !== false &&
252
+ /Button$/u.test(String(node.type)) &&
253
+ nativeLabel(node) === 'Confirm'
254
+ ),
255
+ `${query} token removal did not request confirmation.`,
256
+ );
257
+ await session.execute('ui.press', confirmation.value.identifier
258
+ ? { test_id: confirmation.value.identifier, timeout_ms: timeoutMs, settle: false }
259
+ : { text: 'Confirm', timeout_ms: timeoutMs, settle: false });
260
+ await waitForSnapshot(
261
+ session,
262
+ timeoutMs,
263
+ (snapshot) => nativeNode(snapshot, `asset-${query.toUpperCase()}`) ? null : true,
264
+ `${query} remained visible after removal.`,
265
+ );
266
+ return {
267
+ changed: true,
268
+ imported: false,
269
+ proofPath: 'native-accessibility-token-details-removal',
270
+ };
271
+ }
272
+
273
+ runAdapter(async (input) => {
274
+ const query = String(input.node?.query ?? '').trim();
275
+ const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
276
+ const requested = input.node?.visible;
277
+ const requireUnchanged = input.node?.require_unchanged === true;
278
+ const requireChange = input.node?.require_change === true;
279
+ if (!query || query.length > 50) {
280
+ throw new Error('metamask.assets.set_token_visibility requires query with 1-50 characters.');
281
+ }
282
+ if (typeof requested !== 'boolean') {
283
+ throw new Error('metamask.assets.set_token_visibility requires visible=true or visible=false.');
284
+ }
285
+ const session = createNativeSession(input, 'Assets', `token-visibility-${process.pid}`);
286
+ try {
287
+ await session.open();
288
+ const result = requested
289
+ ? await enableToken(session, query, requireUnchanged, requireChange, timeoutMs)
290
+ : await disableToken(session, query, requireUnchanged, requireChange, timeoutMs);
291
+ return {
292
+ action: input.action,
293
+ query,
294
+ requested,
295
+ requireUnchanged,
296
+ requireChange,
297
+ ...result,
298
+ };
299
+ } finally {
300
+ await closeNativeSession(session);
301
+ }
302
+ });
@@ -0,0 +1,104 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ closeNativeSession,
4
+ createNativeSession,
5
+ nativeLabel,
6
+ } from '../platform/native-session.mjs';
7
+
8
+ function assetRows(snapshot) {
9
+ const byIndex = new Map(snapshot.nodes.map((node) => [node.index, node]));
10
+ const belongsToTokenList = (node) => {
11
+ let current = node;
12
+ for (let depth = 0; depth < 40 && current; depth += 1) {
13
+ if (String(current.identifier ?? '') === 'token-list') return true;
14
+ current = byIndex.get(current.parentIndex);
15
+ }
16
+ return false;
17
+ };
18
+ return snapshot.nodes
19
+ .filter((node) => belongsToTokenList(node)
20
+ && /^asset-[A-Za-z0-9._:-]+$/u.test(String(node.identifier ?? '')))
21
+ .map((node) => {
22
+ const label = nativeLabel(node);
23
+ const fiat = label.match(/[$€£¥]\s?([0-9][0-9,]*(?:\.\d{2})?)/u)?.[1];
24
+ return {
25
+ testId: String(node.identifier),
26
+ name: label.split(',')[0]?.trim() || String(node.identifier).slice('asset-'.length),
27
+ fiat: fiat ? Number(fiat.replaceAll(',', '')) : null,
28
+ };
29
+ });
30
+ }
31
+
32
+ function isAlphabetical(rows) {
33
+ const names = rows.map((row) => row.name);
34
+ const expected = [...names].sort((left, right) => left.localeCompare(right, 'en', { sensitivity: 'base' }));
35
+ return names.length >= 2 && names.every((name, index) => name === expected[index]);
36
+ }
37
+
38
+ function isDeclining(rows) {
39
+ const values = rows.map((row) => row.fiat).filter((value) => value !== null);
40
+ return values.length >= 2 && values.every((value, index) => index === 0 || values[index - 1] >= value);
41
+ }
42
+
43
+ async function choose(session, option, timeoutMs) {
44
+ await session.execute('ui.press', { test_id: 'token-sort-button', timeout_ms: timeoutMs, settle: false });
45
+ await session.execute('ui.press', { test_id: option, timeout_ms: timeoutMs, settle: false });
46
+ const deadline = Date.now() + timeoutMs;
47
+ let rows = [];
48
+ while (Date.now() <= deadline) {
49
+ const snapshot = await session.snapshot();
50
+ rows = assetRows(snapshot);
51
+ if (rows.length >= 2 && !sessionNode(snapshot, option)) return rows;
52
+ await new Promise((resolve) => setTimeout(resolve, 200));
53
+ }
54
+ throw new Error(`Asset sort ${option} did not return to a visible token list: ${JSON.stringify(rows)}.`);
55
+ }
56
+
57
+ async function revealTokenList(session, timeoutMs) {
58
+ if (sessionNode(await session.snapshot(), 'token-sort-button')) return;
59
+ await session.execute('ui.press', {
60
+ test_id: 'homepage-section-title-tokens',
61
+ timeout_ms: timeoutMs,
62
+ settle: false,
63
+ });
64
+ const deadline = Date.now() + timeoutMs;
65
+ while (Date.now() <= deadline) {
66
+ if (sessionNode(await session.snapshot(), 'token-sort-button')) return;
67
+ await new Promise((resolve) => setTimeout(resolve, 200));
68
+ }
69
+ throw new Error('Assets token-list sort control did not become visible after opening Tokens.');
70
+ }
71
+
72
+ function sessionNode(snapshot, identifier) {
73
+ return snapshot.nodes.find((node) => String(node.identifier ?? '') === identifier);
74
+ }
75
+
76
+ runAdapter(async (input) => {
77
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
78
+ const session = createNativeSession(input, 'Assets', `sort-${process.pid}`);
79
+ try {
80
+ await session.open();
81
+ await revealTokenList(session, timeoutMs);
82
+ const alphabetical = await choose(session, 'sort-alphabetical', timeoutMs);
83
+ const declining = await choose(session, 'sort-declining-balance', timeoutMs);
84
+ const failures = [];
85
+ if (!isAlphabetical(alphabetical)) {
86
+ failures.push(`A-Z order was ${alphabetical.map((row) => row.name).join(', ')}`);
87
+ }
88
+ if (!isDeclining(declining)) {
89
+ failures.push(`declining fiat order was ${declining.map((row) => row.fiat).join(', ')}`);
90
+ }
91
+ if (failures.length > 0) {
92
+ throw new Error(`Asset token sorting failed: ${failures.join('; ')}.`);
93
+ }
94
+ return {
95
+ action: input.action,
96
+ alphabetical: alphabetical.map((row) => row.name),
97
+ decliningFiat: declining.map((row) => row.fiat),
98
+ restoredSort: 'declining-balance',
99
+ proofPath: 'native-accessibility-token-sorting',
100
+ };
101
+ } finally {
102
+ await closeNativeSession(session);
103
+ }
104
+ });
@@ -0,0 +1,125 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ closeNativeSession,
4
+ createNativeSession,
5
+ nativeNode,
6
+ } from '../platform/native-session.mjs';
7
+ import {
8
+ clearNetworkSearch,
9
+ findNetwork,
10
+ IDS,
11
+ openNetworkManager,
12
+ waitForSnapshot,
13
+ } from './network-management.mjs';
14
+
15
+ const FIELDS = {
16
+ network: 'network-details-view-name-input',
17
+ rpcUrl: 'network-details-view-rpc-url-input',
18
+ rpcName: 'network-details-view-rpc-name-input',
19
+ chainId: 'network-details-view-chain-id-input',
20
+ symbol: 'network-details-view-symbol-input',
21
+ explorerUrl: 'network-details-view-block-explorer-input',
22
+ };
23
+
24
+ async function fill(session, testId, value, timeoutMs) {
25
+ await session.execute('ui.set_input', {
26
+ test_id: testId,
27
+ value,
28
+ timeout_ms: timeoutMs,
29
+ settle: false,
30
+ });
31
+ }
32
+
33
+ runAdapter(async (input) => {
34
+ const network = String(input.node?.network ?? '').trim();
35
+ const rpcUrl = String(input.node?.rpc_url ?? '').trim();
36
+ const chainId = Number(input.node?.chain_id);
37
+ const symbol = String(input.node?.symbol ?? '').trim().toUpperCase();
38
+ const explorerUrl = String(input.node?.explorer_url ?? '').trim();
39
+ const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
40
+ if (!network || !/^https:\/\//u.test(rpcUrl) || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
41
+ throw new Error('metamask.networks.add_custom requires network, HTTPS rpc_url, positive chain_id, and symbol.');
42
+ }
43
+ if (explorerUrl && !/^https:\/\//u.test(explorerUrl)) {
44
+ throw new Error('metamask.networks.add_custom explorer_url must use HTTPS.');
45
+ }
46
+
47
+ const session = createNativeSession(input, 'Networks', `add-${process.pid}`);
48
+ try {
49
+ await session.open();
50
+ await openNetworkManager(session, timeoutMs);
51
+ let item = await findNetwork(session, network, chainId, timeoutMs);
52
+ if (item) {
53
+ return {
54
+ action: input.action,
55
+ network,
56
+ chainId,
57
+ symbol,
58
+ rpcUrl,
59
+ explorerUrl: explorerUrl || null,
60
+ added: false,
61
+ item,
62
+ proofPath: 'native-accessibility-network-settings',
63
+ };
64
+ }
65
+
66
+ await clearNetworkSearch(session, timeoutMs);
67
+ await session.execute('ui.key_press', { key: 'BACK', timeout_ms: timeoutMs, settle: false });
68
+ await waitForSnapshot(
69
+ session,
70
+ timeoutMs,
71
+ (snapshot) => nativeNode(snapshot, IDS.add),
72
+ 'Mobile Networks settings did not expose Add network after closing search.',
73
+ );
74
+ await session.execute('ui.press', { test_id: IDS.add, timeout_ms: timeoutMs, settle: false });
75
+ await waitForSnapshot(
76
+ session,
77
+ timeoutMs,
78
+ (snapshot) => nativeNode(snapshot, IDS.details),
79
+ 'The Mobile custom-network form did not open.',
80
+ );
81
+ await fill(session, FIELDS.network, network, timeoutMs);
82
+ await fill(session, FIELDS.rpcUrl, rpcUrl, timeoutMs);
83
+ await fill(session, FIELDS.rpcName, `${network} RPC`, timeoutMs);
84
+ await fill(session, FIELDS.chainId, String(chainId), timeoutMs);
85
+ await fill(session, FIELDS.symbol, symbol, timeoutMs);
86
+ if (explorerUrl) await fill(session, FIELDS.explorerUrl, explorerUrl, timeoutMs);
87
+ await session.execute('ui.key_press', { key: 'BACK', timeout_ms: timeoutMs, settle: false });
88
+
89
+ await waitForSnapshot(
90
+ session,
91
+ timeoutMs,
92
+ (snapshot) => {
93
+ const button = nativeNode(snapshot, 'network-details-view-add-button');
94
+ return button?.enabled === true ? button : null;
95
+ },
96
+ 'Custom-network Save did not become enabled after RPC validation.',
97
+ );
98
+ await session.execute('ui.press', {
99
+ test_id: 'network-details-view-add-button',
100
+ timeout_ms: timeoutMs,
101
+ settle: false,
102
+ });
103
+ await waitForSnapshot(
104
+ session,
105
+ timeoutMs,
106
+ (snapshot) => nativeNode(snapshot, IDS.manager),
107
+ 'Mobile Networks settings did not reopen after custom-network save.',
108
+ );
109
+ item = await findNetwork(session, network, chainId, timeoutMs);
110
+ if (!item) throw new Error(`${network} (${chainId}) was absent after custom-network save.`);
111
+ return {
112
+ action: input.action,
113
+ network,
114
+ chainId,
115
+ symbol,
116
+ rpcUrl,
117
+ explorerUrl: explorerUrl || null,
118
+ added: true,
119
+ item,
120
+ proofPath: 'native-accessibility-network-settings',
121
+ };
122
+ } finally {
123
+ await closeNativeSession(session);
124
+ }
125
+ });
@@ -0,0 +1,132 @@
1
+ import { nativeLabel, nativeNode } from '../platform/native-session.mjs';
2
+
3
+ const IDS = {
4
+ add: 'networks-settings-add-custom-network-button',
5
+ details: 'network-details-view-container',
6
+ hamburger: 'navbar-hamburger-menu-button',
7
+ menu: 'networks-menu-item',
8
+ manager: 'networks-settings-container',
9
+ search: 'networks-settings-search-input',
10
+ wallet: ['wallet-safe-area', 'homepage-container'],
11
+ };
12
+
13
+ export function networkItemId(chainId) {
14
+ return `networks-settings-network-item-0x${chainId.toString(16)}`;
15
+ }
16
+
17
+ export function visibleNode(snapshot, identifier) {
18
+ return snapshot.nodes.find((node) =>
19
+ node.identifier === identifier &&
20
+ node.visibleToUser !== false &&
21
+ node.hittable !== false
22
+ );
23
+ }
24
+
25
+ export async function waitForSnapshot(session, timeoutMs, predicate, message) {
26
+ const deadline = Date.now() + timeoutMs;
27
+ while (Date.now() <= deadline) {
28
+ const snapshot = await session.snapshot();
29
+ const value = predicate(snapshot);
30
+ if (value) return { snapshot, value };
31
+ await new Promise((resolve) => setTimeout(resolve, 200));
32
+ }
33
+ throw new Error(message);
34
+ }
35
+
36
+ async function systemBack(session) {
37
+ await session.client.command.back({
38
+ ...session.selection,
39
+ mode: 'system',
40
+ responseLevel: 'digest',
41
+ });
42
+ }
43
+
44
+ export async function openNetworkManager(session, timeoutMs) {
45
+ for (let attempt = 0; attempt < 8; attempt += 1) {
46
+ const snapshot = await session.snapshot();
47
+ if (visibleNode(snapshot, IDS.manager)) return snapshot;
48
+ if (visibleNode(snapshot, IDS.details)) {
49
+ await systemBack(session);
50
+ continue;
51
+ }
52
+ if (visibleNode(snapshot, IDS.menu)) {
53
+ await session.execute('ui.press', { test_id: IDS.menu, timeout_ms: timeoutMs, settle: false });
54
+ continue;
55
+ }
56
+ if (IDS.wallet.some((identifier) => nativeNode(snapshot, identifier))) {
57
+ await session.execute('ui.press', { test_id: IDS.hamburger, timeout_ms: timeoutMs, settle: false });
58
+ continue;
59
+ }
60
+ await systemBack(session);
61
+ }
62
+ return (await waitForSnapshot(
63
+ session,
64
+ timeoutMs,
65
+ (snapshot) => visibleNode(snapshot, IDS.manager),
66
+ 'The Mobile Networks settings screen did not open.',
67
+ )).snapshot;
68
+ }
69
+
70
+ export async function findNetwork(session, network, chainId, timeoutMs) {
71
+ const itemId = networkItemId(chainId);
72
+ let snapshot = await session.snapshot();
73
+ let item = visibleNode(snapshot, itemId);
74
+ if (!item) {
75
+ await session.execute('ui.set_input', {
76
+ test_id: IDS.search,
77
+ value: network,
78
+ timeout_ms: timeoutMs,
79
+ settle: false,
80
+ });
81
+ snapshot = (await waitForSnapshot(
82
+ session,
83
+ timeoutMs,
84
+ (current) => visibleNode(current, itemId) ?? current.nodes.find((node) =>
85
+ nativeLabel(node).toLowerCase() === 'no networks found'
86
+ ),
87
+ `Mobile Networks search did not settle for ${network}.`,
88
+ )).snapshot;
89
+ item = visibleNode(snapshot, itemId);
90
+ }
91
+ if (!item) return null;
92
+ const label = nativeLabel(item);
93
+ if (label && !label.toLowerCase().includes(network.toLowerCase())) {
94
+ throw new Error(`${itemId} did not identify ${network}; observed ${JSON.stringify(label)}.`);
95
+ }
96
+ return { id: itemId, label: label || network };
97
+ }
98
+
99
+ export async function clearNetworkSearch(session, timeoutMs) {
100
+ const snapshot = await session.snapshot();
101
+ const search = nativeNode(snapshot, IDS.search);
102
+ const searchValue = nativeLabel(search);
103
+ if (!search || !searchValue || searchValue.toLowerCase() === 'search networks') return;
104
+ const clear = snapshot.nodes.find((node) =>
105
+ node.parentIndex === search.parentIndex &&
106
+ node.index > search.index &&
107
+ node.hittable === true &&
108
+ node.ref
109
+ );
110
+ if (!clear?.ref) {
111
+ throw new Error('Mobile Networks search did not expose a semantic clear control.');
112
+ }
113
+ await session.client.interactions.press({
114
+ ...session.selection,
115
+ ref: `@${clear.ref}`,
116
+ settle: true,
117
+ verify: true,
118
+ timeoutMs,
119
+ responseLevel: 'digest',
120
+ });
121
+ await waitForSnapshot(
122
+ session,
123
+ timeoutMs,
124
+ (current) => {
125
+ const value = nativeLabel(nativeNode(current, IDS.search));
126
+ return !value || value.toLowerCase() === 'search networks' ? true : null;
127
+ },
128
+ 'Mobile Networks search did not clear.',
129
+ );
130
+ }
131
+
132
+ export { IDS };