@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,354 @@
1
+ const SURFACES = new Set([
2
+ 'auto',
3
+ 'home',
4
+ 'market-list',
5
+ 'market-details',
6
+ 'order-entry',
7
+ 'activity',
8
+ 'funds',
9
+ ]);
10
+ const FEATURES = new Set([
11
+ 'activity',
12
+ 'balance',
13
+ 'chart',
14
+ 'long-short',
15
+ 'market-list',
16
+ 'order-entry',
17
+ 'price',
18
+ 'price-change',
19
+ 'stats',
20
+ ]);
21
+
22
+ function boundedInteger(value, fallback, minimum, maximum, name) {
23
+ const resolved = value == null ? fallback : Number(value);
24
+ if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
25
+ throw new Error(
26
+ `metamask.perps.read_visible_state ${name} must be an integer from ${minimum} to ${maximum}.`,
27
+ );
28
+ }
29
+ return resolved;
30
+ }
31
+
32
+ function stringList(value, name, maximum = 100) {
33
+ if (value == null) return [];
34
+ if (!Array.isArray(value)) {
35
+ throw new Error(`metamask.perps.read_visible_state ${name} must be an array.`);
36
+ }
37
+ const values = Array.from(new Set(value.map((entry) => String(entry).trim()).filter(Boolean)));
38
+ if (values.length > maximum) {
39
+ throw new Error(`metamask.perps.read_visible_state ${name} accepts at most ${maximum} values.`);
40
+ }
41
+ return values;
42
+ }
43
+
44
+ export function resolveVisibleStateOptions(node = {}) {
45
+ const surface = String(node.surface ?? 'auto').toLowerCase();
46
+ if (!SURFACES.has(surface)) {
47
+ throw new Error(
48
+ `metamask.perps.read_visible_state received unsupported surface ${JSON.stringify(surface)}.`,
49
+ );
50
+ }
51
+ const market = node.market == null ? undefined : String(node.market).trim().toUpperCase();
52
+ if (node.market != null && !market) {
53
+ throw new Error('metamask.perps.read_visible_state market must not be empty.');
54
+ }
55
+ return {
56
+ surface,
57
+ market,
58
+ includeOffscreen: node.include_offscreen !== false,
59
+ maxItems: boundedInteger(node.max_items, 100, 1, 200, 'max_items'),
60
+ minimumMarketCount: boundedInteger(
61
+ node.minimum_market_count,
62
+ 0,
63
+ 0,
64
+ 200,
65
+ 'minimum_market_count',
66
+ ),
67
+ maximumMarketCount: boundedInteger(
68
+ node.maximum_market_count,
69
+ 200,
70
+ 0,
71
+ 200,
72
+ 'maximum_market_count',
73
+ ),
74
+ requireTestIds: stringList(node.require_test_ids, 'require_test_ids'),
75
+ forbidTestIds: stringList(node.forbid_test_ids, 'forbid_test_ids'),
76
+ direction: node.direction == null ? undefined : String(node.direction).trim().toLowerCase(),
77
+ orderType: node.order_type == null ? undefined : String(node.order_type).trim().toLowerCase(),
78
+ amount: node.amount == null ? undefined : String(node.amount),
79
+ percent: node.percent == null ? undefined : String(node.percent),
80
+ leverage: node.leverage == null ? undefined : String(node.leverage),
81
+ autoClose: node.auto_close == null ? undefined : Boolean(node.auto_close),
82
+ submitEnabled: node.submit_enabled == null ? undefined : Boolean(node.submit_enabled),
83
+ requireValidationText: stringList(node.require_validation_text, 'require_validation_text', 20),
84
+ minimumPositionCount: boundedInteger(node.minimum_position_count, 0, 0, 200, 'minimum_position_count'),
85
+ maximumPositionCount: boundedInteger(node.maximum_position_count, 200, 0, 200, 'maximum_position_count'),
86
+ minimumOrderCount: boundedInteger(node.minimum_order_count, 0, 0, 200, 'minimum_order_count'),
87
+ maximumOrderCount: boundedInteger(node.maximum_order_count, 200, 0, 200, 'maximum_order_count'),
88
+ minimumActivityCount: boundedInteger(node.minimum_activity_count, 0, 0, 200, 'minimum_activity_count'),
89
+ requireNoHorizontalOverflow: node.require_no_horizontal_overflow === true,
90
+ requireFeatures: stringList(node.require_features, 'require_features').map((feature) => {
91
+ if (!FEATURES.has(feature)) {
92
+ throw new Error(
93
+ `metamask.perps.read_visible_state received unsupported feature ${JSON.stringify(feature)}.`,
94
+ );
95
+ }
96
+ return feature;
97
+ }),
98
+ };
99
+ }
100
+
101
+ function text(value) {
102
+ if (typeof value !== 'string') return undefined;
103
+ const normalized = value.replace(/\s+/gu, ' ').trim();
104
+ return normalized ? normalized.slice(0, 500) : undefined;
105
+ }
106
+
107
+ function equalNumericText(actual, expected) {
108
+ const left = String(actual ?? '').trim();
109
+ const right = String(expected ?? '').trim();
110
+ if (!left || !right) return left === right;
111
+ const leftNumber = Number(left);
112
+ const rightNumber = Number(right);
113
+ return Number.isFinite(leftNumber) && Number.isFinite(rightNumber)
114
+ ? leftNumber === rightNumber
115
+ : left === right;
116
+ }
117
+
118
+ function normalizedItem(source) {
119
+ const testId = text(source?.testId ?? source?.test_id ?? source?.identifier);
120
+ if (!testId) return undefined;
121
+ return {
122
+ testId,
123
+ ...(text(source?.label ?? source?.value) ? { label: text(source?.label ?? source?.value) } : {}),
124
+ ...(text(source?.role ?? source?.type) ? { role: text(source?.role ?? source?.type) } : {}),
125
+ enabled: source?.enabled !== false,
126
+ visible: source?.visible !== false,
127
+ };
128
+ }
129
+
130
+ function marketFromTestId(testId) {
131
+ if (testId.startsWith('market-row-ticker-')) return undefined;
132
+ for (const prefix of ['perps-watchlist-', 'explore-markets-', 'perps-market-row-item-', 'market-row-']) {
133
+ if (!testId.startsWith(prefix)) continue;
134
+ const raw = testId.slice(prefix.length);
135
+ if ((prefix === 'explore-markets-' || prefix === 'market-row-') && raw.startsWith('xyz-')) {
136
+ return `XYZ:${raw.slice(4).toUpperCase()}`;
137
+ }
138
+ return raw.toUpperCase();
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function classifySurface(route, testIds) {
144
+ const joined = [...testIds].join('\n');
145
+ if (/\/perps\/trade\//u.test(route)) {
146
+ return 'order-entry';
147
+ }
148
+ if (/\/perps\/market\//u.test(route)) {
149
+ return 'market-details';
150
+ }
151
+ if (/perps-(?:order-entry|order-view|trade)/u.test(joined)) return 'order-entry';
152
+ if (/perps-(?:market-detail|market-details|pro-market)/u.test(joined)) return 'market-details';
153
+ if (/market-list-view|perps-market-list/u.test(joined)) return 'market-list';
154
+ if (/perps-(?:activity|transactions)/u.test(joined)) return 'activity';
155
+ if (/perps-(?:deposit|withdraw|add-funds)/u.test(joined)) return 'funds';
156
+ if (/perps-home/u.test(joined) || /perps-home|tab=perps/u.test(route)) return 'home';
157
+ return 'unknown';
158
+ }
159
+
160
+ function selected(items, predicate, maximum) {
161
+ return items.filter(predicate).slice(0, maximum);
162
+ }
163
+
164
+ function compactEntries(items, maximum) {
165
+ return selected(items, () => true, maximum).map((item) => ({
166
+ testId: item.testId,
167
+ ...(item.label ? { label: item.label } : {}),
168
+ enabled: item.enabled,
169
+ visible: item.visible,
170
+ }));
171
+ }
172
+
173
+ function visibleFeatures(items) {
174
+ const ids = new Set(items.map((item) => item.testId));
175
+ const has = (pattern) => [...ids].some((testId) => pattern.test(testId));
176
+ const long = has(/(?:long-cta-button|direction-long)$/u);
177
+ const short = has(/(?:short-cta-button|direction-short)$/u);
178
+ return {
179
+ activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
180
+ balance: has(/^perps-balance-dropdown/u),
181
+ chart: has(/perps-(?:candlestick-chart|market-detail-chart|pro-market-chart)/u),
182
+ 'long-short': long && short,
183
+ 'market-list': has(/^(?:market-list-view|perps-market-list|perps-watchlist-|explore-markets-|market-row-)/u),
184
+ 'order-entry': has(/perps-(?:order-entry|pro-order-form)/u),
185
+ price: has(/perps-(?:market-detail-price|pro-market-price)$/u),
186
+ 'price-change': has(/perps-(?:market-detail-change|pro-market-price-change)$/u),
187
+ stats: has(/perps-(?:stats-section|market-detail-oracle-price|pro-market-stats)/u),
188
+ };
189
+ }
190
+
191
+ export function normalizeVisiblePerpsState(raw, options, platform) {
192
+ const items = [...(raw.items ?? []), ...(options.includeOffscreen ? raw.offscreenItems ?? [] : [])]
193
+ .map(normalizedItem)
194
+ .filter(Boolean);
195
+ const unique = Array.from(new Map(items.map((item) => [item.testId, item])).values());
196
+ const testIds = new Set(unique.map((item) => item.testId));
197
+ const route = text(raw.route) ?? '';
198
+ const surface = classifySurface(route, testIds);
199
+ const marketItems = selected(
200
+ unique,
201
+ (item) => Boolean(marketFromTestId(item.testId)),
202
+ options.maxItems,
203
+ ).map((item) => ({
204
+ market: marketFromTestId(item.testId),
205
+ testId: item.testId,
206
+ ...(item.label ? { label: item.label } : {}),
207
+ visible: item.visible,
208
+ }));
209
+ const state = {
210
+ source: 'visible-ui',
211
+ platform,
212
+ surface,
213
+ ...(route ? { route } : {}),
214
+ ...(text(raw.title) ? { title: text(raw.title) } : {}),
215
+ balance: compactEntries(
216
+ selected(unique, (item) => item.testId.startsWith('perps-balance-dropdown'), 1),
217
+ 1,
218
+ )[0] ?? null,
219
+ markets: marketItems,
220
+ positions: compactEntries(
221
+ selected(unique, (item) => /^(?:position-card-|perps-position-row-|perps-position-size-value$|perps-pro-market-position-row-)/u.test(item.testId), options.maxItems),
222
+ options.maxItems,
223
+ ),
224
+ orders: compactEntries(
225
+ selected(unique, (item) => /^(?:order-card-|perps-order-row-)/u.test(item.testId), options.maxItems),
226
+ options.maxItems,
227
+ ),
228
+ activity: compactEntries(
229
+ selected(unique, (item) => /^(?:transaction-card-|perps-activity-row-)/u.test(item.testId), options.maxItems),
230
+ options.maxItems,
231
+ ),
232
+ orderEntry: raw.orderEntry && typeof raw.orderEntry === 'object' ? {
233
+ symbol: text(raw.orderEntry.symbol) ?? null,
234
+ direction: text(raw.orderEntry.direction) ?? null,
235
+ orderType: text(raw.orderEntry.orderType) ?? null,
236
+ amount: raw.orderEntry.amount == null ? null : String(raw.orderEntry.amount),
237
+ percent: raw.orderEntry.percent == null ? null : String(raw.orderEntry.percent),
238
+ leverage: raw.orderEntry.leverage == null ? null : String(raw.orderEntry.leverage),
239
+ autoClose: raw.orderEntry.autoClose === true,
240
+ takeProfitPrice: raw.orderEntry.takeProfitPrice == null ? null : String(raw.orderEntry.takeProfitPrice),
241
+ stopLossPrice: raw.orderEntry.stopLossPrice == null ? null : String(raw.orderEntry.stopLossPrice),
242
+ submit: raw.orderEntry.submit && typeof raw.orderEntry.submit === 'object' ? {
243
+ label: text(raw.orderEntry.submit.label) ?? null,
244
+ enabled: raw.orderEntry.submit.enabled !== false,
245
+ } : null,
246
+ limitPrice: raw.orderEntry.limitPrice == null ? null : String(raw.orderEntry.limitPrice),
247
+ validation: Array.isArray(raw.orderEntry.validation)
248
+ ? raw.orderEntry.validation.map(text).filter(Boolean).slice(0, 20)
249
+ : [],
250
+ } : null,
251
+ features: visibleFeatures(unique),
252
+ controls: compactEntries(
253
+ selected(
254
+ unique,
255
+ (item) => item.testId.startsWith('perps-') && !marketFromTestId(item.testId),
256
+ options.maxItems,
257
+ ),
258
+ options.maxItems,
259
+ ),
260
+ observedTestIds: Array.from(testIds).slice(0, options.maxItems),
261
+ truncated: raw.truncated === true || unique.length > options.maxItems,
262
+ layout: raw.layout && typeof raw.layout === 'object' ? {
263
+ viewportWidth: Number(raw.layout.viewportWidth ?? 0),
264
+ viewportHeight: Number(raw.layout.viewportHeight ?? 0),
265
+ contentWidth: Number(raw.layout.contentWidth ?? 0),
266
+ horizontalOverflow: raw.layout.horizontalOverflow === true,
267
+ clippedTestIds: Array.isArray(raw.layout.clippedTestIds)
268
+ ? raw.layout.clippedTestIds.map(text).filter(Boolean).slice(0, 100)
269
+ : [],
270
+ } : null,
271
+ };
272
+ assertVisibleState(state, options);
273
+ return state;
274
+ }
275
+
276
+ function assertVisibleState(state, options) {
277
+ if (options.requireNoHorizontalOverflow && (
278
+ !state.layout || state.layout.horizontalOverflow || state.layout.clippedTestIds.length > 0
279
+ )) {
280
+ throw new Error(`Visible Perps layout overflows horizontally: ${JSON.stringify(state.layout)}.`);
281
+ }
282
+ if (options.surface !== 'auto' && state.surface !== options.surface) {
283
+ throw new Error(
284
+ `Expected visible Perps surface ${options.surface}, but observed ${state.surface}.`,
285
+ );
286
+ }
287
+ if (state.markets.length < options.minimumMarketCount) {
288
+ throw new Error(
289
+ `Expected at least ${options.minimumMarketCount} visible Perps markets, but observed ${state.markets.length}.`,
290
+ );
291
+ }
292
+ if (state.markets.length > options.maximumMarketCount) {
293
+ throw new Error(
294
+ `Expected at most ${options.maximumMarketCount} visible Perps markets, but observed ${state.markets.length}.`,
295
+ );
296
+ }
297
+ if (state.positions.length < options.minimumPositionCount || state.positions.length > options.maximumPositionCount) {
298
+ throw new Error(`Expected ${options.minimumPositionCount}-${options.maximumPositionCount} visible Perps positions, but observed ${state.positions.length}.`);
299
+ }
300
+ if (state.orders.length < options.minimumOrderCount || state.orders.length > options.maximumOrderCount) {
301
+ throw new Error(`Expected ${options.minimumOrderCount}-${options.maximumOrderCount} visible Perps orders, but observed ${state.orders.length}.`);
302
+ }
303
+ if (state.activity.length < options.minimumActivityCount) {
304
+ throw new Error(`Expected at least ${options.minimumActivityCount} visible Perps activity entries, but observed ${state.activity.length}.`);
305
+ }
306
+ const observed = new Set(state.observedTestIds);
307
+ const missing = options.requireTestIds.filter((testId) => !observed.has(testId));
308
+ if (missing.length > 0) {
309
+ throw new Error(`Missing required visible Perps test IDs: ${missing.join(', ')}.`);
310
+ }
311
+ const forbidden = options.forbidTestIds.filter((testId) => observed.has(testId));
312
+ if (forbidden.length > 0) {
313
+ throw new Error(`Forbidden visible Perps test IDs were observed: ${forbidden.join(', ')}.`);
314
+ }
315
+ const missingFeatures = options.requireFeatures.filter((feature) => state.features[feature] !== true);
316
+ if (missingFeatures.length > 0) {
317
+ throw new Error(`Missing required visible Perps features: ${missingFeatures.join(', ')}.`);
318
+ }
319
+ if (options.market) {
320
+ const requested = options.market.replace(/^XYZ-/u, 'XYZ:');
321
+ const matched = state.markets.some((entry) => entry.market === requested) ||
322
+ [...state.controls, ...state.positions, ...state.orders, ...state.activity]
323
+ .some((entry) => String(entry.label ?? entry.testId).toUpperCase().includes(requested));
324
+ if (!matched) {
325
+ throw new Error(`Expected visible Perps market ${requested}, but it was not observed.`);
326
+ }
327
+ }
328
+ if (options.direction && state.orderEntry?.direction !== options.direction) {
329
+ throw new Error(`Expected Perps order direction ${options.direction}, but observed ${state.orderEntry?.direction ?? 'none'}.`);
330
+ }
331
+ if (options.orderType && state.orderEntry?.orderType !== options.orderType) {
332
+ throw new Error(`Expected Perps order type ${options.orderType}, but observed ${state.orderEntry?.orderType ?? 'none'}.`);
333
+ }
334
+ if (options.amount !== undefined && !equalNumericText(state.orderEntry?.amount, options.amount)) {
335
+ throw new Error(`Expected Perps order amount ${options.amount}, but observed ${state.orderEntry?.amount ?? 'none'}.`);
336
+ }
337
+ if (options.percent !== undefined && !equalNumericText(state.orderEntry?.percent, options.percent)) {
338
+ throw new Error(`Expected Perps order percent ${options.percent}, but observed ${state.orderEntry?.percent ?? 'none'}.`);
339
+ }
340
+ if (options.leverage !== undefined && !equalNumericText(state.orderEntry?.leverage, options.leverage)) {
341
+ throw new Error(`Expected Perps leverage ${options.leverage}, but observed ${state.orderEntry?.leverage ?? 'none'}.`);
342
+ }
343
+ if (options.autoClose !== undefined && state.orderEntry?.autoClose !== options.autoClose) {
344
+ throw new Error(`Expected Perps auto-close ${options.autoClose}, but observed ${state.orderEntry?.autoClose ?? 'none'}.`);
345
+ }
346
+ if (options.submitEnabled !== undefined && state.orderEntry?.submit?.enabled !== options.submitEnabled) {
347
+ throw new Error(`Expected Perps submit enabled ${options.submitEnabled}, but observed ${state.orderEntry?.submit?.enabled ?? 'none'}.`);
348
+ }
349
+ const validationText = state.orderEntry?.validation?.join(' ') ?? '';
350
+ const missingValidation = options.requireValidationText.filter((entry) => !validationText.includes(entry));
351
+ if (missingValidation.length > 0) {
352
+ throw new Error(`Missing required Perps validation text: ${missingValidation.join(', ')}.`);
353
+ }
354
+ }
@@ -0,0 +1,54 @@
1
+ function requiredText(value, name) {
2
+ const resolved = String(value ?? '').trim();
3
+ if (!resolved) throw new Error(`metamask.swap_bridge ${name} is required.`);
4
+ return resolved;
5
+ }
6
+
7
+ function timeout(value) {
8
+ const resolved = Number(value ?? 60_000);
9
+ if (!Number.isInteger(resolved) || resolved < 1_000 || resolved > 180_000) {
10
+ throw new Error('metamask.swap_bridge timeout_ms must be an integer from 1000 to 180000.');
11
+ }
12
+ return resolved;
13
+ }
14
+
15
+ function plainDecimal(value) {
16
+ const text = String(value).trim();
17
+ const match = /^([+-]?)(\d+)(?:\.(\d*))?[eE]([+-]?\d+)$/u.exec(text);
18
+ if (!match) return text;
19
+ const [, sign, integer, fraction = '', exponentText] = match;
20
+ const digits = `${integer}${fraction}`;
21
+ const decimalIndex = integer.length + Number(exponentText);
22
+ if (decimalIndex <= 0) return `${sign}0.${'0'.repeat(-decimalIndex)}${digits}`;
23
+ if (decimalIndex >= digits.length) return `${sign}${digits}${'0'.repeat(decimalIndex - digits.length)}`;
24
+ return `${sign}${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`;
25
+ }
26
+
27
+ export function resolveAssetSelectionOptions(node = {}) {
28
+ const sourceSymbol = requiredText(node.source_symbol, 'source_symbol').toUpperCase();
29
+ const destinationSymbol = requiredText(node.destination_symbol, 'destination_symbol').toUpperCase();
30
+ return {
31
+ sourceSymbol,
32
+ destinationSymbol,
33
+ sourceChain: requiredText(node.source_chain, 'source_chain'),
34
+ destinationChain: requiredText(node.destination_chain, 'destination_chain'),
35
+ sourceQuery: String(node.source_query ?? sourceSymbol).trim(),
36
+ destinationQuery: String(node.destination_query ?? destinationSymbol).trim(),
37
+ sourceAssetId: String(node.source_asset_id ?? '').trim() || undefined,
38
+ destinationAssetId: String(node.destination_asset_id ?? '').trim() || undefined,
39
+ timeoutMs: timeout(node.timeout_ms),
40
+ };
41
+ }
42
+
43
+ export function resolveAmountOptions(node = {}) {
44
+ const amount = plainDecimal(requiredText(node.amount, 'amount'));
45
+ const numeric = Number(amount);
46
+ if (!Number.isFinite(numeric) || numeric <= 0) {
47
+ throw new Error('metamask.swap_bridge amount must be a positive number.');
48
+ }
49
+ return {
50
+ amount,
51
+ waitForQuote: node.wait_for_quote !== false,
52
+ timeoutMs: timeout(node.timeout_ms),
53
+ };
54
+ }
@@ -0,0 +1,97 @@
1
+ function text(value) {
2
+ if (typeof value !== 'string') return undefined;
3
+ const normalized = value.replace(/\s+/gu, ' ').trim();
4
+ return normalized ? normalized.slice(0, 500) : undefined;
5
+ }
6
+
7
+ function symbol(value) {
8
+ if (value == null) return undefined;
9
+ const normalized = String(value).trim().toUpperCase();
10
+ if (!normalized) return undefined;
11
+ return normalized;
12
+ }
13
+
14
+ export function resolveVisibleSwapBridgeOptions(node = {}) {
15
+ return {
16
+ sourceSymbol: symbol(node.source_symbol),
17
+ destinationSymbol: symbol(node.destination_symbol),
18
+ requireAmountInputs: node.require_amount_inputs !== false,
19
+ requireSettings: node.require_settings === true,
20
+ };
21
+ }
22
+
23
+ function normalizedItem(source) {
24
+ const testId = text(source?.testId ?? source?.test_id ?? source?.identifier);
25
+ if (!testId) return undefined;
26
+ return {
27
+ testId,
28
+ label: text(source?.label ?? source?.text) ?? null,
29
+ value: text(source?.value) ?? null,
30
+ enabled: source?.enabled !== false,
31
+ visible: source?.visible !== false,
32
+ };
33
+ }
34
+
35
+ function tokenLabel(item) {
36
+ return text(item?.label ?? item?.value) ?? null;
37
+ }
38
+
39
+ function matchesSymbol(value, expected) {
40
+ if (!expected) return true;
41
+ return new RegExp(`(^|[^A-Z0-9])${expected.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}([^A-Z0-9]|$)`, 'u')
42
+ .test(String(value ?? '').toUpperCase());
43
+ }
44
+
45
+ export function normalizeVisibleSwapBridgeState(raw, options, platform) {
46
+ const items = [...(raw.items ?? []), ...(raw.offscreenItems ?? [])]
47
+ .map(normalizedItem)
48
+ .filter(Boolean);
49
+ const unique = Array.from(new Map(items.map((item) => [item.testId, item])).values());
50
+ const byId = new Map(unique.map((item) => [item.testId, item]));
51
+ const sourceAsset = tokenLabel(byId.get(platform === 'extension' ? 'bridge-source-button' : 'source-token-area'));
52
+ const destinationAsset = tokenLabel(byId.get(platform === 'extension' ? 'bridge-destination-button' : 'dest-token-area'));
53
+ const sourceInput = byId.get(platform === 'extension' ? 'from-amount' : 'source-token-area-input');
54
+ const destinationInput = byId.get(platform === 'extension' ? 'to-amount' : 'dest-token-area-input');
55
+ const settings = byId.get(platform === 'extension' ? 'bridge__header-settings-button' : 'bridge-slippage-settings-button');
56
+ const noQuotes = byId.get(platform === 'extension' ? 'prepare-swap-page-error' : 'bridge-no-quotes');
57
+ const missingPrice = byId.get(platform === 'extension' ? 'bridge-missing-price-banner' : 'bridge-missing-price-banner');
58
+ const state = {
59
+ source: 'visible-ui',
60
+ platform,
61
+ surface: 'swap',
62
+ ...(text(raw.route) ? { route: text(raw.route) } : {}),
63
+ ...(text(raw.title) ? { title: text(raw.title) } : {}),
64
+ sourceAsset,
65
+ destinationAsset,
66
+ sourceAmount: text(sourceInput?.value ?? sourceInput?.label) ?? null,
67
+ destinationAmount: text(destinationInput?.value ?? destinationInput?.label) ?? null,
68
+ controls: unique.slice(0, 100),
69
+ features: {
70
+ 'amount-inputs': Boolean(sourceInput && destinationInput),
71
+ settings: Boolean(settings),
72
+ 'asset-selectors': Boolean(sourceAsset && destinationAsset),
73
+ },
74
+ quoteState: noQuotes ? 'unavailable' : missingPrice ? 'missing-price' : 'idle-or-ready',
75
+ truncated: raw.truncated === true || unique.length > 100,
76
+ };
77
+ assertVisibleSwapBridgeState(state, options);
78
+ return state;
79
+ }
80
+
81
+ function assertVisibleSwapBridgeState(state, options) {
82
+ if (!state.features['asset-selectors']) {
83
+ throw new Error('Visible Swap source and destination token selectors were not both observed.');
84
+ }
85
+ if (options.requireAmountInputs && !state.features['amount-inputs']) {
86
+ throw new Error('Visible Swap source and destination amount inputs were not both observed.');
87
+ }
88
+ if (options.requireSettings && !state.features.settings) {
89
+ throw new Error('Visible Swap settings control was not observed.');
90
+ }
91
+ if (!matchesSymbol(state.sourceAsset, options.sourceSymbol)) {
92
+ throw new Error(`Expected visible Swap source ${options.sourceSymbol}, but observed ${JSON.stringify(state.sourceAsset)}.`);
93
+ }
94
+ if (!matchesSymbol(state.destinationAsset, options.destinationSymbol)) {
95
+ throw new Error(`Expected visible Swap destination ${options.destinationSymbol}, but observed ${JSON.stringify(state.destinationAsset)}.`);
96
+ }
97
+ }