@deeeed/metamask-harness 0.37.0 → 0.38.1
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.
- package/CHANGELOG.md +33 -0
- package/adapters/extension/artifact-runtime-state.cjs +13 -3
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +2 -0
- package/adapters/extension/live.sh +1 -1
- package/adapters/extension/readiness.mjs +1 -1
- package/adapters/manifest.json +8 -0
- package/dist/adapters/extension/runtime-decision.js +4 -1
- package/dist/adapters/extension/runtime.js +42 -3
- package/dist/adapters/mobile/provision.js +38 -3
- package/dist/adapters.js +68 -3
- package/dist/app-lifecycle.js +4 -3
- package/dist/commands/call.js +4 -1
- package/dist/commands/launch/extension.js +21 -5
- package/dist/commands/launch/index.js +3 -0
- package/dist/commands/run-engine.js +40 -29
- package/dist/commands/run.js +1 -1
- package/dist/recipe-security.js +40 -0
- package/dist/runner.js +3 -0
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
- package/library/actions/extension/assets/finish_send.mjs +128 -0
- package/library/actions/extension/assets/import_custom_token.mjs +227 -0
- package/library/actions/extension/assets/prepare_send.mjs +53 -0
- package/library/actions/extension/assets/read_details.mjs +57 -0
- package/library/actions/extension/assets/read_visible_state.mjs +50 -0
- package/library/actions/extension/assets/review_send.mjs +23 -0
- package/library/actions/extension/assets/select_network_scope.mjs +30 -0
- package/library/actions/extension/assets/send.mjs +104 -0
- package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
- package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
- package/library/actions/extension/assets/verify_sorting.mjs +89 -0
- package/library/actions/extension/networks/add_chainlist.mjs +94 -0
- package/library/actions/extension/networks/add_custom.mjs +123 -0
- package/library/actions/extension/networks/custom_network.mjs +103 -0
- package/library/actions/extension/networks/read_visible_state.mjs +50 -0
- package/library/actions/extension/networks/remove_custom.mjs +68 -0
- package/library/actions/extension/perps/close_orders.mjs +1 -0
- package/library/actions/extension/perps/close_visible_position.mjs +133 -0
- package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
- package/library/actions/extension/perps/edit_margin.mjs +82 -0
- package/library/actions/extension/perps/open_balance_action.mjs +53 -0
- package/library/actions/extension/perps/open_position_action.mjs +80 -0
- package/library/actions/extension/perps/perps.mjs +194 -2
- package/library/actions/extension/perps/place_order.mjs +5 -2
- package/library/actions/extension/perps/read_eligibility.mjs +34 -0
- package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
- package/library/actions/extension/perps/read_visible_state.mjs +98 -0
- package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
- package/library/actions/extension/perps/select_market_filter.mjs +72 -0
- package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
- package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
- package/library/actions/extension/platform/cdp.mjs +104 -1
- package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
- package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
- package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
- package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
- package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
- package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
- package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
- package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
- package/library/actions/extension/ui/navigate.mjs +264 -8
- package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
- package/library/actions/extension/wallet/lock.mjs +35 -0
- package/library/actions/extension/wallet/select_account.mjs +71 -7
- package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
- package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
- package/library/actions/mobile/platform/native-session-name.mjs +5 -0
- package/library/actions/mobile/platform/observe-ui.mjs +5 -3
- package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
- package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
- package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
- package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
- package/library/actions/mobile/ui/native-navigation.mjs +45 -5
- package/library/actions/mobile/ui/navigate.mjs +3 -1
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
- package/library/actions/mobile/wallet/native-ui.mjs +21 -2
- package/library/actions/shared/assets/visible-state.mjs +176 -0
- package/library/actions/shared/networks/visible-state.mjs +70 -0
- package/library/actions/shared/perps/visible-state.mjs +354 -0
- package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
- package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
- package/library/manifests/extension.action-manifest.json +1255 -189
- package/library/manifests/mobile.action-manifest.json +219 -1
- package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
- package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
- package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
- package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
- package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
- package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
- package/library/recipes/assets/release-token-details.recipe.json +48 -0
- package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
- package/library/recipes/assets/release-token-list.recipe.json +75 -0
- package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
- package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
- package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
- package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
- package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
- package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
- package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
- package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
- package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
- package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
- package/library/recipes/perps/release-market-details.recipe.json +83 -0
- package/library/recipes/perps/release-market-search.recipe.json +79 -0
- package/library/recipes/perps/release-watchlist.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
- package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
- package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
- package/package.json +6 -5
- package/scripts/validate-release-report.mjs +333 -0
|
@@ -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
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
|
|
2
2
|
import { createAgentDeviceClient } from 'agent-device';
|
|
3
|
+
import { nativeSessionName } from '../platform/native-session-name.mjs';
|
|
3
4
|
|
|
4
5
|
const IDS = {
|
|
5
6
|
wallet: ['wallet-safe-area', 'homepage-container'],
|
|
@@ -7,6 +8,10 @@ const IDS = {
|
|
|
7
8
|
tradeTab: 'tab-bar-item-Trade',
|
|
8
9
|
perpsEntry: 'homepage-balance-breakdown-row-perps',
|
|
9
10
|
perpsTradeEntry: 'wallet-perps-action',
|
|
11
|
+
swapTradeEntry: 'wallet-actions-bottom-sheet-swap-button',
|
|
12
|
+
swapSourceInput: 'source-token-area-input',
|
|
13
|
+
swapSourceToken: 'source-token-area',
|
|
14
|
+
swapDestinationToken: 'dest-token-area',
|
|
10
15
|
perpsHome: 'perps-home',
|
|
11
16
|
perpsHomeBack: 'perps-home-back-button',
|
|
12
17
|
tutorialSkip: 'perps-tutorial-skip-button',
|
|
@@ -27,8 +32,7 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
27
32
|
throw new Error('Opaque Mobile navigation requires one explicit Android device serial.');
|
|
28
33
|
}
|
|
29
34
|
const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
|
|
30
|
-
const session =
|
|
31
|
-
.replace(/[^A-Za-z0-9._-]/gu, '-');
|
|
35
|
+
const session = nativeSessionName(env, device, app);
|
|
32
36
|
const client = createAgentDeviceClient({
|
|
33
37
|
session,
|
|
34
38
|
lockPolicy: 'reject',
|
|
@@ -84,6 +88,10 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
84
88
|
if (destination.page === 'home') {
|
|
85
89
|
await navigateHome({ identifiers, press, wait, back });
|
|
86
90
|
observedScreen = 'wallet-home';
|
|
91
|
+
} else if (destination.page === 'swap') {
|
|
92
|
+
observedScreen = await navigateSwap({ identifiers, press, wait, back });
|
|
93
|
+
} else if (destination.page === 'perps-market-list') {
|
|
94
|
+
observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000) });
|
|
87
95
|
} else {
|
|
88
96
|
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
89
97
|
const initialIds = await identifiers();
|
|
@@ -116,10 +124,29 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
116
124
|
observedScreen,
|
|
117
125
|
};
|
|
118
126
|
} finally {
|
|
119
|
-
await closeNativeSession(
|
|
127
|
+
await closeNativeSession(client, selection);
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
|
|
131
|
+
async function navigateSwap({ identifiers, press, wait, back }) {
|
|
132
|
+
let ids = await identifiers();
|
|
133
|
+
if (ids.has(IDS.swapSourceInput)) return IDS.swapSourceInput;
|
|
134
|
+
if (!ids.has(IDS.swapTradeEntry)) {
|
|
135
|
+
await navigateHome({ identifiers, press, wait, back });
|
|
136
|
+
await press(IDS.tradeTab);
|
|
137
|
+
await wait(IDS.swapTradeEntry);
|
|
138
|
+
ids = await identifiers();
|
|
139
|
+
}
|
|
140
|
+
if (!ids.has(IDS.swapTradeEntry)) {
|
|
141
|
+
throw new Error('Opaque Mobile navigation could not find the visible Swap entry.');
|
|
142
|
+
}
|
|
143
|
+
await press(IDS.swapTradeEntry);
|
|
144
|
+
await wait(IDS.swapSourceInput);
|
|
145
|
+
await wait(IDS.swapSourceToken);
|
|
146
|
+
await wait(IDS.swapDestinationToken);
|
|
147
|
+
return IDS.swapSourceInput;
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs }) {
|
|
124
151
|
const market = String(destination.params.market.symbol).toUpperCase();
|
|
125
152
|
await press(`perps-market-row-item-${market}`);
|
|
@@ -195,6 +222,20 @@ async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
|
|
|
195
222
|
return waitForPerpsDestination(identifiers, timeoutMs);
|
|
196
223
|
}
|
|
197
224
|
|
|
225
|
+
async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs }) {
|
|
226
|
+
let ids = await identifiers();
|
|
227
|
+
if (ids.has(IDS.marketList)) return IDS.marketList;
|
|
228
|
+
const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs });
|
|
229
|
+
ids = await identifiers();
|
|
230
|
+
if (ids.has(IDS.marketList)) return IDS.marketList;
|
|
231
|
+
if (ids.has(IDS.proMarketList)) {
|
|
232
|
+
await press(IDS.proMarketList);
|
|
233
|
+
await wait(IDS.marketList);
|
|
234
|
+
return IDS.marketList;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
|
|
237
|
+
}
|
|
238
|
+
|
|
198
239
|
async function waitForPerpsDestination(identifiers, timeoutMs) {
|
|
199
240
|
const deadline = Date.now() + timeoutMs;
|
|
200
241
|
while (Date.now() <= deadline) {
|
|
@@ -207,8 +248,7 @@ async function waitForPerpsDestination(identifiers, timeoutMs) {
|
|
|
207
248
|
throw new Error('Opaque Mobile navigation did not reach a visible Perps destination.');
|
|
208
249
|
}
|
|
209
250
|
|
|
210
|
-
async function closeNativeSession(
|
|
211
|
-
await transport.close();
|
|
251
|
+
async function closeNativeSession(client, selection) {
|
|
212
252
|
try {
|
|
213
253
|
await client.sessions.close({ ...selection, responseLevel: 'digest' });
|
|
214
254
|
} catch (error) {
|
|
@@ -4,6 +4,8 @@ import { navigateOpaqueMobile } from './native-navigation.mjs';
|
|
|
4
4
|
const PAGE_ROUTES = {
|
|
5
5
|
home: { route: 'WalletView', params: {} },
|
|
6
6
|
perps: { route: 'PerpsMarketListView', params: {} },
|
|
7
|
+
'perps-market-list': { route: 'PerpsMarketListView', params: {} },
|
|
8
|
+
swap: { route: 'BridgeView', params: {} },
|
|
7
9
|
};
|
|
8
10
|
|
|
9
11
|
function text(value) {
|
|
@@ -19,7 +21,7 @@ function pageRoute(node) {
|
|
|
19
21
|
if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
|
|
20
22
|
return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market } } };
|
|
21
23
|
}
|
|
22
|
-
throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market.');
|
|
24
|
+
throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
runAdapter(async (input) => {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
selectBridgeStatusEntry,
|
|
7
7
|
} from '../platform/bridge.mjs';
|
|
8
8
|
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
9
|
+
import { isOpaqueMobileRuntime, unlockWalletThroughNativeUi } from './native-ui.mjs';
|
|
9
10
|
|
|
10
11
|
async function fixturePassword(projectRoot) {
|
|
11
12
|
const candidates = [
|
|
@@ -156,6 +157,22 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
|
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
export async function ensureUnlocked(input) {
|
|
160
|
+
if (isOpaqueMobileRuntime(input)) {
|
|
161
|
+
const timeoutMs = Number(input.node?.unlock_timeout_ms ?? 30_000);
|
|
162
|
+
const password = input.node?.password ?? await fixturePassword(input.context.projectRoot);
|
|
163
|
+
const proof = await unlockWalletThroughNativeUi(input, String(password), timeoutMs);
|
|
164
|
+
return {
|
|
165
|
+
action: input.action,
|
|
166
|
+
unlocked: true,
|
|
167
|
+
alreadyUnlocked: proof.alreadyReady,
|
|
168
|
+
account: proof.selectedAddress || proof.selectedAddressObserved
|
|
169
|
+
? { address: proof.selectedAddress ?? proof.selectedAddressObserved }
|
|
170
|
+
: null,
|
|
171
|
+
redacted: true,
|
|
172
|
+
provider: proof.provider,
|
|
173
|
+
proofPath: proof.proofPath,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
159
176
|
const targetTimeoutMs = Number(input.node?.target_timeout_ms ?? 20000);
|
|
160
177
|
const targetDeadline = Date.now() + targetTimeoutMs;
|
|
161
178
|
const targetStatus = await waitForTargetStatus(input, targetTimeoutMs);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
|
|
2
2
|
import { createAgentDeviceClient } from 'agent-device';
|
|
3
|
+
import { nativeSessionName } from '../platform/native-session-name.mjs';
|
|
3
4
|
|
|
4
5
|
const TEST_IDS = {
|
|
5
6
|
existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
|
|
@@ -163,6 +164,25 @@ export async function readWalletThroughNativeUi(input) {
|
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
export async function unlockWalletThroughNativeUi(input, password, timeoutMs) {
|
|
168
|
+
const native = nativeSession(input);
|
|
169
|
+
try {
|
|
170
|
+
const initial = await native.normalizeWalletSurface(timeoutMs);
|
|
171
|
+
if (initial.wallet) {
|
|
172
|
+
return await native.walletProof(undefined, true, timeoutMs);
|
|
173
|
+
}
|
|
174
|
+
if (!initial.visible.has(TEST_IDS.login)) {
|
|
175
|
+
throw new Error('Opaque Mobile wallet unlock could not find the visible login screen.');
|
|
176
|
+
}
|
|
177
|
+
await native.fill(TEST_IDS.loginPassword, password, timeoutMs);
|
|
178
|
+
await native.press(TEST_IDS.loginButton, timeoutMs);
|
|
179
|
+
await native.waitForWallet(timeoutMs);
|
|
180
|
+
return await native.walletProof(undefined, false, timeoutMs);
|
|
181
|
+
} finally {
|
|
182
|
+
await native.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
export async function resetWalletThroughNativeUi(input) {
|
|
167
187
|
const native = nativeSession(input);
|
|
168
188
|
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
@@ -198,8 +218,7 @@ function nativeSession(input) {
|
|
|
198
218
|
const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
|
|
199
219
|
if (!device) throw new Error('Opaque Mobile UI execution requires one explicit Android device serial.');
|
|
200
220
|
const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
|
|
201
|
-
const session =
|
|
202
|
-
.replace(/[^A-Za-z0-9._-]/gu, '-');
|
|
221
|
+
const session = nativeSessionName(env, device, app);
|
|
203
222
|
const client = createAgentDeviceClient({
|
|
204
223
|
session,
|
|
205
224
|
lockPolicy: 'reject',
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
function boundedInteger(value, fallback, minimum, maximum, name) {
|
|
2
|
+
const resolved = value == null ? fallback : Number(value);
|
|
3
|
+
if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
4
|
+
throw new Error(
|
|
5
|
+
`metamask.assets.read_visible_state ${name} must be an integer from ${minimum} to ${maximum}.`,
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
return resolved;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function stringList(value, name, maximum = 50) {
|
|
12
|
+
if (value == null) return [];
|
|
13
|
+
if (!Array.isArray(value)) {
|
|
14
|
+
throw new Error(`metamask.assets.read_visible_state ${name} must be an array.`);
|
|
15
|
+
}
|
|
16
|
+
const values = Array.from(new Set(value.map((entry) => String(entry).trim().toUpperCase()).filter(Boolean)));
|
|
17
|
+
if (values.length > maximum) {
|
|
18
|
+
throw new Error(`metamask.assets.read_visible_state ${name} accepts at most ${maximum} values.`);
|
|
19
|
+
}
|
|
20
|
+
return values;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveVisibleAssetsOptions(node = {}) {
|
|
24
|
+
return {
|
|
25
|
+
maxItems: boundedInteger(node.max_items, 100, 1, 200, 'max_items'),
|
|
26
|
+
minimumAssetCount: boundedInteger(node.minimum_asset_count, 0, 0, 200, 'minimum_asset_count'),
|
|
27
|
+
minimumPriceChangeCount: boundedInteger(
|
|
28
|
+
node.minimum_price_change_count,
|
|
29
|
+
0,
|
|
30
|
+
0,
|
|
31
|
+
200,
|
|
32
|
+
'minimum_price_change_count',
|
|
33
|
+
),
|
|
34
|
+
minimumWithoutPriceChangeCount: boundedInteger(
|
|
35
|
+
node.minimum_without_price_change_count,
|
|
36
|
+
0,
|
|
37
|
+
0,
|
|
38
|
+
200,
|
|
39
|
+
'minimum_without_price_change_count',
|
|
40
|
+
),
|
|
41
|
+
requireSymbols: stringList(node.require_symbols, 'require_symbols'),
|
|
42
|
+
requireFiat: node.require_fiat === true,
|
|
43
|
+
requirePriceChangeStyling: node.require_price_change_styling === true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function text(value) {
|
|
48
|
+
if (typeof value !== 'string') return undefined;
|
|
49
|
+
const normalized = value.replace(/\s+/gu, ' ').trim();
|
|
50
|
+
return normalized ? normalized.slice(0, 500) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function firstMatch(value, expression) {
|
|
54
|
+
return text(value)?.match(expression)?.[0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function priceChangeFromLabel(value) {
|
|
58
|
+
const label = text(value);
|
|
59
|
+
if (!label) return undefined;
|
|
60
|
+
for (const match of label.matchAll(/[+-]?\d+(?:\.\d+)?%/gu)) {
|
|
61
|
+
const following = label.slice((match.index ?? 0) + match[0].length, (match.index ?? 0) + match[0].length + 8);
|
|
62
|
+
if (!/^\s*APY\b/iu.test(following)) return match[0];
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function symbolFromAmount(value) {
|
|
68
|
+
return text(value)?.match(/\b([A-Za-z][A-Za-z0-9]{1,11})$/u)?.[1]?.toUpperCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function symbolFromTestId(value) {
|
|
72
|
+
const testId = text(value);
|
|
73
|
+
if (!testId?.startsWith('asset-')) return undefined;
|
|
74
|
+
return testId.slice('asset-'.length).trim().toUpperCase() || undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeItem(source) {
|
|
78
|
+
const label = text(source?.label);
|
|
79
|
+
const amount = text(source?.amount) ?? text(label?.split(',').at(-1));
|
|
80
|
+
const fiat = text(source?.fiat) ?? firstMatch(label, /[$€£¥]\s?\d[\d,]*(?:\.\d{2})(?!\d)/u);
|
|
81
|
+
const priceChange = text(source?.priceChange) ?? priceChangeFromLabel(label);
|
|
82
|
+
const symbol = text(source?.symbol)?.toUpperCase() ?? symbolFromTestId(source?.testId) ?? symbolFromAmount(amount);
|
|
83
|
+
return {
|
|
84
|
+
testId: text(source?.testId) ?? null,
|
|
85
|
+
symbol: symbol ?? null,
|
|
86
|
+
name: text(source?.name) ?? text(label?.split(',')[0]) ?? symbol ?? null,
|
|
87
|
+
fiat: fiat ?? null,
|
|
88
|
+
amount: amount ?? null,
|
|
89
|
+
priceChange: priceChange ?? null,
|
|
90
|
+
priceChangeTone: text(source?.priceChangeTone)?.toLowerCase() ?? null,
|
|
91
|
+
...(label ? { label } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function normalizeVisibleAssetsState(raw, options, platform) {
|
|
96
|
+
const assets = (Array.isArray(raw.items) ? raw.items : [])
|
|
97
|
+
.slice(0, options.maxItems)
|
|
98
|
+
.map(normalizeItem)
|
|
99
|
+
.filter((item) => item.symbol || item.name);
|
|
100
|
+
const malformed = assets.filter((item) =>
|
|
101
|
+
/(?:\bNaN\b|\bundefined\b|\bnull\b)/u.test(
|
|
102
|
+
[item.label, item.fiat, item.amount, item.priceChange].filter(Boolean).join(' '),
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
const state = {
|
|
106
|
+
source: 'visible-ui',
|
|
107
|
+
platform,
|
|
108
|
+
surface: 'token-list',
|
|
109
|
+
...(text(raw.route) ? { route: text(raw.route) } : {}),
|
|
110
|
+
...(text(raw.title) ? { title: text(raw.title) } : {}),
|
|
111
|
+
assets,
|
|
112
|
+
observedSymbols: Array.from(new Set(assets.map((item) => item.symbol).filter(Boolean))),
|
|
113
|
+
features: {
|
|
114
|
+
'token-list': assets.length > 0,
|
|
115
|
+
fiat: assets.some((item) => item.fiat !== null),
|
|
116
|
+
'price-change': assets.some((item) => item.priceChange !== null),
|
|
117
|
+
'price-change-styling': assets.some((item) => item.priceChangeTone !== null),
|
|
118
|
+
},
|
|
119
|
+
malformedValueCount: malformed.length,
|
|
120
|
+
truncated: raw.truncated === true || (raw.items?.length ?? 0) > options.maxItems,
|
|
121
|
+
};
|
|
122
|
+
assertVisibleAssetsState(state, options);
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertVisibleAssetsState(state, options) {
|
|
127
|
+
if (state.assets.length < options.minimumAssetCount) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Expected at least ${options.minimumAssetCount} visible assets, but observed ${state.assets.length}.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const observed = new Set(state.observedSymbols);
|
|
133
|
+
const missing = options.requireSymbols.filter((symbol) => !observed.has(symbol));
|
|
134
|
+
if (missing.length > 0) {
|
|
135
|
+
throw new Error(`Missing required visible asset symbols: ${missing.join(', ')}.`);
|
|
136
|
+
}
|
|
137
|
+
if (options.requireFiat) {
|
|
138
|
+
const missingFiat = state.assets
|
|
139
|
+
.filter((item) => options.requireSymbols.length === 0 || options.requireSymbols.includes(item.symbol))
|
|
140
|
+
.filter((item) => item.fiat === null)
|
|
141
|
+
.map((item) => item.symbol ?? item.name);
|
|
142
|
+
if (missingFiat.length > 0) {
|
|
143
|
+
throw new Error(`Required visible assets lack formatted fiat values: ${missingFiat.join(', ')}.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const priceChangeCount = state.assets.filter((item) => item.priceChange !== null).length;
|
|
147
|
+
if (priceChangeCount < options.minimumPriceChangeCount) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Expected price changes for at least ${options.minimumPriceChangeCount} visible assets, but observed ${priceChangeCount}.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const withoutPriceChangeCount = state.assets.length - priceChangeCount;
|
|
153
|
+
if (withoutPriceChangeCount < options.minimumWithoutPriceChangeCount) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Expected at least ${options.minimumWithoutPriceChangeCount} clean assets without price-change data, but observed ${withoutPriceChangeCount}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (state.malformedValueCount > 0) {
|
|
159
|
+
throw new Error(`Visible asset values contain ${state.malformedValueCount} malformed entries.`);
|
|
160
|
+
}
|
|
161
|
+
if (options.requirePriceChangeStyling) {
|
|
162
|
+
const invalidStyles = state.assets
|
|
163
|
+
.filter((item) => item.priceChange !== null)
|
|
164
|
+
.filter((item) => {
|
|
165
|
+
const numeric = Number(item.priceChange.replace('%', ''));
|
|
166
|
+
if (!Number.isFinite(numeric)) return true;
|
|
167
|
+
if (numeric < 0) return item.priceChangeTone !== 'negative';
|
|
168
|
+
if (numeric > 0) return item.priceChangeTone !== 'positive';
|
|
169
|
+
return item.priceChangeTone !== 'neutral';
|
|
170
|
+
})
|
|
171
|
+
.map((item) => item.symbol ?? item.name);
|
|
172
|
+
if (invalidStyles.length > 0) {
|
|
173
|
+
throw new Error(`Visible price-change styling does not match its sign for: ${invalidStyles.join(', ')}.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeVisibleAssetsState,
|
|
3
|
+
resolveVisibleAssetsOptions,
|
|
4
|
+
} from '../assets/visible-state.mjs';
|
|
5
|
+
|
|
6
|
+
function boundedInteger(value, fallback, minimum, maximum, name) {
|
|
7
|
+
const resolved = value == null ? fallback : Number(value);
|
|
8
|
+
if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`metamask.networks.read_visible_state ${name} must be an integer from ${minimum} to ${maximum}.`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizedText(value) {
|
|
17
|
+
return typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveVisibleNetworkOptions(node = {}) {
|
|
21
|
+
const network = normalizedText(node.network);
|
|
22
|
+
if (!network) throw new Error('metamask.networks.read_visible_state requires network.');
|
|
23
|
+
return {
|
|
24
|
+
network,
|
|
25
|
+
chainId: node.chain_id == null ? undefined : boundedInteger(node.chain_id, 0, 1, 2 ** 31 - 1, 'chain_id'),
|
|
26
|
+
settleMs: boundedInteger(node.settle_ms, 0, 0, 60_000, 'settle_ms'),
|
|
27
|
+
assets: resolveVisibleAssetsOptions(node),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeVisibleNetworkState(beforeRaw, afterRaw, options, platform) {
|
|
32
|
+
const selectedNetwork = normalizedText(afterRaw.network ?? beforeRaw.network);
|
|
33
|
+
if (!selectedNetwork.toLowerCase().includes(options.network.toLowerCase())) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Expected selected network ${options.network}, but observed ${selectedNetwork || 'none'}.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const forbiddenError = 'none of the cryptocurrencies are supported by price api';
|
|
39
|
+
const errors = [beforeRaw.errorText, afterRaw.errorText]
|
|
40
|
+
.map(normalizedText)
|
|
41
|
+
.filter((value) => value.toLowerCase().includes(forbiddenError));
|
|
42
|
+
if (errors.length > 0) {
|
|
43
|
+
throw new Error(`Selected network ${options.network} displayed a price API error.`);
|
|
44
|
+
}
|
|
45
|
+
const before = normalizeVisibleAssetsState(beforeRaw, options.assets, platform);
|
|
46
|
+
const after = normalizeVisibleAssetsState(afterRaw, options.assets, platform);
|
|
47
|
+
const afterBySymbol = new Map(after.assets.map((asset) => [asset.symbol, asset]));
|
|
48
|
+
const unstable = before.assets
|
|
49
|
+
.filter((asset) => options.assets.requireSymbols.length === 0 || options.assets.requireSymbols.includes(asset.symbol))
|
|
50
|
+
.filter((asset) => {
|
|
51
|
+
const current = afterBySymbol.get(asset.symbol);
|
|
52
|
+
return !current || current.amount !== asset.amount;
|
|
53
|
+
})
|
|
54
|
+
.map((asset) => asset.symbol ?? asset.name);
|
|
55
|
+
if (unstable.length > 0) {
|
|
56
|
+
throw new Error(`Network asset rows did not persist across the settle window: ${unstable.join(', ')}.`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
source: 'visible-ui',
|
|
60
|
+
platform,
|
|
61
|
+
network: options.network,
|
|
62
|
+
...(options.chainId ? { chainId: options.chainId } : {}),
|
|
63
|
+
selectedNetwork,
|
|
64
|
+
settleMs: options.settleMs,
|
|
65
|
+
stable: true,
|
|
66
|
+
before: before.assets,
|
|
67
|
+
after: after.assets,
|
|
68
|
+
priceApiError: false,
|
|
69
|
+
};
|
|
70
|
+
}
|