@deeeed/metamask-harness 0.36.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 (140) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +138 -0
  3. package/adapters/extension/inject.mjs +2 -0
  4. package/adapters/extension/launch-browser.cjs +24 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +17 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +164 -20
  15. package/dist/adapters/extension/runtime.js +169 -3
  16. package/dist/adapters/mobile/provision.js +38 -3
  17. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  18. package/dist/adapters/mobile/release-artifact.js +295 -0
  19. package/dist/adapters.js +78 -6
  20. package/dist/app-lifecycle.js +4 -3
  21. package/dist/command-contract.js +16 -0
  22. package/dist/commands/call.js +6 -2
  23. package/dist/commands/launch/extension.js +74 -9
  24. package/dist/commands/launch/index.js +3 -0
  25. package/dist/commands/launch/mobile.js +2 -0
  26. package/dist/commands/provision.js +2 -0
  27. package/dist/commands/run-engine.js +109 -14
  28. package/dist/commands/run.js +4 -2
  29. package/dist/commands/runtime-launch.js +178 -10
  30. package/dist/heal-bounds.js +1 -1
  31. package/dist/live-adapter-contract.js +3 -1
  32. package/dist/metamask-action-validation.js +47 -1
  33. package/dist/mm-harness-cli.js +31 -1
  34. package/dist/recipe-security.js +40 -0
  35. package/dist/run-diagnostics.js +1 -1
  36. package/dist/runner.js +3 -0
  37. package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
  38. package/library/actions/extension/assets/finish_send.mjs +128 -0
  39. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  40. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  41. package/library/actions/extension/assets/read_details.mjs +57 -0
  42. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  43. package/library/actions/extension/assets/review_send.mjs +23 -0
  44. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  45. package/library/actions/extension/assets/send.mjs +104 -0
  46. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  47. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  48. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  49. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  50. package/library/actions/extension/networks/add_custom.mjs +123 -0
  51. package/library/actions/extension/networks/custom_network.mjs +103 -0
  52. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  53. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  54. package/library/actions/extension/perps/close_orders.mjs +1 -0
  55. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  56. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  57. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  58. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  59. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  60. package/library/actions/extension/perps/perps.mjs +196 -2
  61. package/library/actions/extension/perps/place_order.mjs +5 -2
  62. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  63. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  64. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  65. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  66. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  67. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  68. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  69. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  70. package/library/actions/extension/platform/cdp.mjs +109 -3
  71. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  72. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  73. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  74. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  75. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  76. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  77. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  78. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  79. package/library/actions/extension/ui/navigate.mjs +264 -8
  80. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  81. package/library/actions/extension/wallet/import.mjs +13 -46
  82. package/library/actions/extension/wallet/lock.mjs +35 -0
  83. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  84. package/library/actions/extension/wallet/select_account.mjs +71 -7
  85. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  86. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  87. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  88. package/library/actions/mobile/platform/observe-ui.mjs +86 -2
  89. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  90. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  91. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  92. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  93. package/library/actions/mobile/ui/native-navigation.mjs +265 -0
  94. package/library/actions/mobile/ui/navigate.mjs +10 -1
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  96. package/library/actions/mobile/wallet/import.mjs +71 -2
  97. package/library/actions/mobile/wallet/native-ui.mjs +512 -0
  98. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  99. package/library/actions/mobile/wallet/reset.mjs +17 -4
  100. package/library/actions/shared/assets/visible-state.mjs +176 -0
  101. package/library/actions/shared/networks/visible-state.mjs +70 -0
  102. package/library/actions/shared/perps/visible-state.mjs +354 -0
  103. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  104. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  105. package/library/actions/shared/ui/locators.mjs +7 -0
  106. package/library/manifests/extension.action-manifest.json +1366 -184
  107. package/library/manifests/mobile.action-manifest.json +235 -1
  108. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  109. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  110. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  111. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  112. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  113. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  114. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  115. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  116. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  117. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  118. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  119. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  120. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  121. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  122. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  123. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  124. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  125. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  126. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  127. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  128. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  129. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  130. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  131. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  132. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  133. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  134. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  135. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  136. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  137. package/library/recipes/wallet/import.recipe.json +20 -1
  138. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  139. package/package.json +6 -5
  140. package/scripts/validate-release-report.mjs +319 -0
@@ -0,0 +1,37 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
3
+ import {
4
+ normalizeVisibleAssetsState,
5
+ resolveVisibleAssetsOptions,
6
+ } from '../../shared/assets/visible-state.mjs';
7
+
8
+ runAdapter(async (input) => {
9
+ const options = resolveVisibleAssetsOptions(input.node);
10
+ const observed = await observeNativeUi(
11
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
12
+ input.context,
13
+ );
14
+ const screen = observed.observations?.['ui.screen'];
15
+ const visible = observed.observations?.['ui.visible'];
16
+ if (!screen || !visible) {
17
+ const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
+ throw new Error(`Mobile visible Assets state is unavailable${warning ? `: ${warning}` : '.'}`);
19
+ }
20
+ const items = [...(visible.items ?? []), ...(visible.hidden_or_offscreen ?? [])]
21
+ .filter((item) => String(item.test_id ?? '').startsWith('asset-'))
22
+ .map((item) => ({
23
+ testId: item.test_id,
24
+ label: item.label,
25
+ symbol: String(item.test_id).slice('asset-'.length),
26
+ }));
27
+ return {
28
+ action: input.action,
29
+ ...normalizeVisibleAssetsState({
30
+ route: screen.route,
31
+ title: screen.title,
32
+ items,
33
+ truncated: visible.truncated,
34
+ }, options, 'mobile'),
35
+ proofPath: 'native-accessibility',
36
+ };
37
+ });
@@ -0,0 +1,31 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
3
+ import {
4
+ normalizeVisiblePerpsState,
5
+ resolveVisibleStateOptions,
6
+ } from '../../shared/perps/visible-state.mjs';
7
+
8
+ runAdapter(async (input) => {
9
+ const options = resolveVisibleStateOptions(input.node);
10
+ const observed = await observeNativeUi(
11
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
12
+ input.context,
13
+ );
14
+ const screen = observed.observations?.['ui.screen'];
15
+ const visible = observed.observations?.['ui.visible'];
16
+ if (!screen || !visible) {
17
+ const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
+ throw new Error(`Mobile visible Perps state is unavailable${warning ? `: ${warning}` : '.'}`);
19
+ }
20
+ return {
21
+ action: input.action,
22
+ ...normalizeVisiblePerpsState({
23
+ route: screen.route,
24
+ title: screen.title,
25
+ items: visible.items,
26
+ offscreenItems: visible.hidden_or_offscreen,
27
+ truncated: visible.truncated,
28
+ }, options, 'mobile'),
29
+ proofPath: 'native-accessibility',
30
+ };
31
+ });
@@ -0,0 +1,5 @@
1
+ export function nativeSessionName(env, device, app = 'io.metamask') {
2
+ const configured = String(env.METAMASK_RECIPE_MOBILE_SESSION ?? '').trim();
3
+ const source = configured || `mm-harness-${app}-${device}`;
4
+ return source.replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 120);
5
+ }
@@ -1,12 +1,14 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { createAgentDeviceClient } from 'agent-device';
3
4
 
4
5
  import { bridgeCommand } from './bridge.mjs';
6
+ import { nativeSessionName } from './native-session-name.mjs';
5
7
  import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
6
8
 
7
9
  const execFileAsync = promisify(execFile);
8
- const VISIBLE_LIMIT = 50;
9
- const HIDDEN_LIMIT = 50;
10
+ const VISIBLE_LIMIT = 200;
11
+ const HIDDEN_LIMIT = 100;
10
12
 
11
13
  export async function observeNativeUi(payload, context) {
12
14
  await hideAccessibilityMaskingHud(payload, context);
@@ -111,6 +113,9 @@ async function readAndroidHierarchy(node, env) {
111
113
  );
112
114
  }
113
115
  const serial = await resolveAndroidSerial(adbPath, node, env);
116
+ if (isOpaqueRuntime(env)) {
117
+ return readAndroidAgentDeviceHierarchy(serial, env);
118
+ }
114
119
  const { stdout } = await execFileAsync(
115
120
  adbPath,
116
121
  ['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'],
@@ -135,6 +140,85 @@ async function readAndroidHierarchy(node, env) {
135
140
  );
136
141
  }
137
142
 
143
+ async function readAndroidAgentDeviceHierarchy(serial, env) {
144
+ const app = text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask';
145
+ const session = nativeSessionName(env, serial, app);
146
+ const client = createAgentDeviceClient({
147
+ session,
148
+ lockPolicy: 'reject',
149
+ lockPlatform: 'android',
150
+ });
151
+ try {
152
+ const snapshot = await client.capture.snapshot({
153
+ platform: 'android',
154
+ target: 'mobile',
155
+ serial,
156
+ session,
157
+ app,
158
+ interactiveOnly: false,
159
+ forceFull: true,
160
+ });
161
+ const hierarchy = flattenAgentDeviceHierarchy(
162
+ snapshot.nodes,
163
+ app,
164
+ );
165
+ if (hierarchy.nodeCount === 0) {
166
+ throw new Error('Agent Device returned an empty Android accessibility hierarchy.');
167
+ }
168
+ return normalizedHierarchy(
169
+ 'agent-device-accessibility',
170
+ 'android',
171
+ serial,
172
+ hierarchy.visible,
173
+ hierarchy.hidden,
174
+ hierarchy,
175
+ );
176
+ } finally {
177
+ await client.sessions.close({ session, shutdown: false });
178
+ }
179
+ }
180
+
181
+ function flattenAgentDeviceHierarchy(nodes, appId) {
182
+ const visible = [];
183
+ const hidden = [];
184
+ let screenName;
185
+ for (const source of Array.isArray(nodes) ? nodes : []) {
186
+ const node = record(source);
187
+ const bundleId = text(node.bundleId);
188
+ if (bundleId && bundleId !== appId) continue;
189
+ const bounds = normalizeBounds(node.rect ?? node.bounds);
190
+ const label = text(node.label) ?? text(node.value);
191
+ const role = text(node.type)?.toLowerCase();
192
+ const testId = text(node.identifier);
193
+ if (!isActionableRole(role) && !testId) continue;
194
+ screenName ??= label;
195
+ const item = compactItem({
196
+ role,
197
+ label,
198
+ test_id: testId,
199
+ enabled: boolean(node.enabled),
200
+ selected: boolean(node.selected),
201
+ focused: boolean(node.focused),
202
+ bounds,
203
+ });
204
+ if (hasVisibleBounds(bounds)) {
205
+ visible.push(item);
206
+ } else {
207
+ hidden.push({ ...item, reason: 'hidden_or_offscreen' });
208
+ }
209
+ }
210
+ return {
211
+ visible,
212
+ hidden,
213
+ nodeCount: Array.isArray(nodes) ? nodes.length : 0,
214
+ screenName,
215
+ };
216
+ }
217
+
218
+ function isOpaqueRuntime(env) {
219
+ return env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1';
220
+ }
221
+
138
222
  function normalizedHierarchy(
139
223
  provider,
140
224
  platform,
@@ -0,0 +1,45 @@
1
+ import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
2
+ import { createAgentDeviceClient } from 'agent-device';
3
+ import { nativeSessionName } from '../platform/native-session-name.mjs';
4
+
5
+ export function createSwapNativeSession(input) {
6
+ const env = { ...process.env, ...(input.context?.env ?? {}) };
7
+ const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
8
+ if (!device) throw new Error('Opaque Mobile Swap actions require one explicit Android device serial.');
9
+ const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
10
+ const session = nativeSessionName(env, device, app);
11
+ const client = createAgentDeviceClient({ session, lockPolicy: 'reject', lockPlatform: 'android' });
12
+ const selection = { platform: 'android', target: 'mobile', serial: device, session };
13
+ const transport = createAgentDeviceUiTransport({ platform: 'android', device, app, session, client });
14
+ const context = {
15
+ nodeId: String(input.context?.nodeId ?? 'action'),
16
+ projectRoot: input.context.projectRoot,
17
+ artifactsDir: input.context.artifactsDir,
18
+ env,
19
+ };
20
+ return {
21
+ client,
22
+ transport,
23
+ selection,
24
+ context,
25
+ app,
26
+ execute: (action, node) => transport.execute(action, node, context),
27
+ snapshot: () => client.capture.snapshot({ ...selection, app, interactiveOnly: false, forceFull: true }),
28
+ };
29
+ }
30
+
31
+ export async function closeSwapNativeSession(session) {
32
+ try {
33
+ await session.transport.close();
34
+ } catch (error) {
35
+ if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
36
+ }
37
+ }
38
+
39
+ export function nativeNode(snapshot, identifier) {
40
+ return snapshot.nodes.find((node) => String(node.identifier ?? '') === identifier);
41
+ }
42
+
43
+ export function nativeLabel(node) {
44
+ return String(node?.label ?? node?.value ?? '').replace(/\s+/gu, ' ').trim();
45
+ }
@@ -0,0 +1,31 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
3
+ import {
4
+ normalizeVisibleSwapBridgeState,
5
+ resolveVisibleSwapBridgeOptions,
6
+ } from '../../shared/swap-bridge/visible-state.mjs';
7
+
8
+ runAdapter(async (input) => {
9
+ const options = resolveVisibleSwapBridgeOptions(input.node);
10
+ const observed = await observeNativeUi(
11
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
12
+ input.context,
13
+ );
14
+ const screen = observed.observations?.['ui.screen'];
15
+ const visible = observed.observations?.['ui.visible'];
16
+ if (!screen || !visible) {
17
+ const warning = observed.warnings?.map((entry) => entry.message).filter(Boolean).join('; ');
18
+ throw new Error(`Mobile visible Swap state is unavailable${warning ? `: ${warning}` : '.'}`);
19
+ }
20
+ return {
21
+ action: input.action,
22
+ ...normalizeVisibleSwapBridgeState({
23
+ route: screen.route,
24
+ title: screen.title,
25
+ items: visible.items,
26
+ offscreenItems: visible.hidden_or_offscreen,
27
+ truncated: visible.truncated,
28
+ }, options, 'mobile'),
29
+ proofPath: 'native-accessibility',
30
+ };
31
+ });
@@ -0,0 +1,94 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { resolveAssetSelectionOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+ import {
4
+ closeSwapNativeSession,
5
+ createSwapNativeSession,
6
+ nativeLabel,
7
+ nativeNode,
8
+ } from './native-session.mjs';
9
+
10
+ async function waitFor(session, identifier, timeoutMs) {
11
+ await session.execute('ui.wait_for', {
12
+ test_id: identifier,
13
+ expected: 'visible',
14
+ timeout_ms: timeoutMs,
15
+ settle: false,
16
+ });
17
+ }
18
+
19
+ function androidChainId(chain) {
20
+ const match = String(chain).match(/^eip155:(\d+)$/u);
21
+ return match ? `0x${Number(match[1]).toString(16)}` : String(chain);
22
+ }
23
+
24
+ async function selectAsset(session, role, asset, timeoutMs, pickerAlreadyOpen = false) {
25
+ const picker = role === 'source' ? 'source-token-area' : 'dest-token-area';
26
+ if (!pickerAlreadyOpen) await session.execute('ui.press', { test_id: picker, timeout_ms: timeoutMs });
27
+ await waitFor(session, 'bridge-token-search-input', timeoutMs);
28
+ await session.execute('ui.press', { text: 'All', timeout_ms: timeoutMs });
29
+ await session.execute('ui.set_input', {
30
+ test_id: 'bridge-token-search-input',
31
+ value: asset.query,
32
+ timeout_ms: timeoutMs,
33
+ });
34
+ const row = `asset-${androidChainId(asset.chain)}-${asset.symbol}`;
35
+ await session.execute('ui.key_press', { key: 'BACK', timeout_ms: timeoutMs });
36
+ await revealAssetRow(session, row, timeoutMs);
37
+ await session.execute('ui.press', { test_id: row, timeout_ms: timeoutMs });
38
+ await waitFor(session, picker, timeoutMs);
39
+ const selected = nativeLabel(nativeNode(await session.snapshot(), picker));
40
+ if (!selected.toUpperCase().includes(asset.symbol)) {
41
+ throw new Error(`Swap ${role} asset ${asset.symbol} was not selected; observed ${JSON.stringify(selected)}.`);
42
+ }
43
+ return { role, testId: row, symbol: asset.symbol, chain: asset.chain };
44
+ }
45
+
46
+ async function revealAssetRow(session, row, timeoutMs) {
47
+ const deadline = Date.now() + timeoutMs;
48
+ while (Date.now() <= deadline) {
49
+ if (nativeNode(await session.snapshot(), row)) return;
50
+ await session.execute('ui.scroll', {
51
+ direction: 'down',
52
+ amount: 0.75,
53
+ timeout_ms: Math.max(1, deadline - Date.now()),
54
+ settle: false,
55
+ });
56
+ }
57
+ throw new Error(`Swap asset row ${row} was not visible in the all-network token list.`);
58
+ }
59
+
60
+ async function closeOpenPicker(session, timeoutMs) {
61
+ for (let attempt = 0; attempt < 2; attempt += 1) {
62
+ const snapshot = await session.snapshot();
63
+ if (!nativeNode(snapshot, 'bridge-token-search-input')) return;
64
+ await session.execute('ui.key_press', { key: 'BACK', timeout_ms: timeoutMs });
65
+ }
66
+ if (nativeNode(await session.snapshot(), 'bridge-token-search-input')) {
67
+ throw new Error('Swap token picker remained open after dismissing its keyboard and sheet.');
68
+ }
69
+ }
70
+
71
+ runAdapter(async (input) => {
72
+ const options = resolveAssetSelectionOptions(input.node);
73
+ if (options.sourceAssetId || options.destinationAssetId) {
74
+ throw new Error('Mobile Swap selection cannot disambiguate asset IDs from native accessibility. Next: omit source_asset_id and destination_asset_id or run the Extension recipe.');
75
+ }
76
+ const session = createSwapNativeSession(input);
77
+ try {
78
+ await closeOpenPicker(session, options.timeoutMs);
79
+ await waitFor(session, 'source-token-area-input', options.timeoutMs);
80
+ const source = await selectAsset(session, 'source', {
81
+ chain: options.sourceChain,
82
+ symbol: options.sourceSymbol,
83
+ query: options.sourceQuery,
84
+ }, options.timeoutMs);
85
+ const destination = await selectAsset(session, 'destination', {
86
+ chain: options.destinationChain,
87
+ symbol: options.destinationSymbol,
88
+ query: options.destinationQuery,
89
+ }, options.timeoutMs);
90
+ return { action: input.action, source, destination, proofPath: 'native-accessibility-selection' };
91
+ } finally {
92
+ await closeSwapNativeSession(session);
93
+ }
94
+ });
@@ -0,0 +1,73 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
3
+ import {
4
+ closeSwapNativeSession,
5
+ createSwapNativeSession,
6
+ nativeLabel,
7
+ nativeNode,
8
+ } from './native-session.mjs';
9
+
10
+ async function replaceNumericInput(session, amount, timeoutMs) {
11
+ await session.execute('ui.press', {
12
+ test_id: 'source-token-area-input',
13
+ timeout_ms: timeoutMs,
14
+ settle: false,
15
+ });
16
+ let current = nativeLabel(nativeNode(await session.snapshot(), 'source-token-area-input'));
17
+ for (let index = 0; index < Math.min(50, current.length + 2); index += 1) {
18
+ await session.execute('ui.press', {
19
+ test_id: 'keypad-delete-button',
20
+ timeout_ms: timeoutMs,
21
+ settle: false,
22
+ });
23
+ }
24
+ current = nativeLabel(nativeNode(await session.snapshot(), 'source-token-area-input'));
25
+ if (current !== '0' && current !== '') {
26
+ throw new Error(`Swap amount keypad could not clear the previous value ${JSON.stringify(current)}.`);
27
+ }
28
+ const keys = amount.startsWith('0.') && current === '0' ? amount.slice(1) : amount;
29
+ for (const key of keys) {
30
+ if (!/[0-9.]/u.test(key)) throw new Error(`Swap amount contains unsupported keypad character ${JSON.stringify(key)}.`);
31
+ await session.execute('ui.press', {
32
+ test_id: key === '.' ? 'keypad-key-dot' : `keypad-key-${key}`,
33
+ timeout_ms: timeoutMs,
34
+ settle: false,
35
+ });
36
+ }
37
+ await new Promise((resolve) => setTimeout(resolve, 300));
38
+ }
39
+
40
+ runAdapter(async (input) => {
41
+ const options = resolveAmountOptions(input.node);
42
+ const session = createSwapNativeSession(input);
43
+ try {
44
+ await replaceNumericInput(session, options.amount, options.timeoutMs);
45
+ const deadline = Date.now() + Math.max(1_000, options.timeoutMs - 10_000);
46
+ let observed;
47
+ do {
48
+ const snapshot = await session.snapshot();
49
+ const sourceAmount = nativeLabel(nativeNode(snapshot, 'source-token-area-input'));
50
+ const destinationAmount = nativeLabel(nativeNode(snapshot, 'dest-token-area-input'));
51
+ const cta = nativeNode(snapshot, 'bridge-confirm-button') ?? nativeNode(snapshot, 'bridge-confirm-button-keypad');
52
+ const noQuote = nativeNode(snapshot, 'bridge-no-quotes');
53
+ observed = {
54
+ sourceAmount,
55
+ destinationAmount,
56
+ cta: nativeLabel(cta) || null,
57
+ ctaEnabled: cta?.enabled !== false,
58
+ noQuote: Boolean(noQuote),
59
+ };
60
+ if (!options.waitForQuote || observed.noQuote || (Number(destinationAmount) > 0 && cta)) break;
61
+ await new Promise((resolve) => setTimeout(resolve, 250));
62
+ } while (Date.now() < deadline);
63
+ if (observed.sourceAmount !== options.amount) {
64
+ throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
65
+ }
66
+ if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0 && observed.cta)) {
67
+ throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
68
+ }
69
+ return { action: input.action, ...observed, proofPath: 'native-accessibility-amount' };
70
+ } finally {
71
+ await closeSwapNativeSession(session);
72
+ }
73
+ });