@deeeed/metamask-harness 0.38.1 → 0.39.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.
- package/CHANGELOG.md +12 -0
- package/dist/adapters.js +20 -11
- package/library/actions/extension/assets/open_details.mjs +30 -0
- package/library/actions/extension/assets/set_token_visibility.mjs +5 -0
- package/library/actions/extension/perps/search_markets.mjs +32 -0
- package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
- package/library/actions/extension/swap_bridge/read_visible_state.mjs +2 -6
- package/library/actions/extension/swap_bridge/set_amount.mjs +11 -2
- package/library/actions/mobile/assets/import_custom_token.mjs +181 -0
- package/library/actions/mobile/assets/open_details.mjs +43 -0
- package/library/actions/mobile/assets/read_details.mjs +96 -0
- package/library/actions/mobile/assets/set_token_visibility.mjs +302 -0
- package/library/actions/mobile/assets/verify_sorting.mjs +104 -0
- package/library/actions/mobile/networks/add_custom.mjs +125 -0
- package/library/actions/mobile/networks/network-management.mjs +132 -0
- package/library/actions/mobile/networks/read_visible_state.mjs +43 -0
- package/library/actions/mobile/networks/remove_custom.mjs +100 -0
- package/library/actions/mobile/perps/search_markets.mjs +46 -0
- package/library/actions/mobile/perps/set_market_favorite.mjs +117 -0
- package/library/actions/mobile/platform/native-session-name.mjs +14 -0
- package/library/actions/mobile/platform/native-session.mjs +59 -0
- package/library/actions/mobile/platform/observe-ui.mjs +9 -1
- package/library/actions/mobile/swap_bridge/native-session.mjs +10 -45
- package/library/actions/mobile/swap_bridge/select_assets.mjs +15 -1
- package/library/actions/mobile/swap_bridge/set_amount.mjs +1 -1
- package/library/actions/mobile/swap_bridge/set_max_amount.mjs +55 -0
- package/library/actions/mobile/swap_bridge/set_slippage.mjs +96 -0
- package/library/actions/mobile/swap_bridge/submit_transaction.mjs +141 -0
- package/library/actions/mobile/ui/native-navigation.mjs +75 -23
- package/library/actions/mobile/ui/navigate.mjs +13 -2
- package/library/actions/mobile/wallet/lock.mjs +14 -0
- package/library/actions/mobile/wallet/native-ui.mjs +67 -14
- package/library/actions/shared/perps/visible-state.mjs +8 -6
- package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
- package/library/manifests/extension.action-manifest.json +43 -2
- package/library/manifests/mobile.action-manifest.json +308 -1
- package/library/recipes/assets/release-token-details.recipe.json +5 -4
- package/library/recipes/assets/release-token-hide-readd.recipe.json +2 -0
- package/library/recipes/mobile/networks/release-custom-network-persistence.recipe.json +90 -0
- package/library/recipes/mobile/networks/release-multichain-visibility.recipe.json +58 -0
- package/library/recipes/perps/release-market-search.recipe.json +8 -4
- package/library/recipes/perps/release-watchlist.recipe.json +31 -19
- package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +5 -13
- package/package.json +1 -1
- /package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import {
|
|
3
|
+
closeNativeSession,
|
|
4
|
+
createNativeSession,
|
|
5
|
+
nativeLabel,
|
|
6
|
+
nativeNode,
|
|
7
|
+
} from '../platform/native-session.mjs';
|
|
8
|
+
|
|
9
|
+
function visibleLabels(snapshot) {
|
|
10
|
+
return snapshot.nodes
|
|
11
|
+
.filter((node) => node.visibleToUser !== false)
|
|
12
|
+
.map(nativeLabel)
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function quote(snapshot) {
|
|
17
|
+
const source = nativeNode(snapshot, 'source-token-area');
|
|
18
|
+
const destination = nativeNode(snapshot, 'dest-token-area');
|
|
19
|
+
const sourceAmount = nativeLabel(nativeNode(snapshot, 'source-token-area-input'));
|
|
20
|
+
const destinationAmount = nativeLabel(nativeNode(snapshot, 'dest-token-area-input'));
|
|
21
|
+
const button = nativeNode(snapshot, 'bridge-confirm-button');
|
|
22
|
+
const labels = visibleLabels(snapshot);
|
|
23
|
+
return {
|
|
24
|
+
source: nativeLabel(source),
|
|
25
|
+
destination: nativeLabel(destination),
|
|
26
|
+
sourceAmount,
|
|
27
|
+
destinationAmount,
|
|
28
|
+
buttonEnabled: button?.enabled === true,
|
|
29
|
+
warning: labels.find((label) => /insufficient|no quotes|unavailable|may fail|might fail/iu.test(label)) ?? null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function waitForQuote(session, expected, timeoutMs) {
|
|
34
|
+
const deadline = Date.now() + Math.min(timeoutMs, 60_000);
|
|
35
|
+
let stable = null;
|
|
36
|
+
let stableSince = 0;
|
|
37
|
+
while (Date.now() <= deadline) {
|
|
38
|
+
const snapshot = await session.snapshot();
|
|
39
|
+
const current = quote(snapshot);
|
|
40
|
+
const matches = current.source.toUpperCase().includes(expected.sourceSymbol) &&
|
|
41
|
+
current.destination.toUpperCase().includes(expected.destinationSymbol) &&
|
|
42
|
+
Number(current.sourceAmount) === expected.amount &&
|
|
43
|
+
Number(current.destinationAmount) > 0 &&
|
|
44
|
+
current.buttonEnabled &&
|
|
45
|
+
!current.warning;
|
|
46
|
+
const identity = JSON.stringify(current);
|
|
47
|
+
if (!matches) {
|
|
48
|
+
stable = null;
|
|
49
|
+
stableSince = 0;
|
|
50
|
+
} else if (stable !== identity) {
|
|
51
|
+
stable = identity;
|
|
52
|
+
stableSince = Date.now();
|
|
53
|
+
} else if (Date.now() - stableSince >= 1500) {
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
57
|
+
}
|
|
58
|
+
throw new Error('The Mobile Swap quote did not remain submittable for the requested pair and amount.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function pressIfVisible(session, testId, timeoutMs) {
|
|
62
|
+
if (!nativeNode(await session.snapshot(), testId)) return false;
|
|
63
|
+
await session.execute('ui.press', { test_id: testId, timeout_ms: timeoutMs, settle: false });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function waitForTerminalStatus(session, timeoutMs) {
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
while (Date.now() <= deadline) {
|
|
70
|
+
const snapshot = await session.snapshot();
|
|
71
|
+
const labels = visibleLabels(snapshot);
|
|
72
|
+
const failed = labels.find((label) => /^(?:Swap|Bridge) failed$/iu.test(label));
|
|
73
|
+
if (failed) throw new Error(`Mobile ${failed}.`);
|
|
74
|
+
const success = labels.find((label) => /^(?:Swap|Bridge) complete$/iu.test(label));
|
|
75
|
+
if (success) return { snapshot, status: success };
|
|
76
|
+
if (nativeNode(snapshot, 'price-impact-footer-confirm')) {
|
|
77
|
+
await session.execute('ui.press', {
|
|
78
|
+
test_id: 'price-impact-footer-confirm',
|
|
79
|
+
timeout_ms: Math.max(1, deadline - Date.now()),
|
|
80
|
+
settle: false,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
84
|
+
}
|
|
85
|
+
throw new Error('Mobile Swap did not reach a terminal post-trade status.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
runAdapter(async (input) => {
|
|
89
|
+
const sourceSymbol = String(input.node?.source_symbol ?? '').trim().toUpperCase();
|
|
90
|
+
const destinationSymbol = String(input.node?.destination_symbol ?? '').trim().toUpperCase();
|
|
91
|
+
const amount = Number(input.node?.amount);
|
|
92
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 300_000);
|
|
93
|
+
if (!sourceSymbol || !destinationSymbol || !(amount > 0)) {
|
|
94
|
+
throw new Error('metamask.swap_bridge.submit_transaction requires source_symbol, destination_symbol, and positive amount.');
|
|
95
|
+
}
|
|
96
|
+
if (input.node?.confirm_live !== true) {
|
|
97
|
+
throw new Error('metamask.swap_bridge.submit_transaction requires confirm_live=true.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const session = createNativeSession(input, 'Swap', `submit-${process.pid}`);
|
|
101
|
+
try {
|
|
102
|
+
await session.open();
|
|
103
|
+
const before = await waitForQuote(session, { sourceSymbol, destinationSymbol, amount }, timeoutMs);
|
|
104
|
+
await session.execute('ui.press', {
|
|
105
|
+
test_id: 'bridge-confirm-button',
|
|
106
|
+
timeout_ms: Math.min(timeoutMs, 60_000),
|
|
107
|
+
settle: false,
|
|
108
|
+
});
|
|
109
|
+
const terminal = await waitForTerminalStatus(session, timeoutMs);
|
|
110
|
+
await pressIfVisible(session, 'post-trade-bottom-sheet-view-activity-button', timeoutMs);
|
|
111
|
+
let activity = null;
|
|
112
|
+
const activityDeadline = Date.now() + Math.min(timeoutMs, 60_000);
|
|
113
|
+
while (Date.now() <= activityDeadline) {
|
|
114
|
+
const snapshot = await session.snapshot();
|
|
115
|
+
if (nativeNode(snapshot, 'activity-screen-safe-area-view')) {
|
|
116
|
+
const labels = visibleLabels(snapshot);
|
|
117
|
+
activity = labels.find((label) => {
|
|
118
|
+
const upper = label.toUpperCase();
|
|
119
|
+
return upper.includes(sourceSymbol) && upper.includes(destinationSymbol);
|
|
120
|
+
}) ?? labels.find((label) => /swap|bridge/iu.test(label)) ?? null;
|
|
121
|
+
if (activity) break;
|
|
122
|
+
}
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
124
|
+
}
|
|
125
|
+
if (!activity) throw new Error('Confirmed Mobile Swap did not appear in visible Activity.');
|
|
126
|
+
return {
|
|
127
|
+
action: input.action,
|
|
128
|
+
sourceSymbol,
|
|
129
|
+
destinationSymbol,
|
|
130
|
+
amount,
|
|
131
|
+
destinationAmount: before.destinationAmount,
|
|
132
|
+
submitted: true,
|
|
133
|
+
confirmed: true,
|
|
134
|
+
status: terminal.status,
|
|
135
|
+
activity,
|
|
136
|
+
proofPath: 'native-accessibility-swap-post-trade-activity',
|
|
137
|
+
};
|
|
138
|
+
} finally {
|
|
139
|
+
await closeNativeSession(session);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
|
|
2
2
|
import { createAgentDeviceClient } from 'agent-device';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
nativeAgentDeviceStateDir,
|
|
5
|
+
nativeSessionName,
|
|
6
|
+
} from '../platform/native-session-name.mjs';
|
|
4
7
|
|
|
5
8
|
const IDS = {
|
|
6
9
|
wallet: ['wallet-safe-area', 'homepage-container'],
|
|
@@ -9,12 +12,16 @@ const IDS = {
|
|
|
9
12
|
perpsEntry: 'homepage-balance-breakdown-row-perps',
|
|
10
13
|
perpsTradeEntry: 'wallet-perps-action',
|
|
11
14
|
swapTradeEntry: 'wallet-actions-bottom-sheet-swap-button',
|
|
15
|
+
swapBack: 'button-icon',
|
|
12
16
|
swapSourceInput: 'source-token-area-input',
|
|
13
17
|
swapSourceToken: 'source-token-area',
|
|
14
18
|
swapDestinationToken: 'dest-token-area',
|
|
15
19
|
perpsHome: 'perps-home',
|
|
16
20
|
perpsHomeBack: 'perps-home-back-button',
|
|
21
|
+
perpsSearch: 'perps-home-search-toggle',
|
|
17
22
|
tutorialSkip: 'perps-tutorial-skip-button',
|
|
23
|
+
modeLite: 'perps-mode-selection-lite-option',
|
|
24
|
+
modePro: 'perps-mode-selection-pro-option',
|
|
18
25
|
market: 'perps-market-details-view',
|
|
19
26
|
marketBack: 'perps-market-header-back-button',
|
|
20
27
|
marketSubtitle: 'perps-market-header-subtitle',
|
|
@@ -23,6 +30,11 @@ const IDS = {
|
|
|
23
30
|
proMarketList: 'perps-pro-market-header-market-list-button',
|
|
24
31
|
proMarketSubtitle: 'perps-pro-market-header-subtitle',
|
|
25
32
|
marketList: 'perps-market-list',
|
|
33
|
+
marketListClose: 'perps-market-list-close-button-back-button',
|
|
34
|
+
tokenList: 'token-list',
|
|
35
|
+
tokenListBack: 'back-button',
|
|
36
|
+
tokenDetails: 'token-asset-overview',
|
|
37
|
+
tokenDetailsBack: 'back-arrow-button',
|
|
26
38
|
};
|
|
27
39
|
|
|
28
40
|
export async function navigateOpaqueMobile(input, destination) {
|
|
@@ -33,8 +45,10 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
33
45
|
}
|
|
34
46
|
const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
|
|
35
47
|
const session = nativeSessionName(env, device, app);
|
|
48
|
+
const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
|
|
36
49
|
const client = createAgentDeviceClient({
|
|
37
50
|
session,
|
|
51
|
+
stateDir,
|
|
38
52
|
lockPolicy: 'reject',
|
|
39
53
|
lockPlatform: 'android',
|
|
40
54
|
});
|
|
@@ -44,6 +58,7 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
44
58
|
device,
|
|
45
59
|
app,
|
|
46
60
|
session,
|
|
61
|
+
stateDir,
|
|
47
62
|
client,
|
|
48
63
|
});
|
|
49
64
|
const context = {
|
|
@@ -52,9 +67,11 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
52
67
|
artifactsDir: input.context.artifactsDir,
|
|
53
68
|
env,
|
|
54
69
|
};
|
|
70
|
+
const perpsMode = destination.params?.mode === 'pro' ? 'pro' : 'lite';
|
|
55
71
|
const press = (testId) => transport.execute('ui.press', {
|
|
56
72
|
test_id: testId,
|
|
57
73
|
timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
|
|
74
|
+
settle: false,
|
|
58
75
|
}, context);
|
|
59
76
|
const wait = (testId) => transport.execute('ui.wait_for', {
|
|
60
77
|
test_id: testId,
|
|
@@ -80,7 +97,9 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
80
97
|
forceFull: true,
|
|
81
98
|
});
|
|
82
99
|
const identifiers = async () => new Set(
|
|
83
|
-
(await snapshot()).nodes
|
|
100
|
+
(await snapshot()).nodes
|
|
101
|
+
.filter((node) => node.visibleToUser !== false)
|
|
102
|
+
.map((node) => String(node.identifier ?? '')),
|
|
84
103
|
);
|
|
85
104
|
|
|
86
105
|
try {
|
|
@@ -91,25 +110,25 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
91
110
|
} else if (destination.page === 'swap') {
|
|
92
111
|
observedScreen = await navigateSwap({ identifiers, press, wait, back });
|
|
93
112
|
} else if (destination.page === 'perps-market-list') {
|
|
94
|
-
observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000) });
|
|
113
|
+
observedScreen = await navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs: Number(input.node?.timeout_ms ?? 30_000), perpsMode });
|
|
95
114
|
} else {
|
|
96
115
|
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
97
116
|
const initialIds = await identifiers();
|
|
98
117
|
observedScreen = destination.page === 'perps-market' && initialIds.has(IDS.marketList)
|
|
99
|
-
? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs })
|
|
100
|
-
: await navigatePerps({ identifiers, press, wait, back, timeoutMs });
|
|
118
|
+
? await selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode })
|
|
119
|
+
: await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
|
|
101
120
|
if (destination.page === 'perps-market') {
|
|
102
121
|
const market = String(destination.params.market.symbol).toUpperCase();
|
|
103
122
|
const ids = await identifiers();
|
|
104
123
|
if (ids.has(IDS.proMarket) && !await marketMatches(snapshot, market)) {
|
|
105
124
|
await press(IDS.proMarketList);
|
|
106
|
-
observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs });
|
|
125
|
+
observedScreen = await selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode });
|
|
107
126
|
} else if (!ids.has(IDS.proMarket) && (!ids.has(IDS.market) || !await marketMatches(snapshot, market))) {
|
|
108
127
|
if (ids.has(IDS.market)) await press(IDS.marketBack);
|
|
109
128
|
await press(`perps-market-row-item-${market}`);
|
|
110
129
|
observedScreen = IDS.market;
|
|
111
130
|
}
|
|
112
|
-
await waitForPerpsDestination(identifiers, timeoutMs);
|
|
131
|
+
await waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
|
|
113
132
|
if (!await marketMatches(snapshot, market)) {
|
|
114
133
|
throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
|
|
115
134
|
}
|
|
@@ -147,10 +166,10 @@ async function navigateSwap({ identifiers, press, wait, back }) {
|
|
|
147
166
|
return IDS.swapSourceInput;
|
|
148
167
|
}
|
|
149
168
|
|
|
150
|
-
async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs }) {
|
|
169
|
+
async function selectMarket({ destination, identifiers, press, snapshot, timeoutMs, perpsMode }) {
|
|
151
170
|
const market = String(destination.params.market.symbol).toUpperCase();
|
|
152
171
|
await press(`perps-market-row-item-${market}`);
|
|
153
|
-
const observed = await waitForPerpsDestination(identifiers, timeoutMs);
|
|
172
|
+
const observed = await waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
|
|
154
173
|
if (!await marketMatches(snapshot, market)) {
|
|
155
174
|
throw new Error(`Opaque Mobile navigation opened a different Perps market than ${market}.`);
|
|
156
175
|
}
|
|
@@ -162,32 +181,56 @@ async function navigateHome({ identifiers, press, wait, back }) {
|
|
|
162
181
|
const ids = await identifiers();
|
|
163
182
|
if (IDS.wallet.some((id) => ids.has(id))) return;
|
|
164
183
|
if (ids.has(IDS.walletTab)) {
|
|
165
|
-
await press
|
|
166
|
-
|
|
167
|
-
|
|
184
|
+
if (await pressCurrent(press, IDS.walletTab)) {
|
|
185
|
+
await wait(IDS.wallet[0]);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
168
189
|
}
|
|
169
190
|
if (ids.has(IDS.marketBack)) {
|
|
170
|
-
await press
|
|
191
|
+
await pressCurrent(press, IDS.marketBack);
|
|
171
192
|
continue;
|
|
172
193
|
}
|
|
173
194
|
if (ids.has(IDS.proMarketBack)) {
|
|
174
|
-
await press
|
|
195
|
+
await pressCurrent(press, IDS.proMarketBack);
|
|
175
196
|
continue;
|
|
176
197
|
}
|
|
177
198
|
if (ids.has(IDS.perpsHomeBack)) {
|
|
178
|
-
await press
|
|
199
|
+
await pressCurrent(press, IDS.perpsHomeBack);
|
|
179
200
|
continue;
|
|
180
201
|
}
|
|
181
|
-
if (ids.has(IDS.
|
|
182
|
-
await
|
|
202
|
+
if (ids.has(IDS.marketListClose)) {
|
|
203
|
+
await pressCurrent(press, IDS.marketListClose);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (ids.has(IDS.tokenList) && ids.has(IDS.tokenListBack)) {
|
|
207
|
+
await pressCurrent(press, IDS.tokenListBack);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ids.has(IDS.tokenDetails) && ids.has(IDS.tokenDetailsBack)) {
|
|
211
|
+
await pressCurrent(press, IDS.tokenDetailsBack);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (ids.has(IDS.swapSourceInput) && ids.has(IDS.swapBack)) {
|
|
215
|
+
await pressCurrent(press, IDS.swapBack);
|
|
183
216
|
continue;
|
|
184
217
|
}
|
|
185
|
-
|
|
218
|
+
await back();
|
|
186
219
|
}
|
|
187
220
|
throw new Error('Opaque Mobile navigation did not reach wallet home.');
|
|
188
221
|
}
|
|
189
222
|
|
|
190
|
-
async function
|
|
223
|
+
async function pressCurrent(press, testId) {
|
|
224
|
+
try {
|
|
225
|
+
await press(testId);
|
|
226
|
+
return true;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (!/Selector did not match/iu.test(String(error?.message ?? error))) throw error;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
|
|
191
234
|
let ids = await identifiers();
|
|
192
235
|
if (ids.has(IDS.proMarket)) return IDS.proMarket;
|
|
193
236
|
if (ids.has(IDS.market)) {
|
|
@@ -219,15 +262,20 @@ async function navigatePerps({ identifiers, press, wait, back, timeoutMs }) {
|
|
|
219
262
|
ids = await identifiers();
|
|
220
263
|
}
|
|
221
264
|
if (ids.has(IDS.tutorialSkip)) await press(IDS.tutorialSkip);
|
|
222
|
-
return waitForPerpsDestination(identifiers, timeoutMs);
|
|
265
|
+
return waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode);
|
|
223
266
|
}
|
|
224
267
|
|
|
225
|
-
async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs }) {
|
|
268
|
+
async function navigatePerpsMarketList({ identifiers, press, wait, back, timeoutMs, perpsMode }) {
|
|
226
269
|
let ids = await identifiers();
|
|
227
270
|
if (ids.has(IDS.marketList)) return IDS.marketList;
|
|
228
|
-
const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs });
|
|
271
|
+
const observed = await navigatePerps({ identifiers, press, wait, back, timeoutMs, perpsMode });
|
|
229
272
|
ids = await identifiers();
|
|
230
273
|
if (ids.has(IDS.marketList)) return IDS.marketList;
|
|
274
|
+
if (ids.has(IDS.perpsSearch)) {
|
|
275
|
+
await press(IDS.perpsSearch);
|
|
276
|
+
await wait(IDS.marketList);
|
|
277
|
+
return IDS.marketList;
|
|
278
|
+
}
|
|
231
279
|
if (ids.has(IDS.proMarketList)) {
|
|
232
280
|
await press(IDS.proMarketList);
|
|
233
281
|
await wait(IDS.marketList);
|
|
@@ -236,10 +284,14 @@ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeout
|
|
|
236
284
|
throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
|
|
237
285
|
}
|
|
238
286
|
|
|
239
|
-
async function waitForPerpsDestination(identifiers, timeoutMs) {
|
|
287
|
+
async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode = 'lite') {
|
|
240
288
|
const deadline = Date.now() + timeoutMs;
|
|
241
289
|
while (Date.now() <= deadline) {
|
|
242
290
|
const ids = await identifiers();
|
|
291
|
+
if (ids.has(IDS.modeLite) || ids.has(IDS.modePro)) {
|
|
292
|
+
await press(perpsMode === 'pro' ? IDS.modePro : IDS.modeLite);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
243
295
|
for (const id of [IDS.perpsHome, IDS.market, IDS.proMarket]) {
|
|
244
296
|
if (ids.has(id)) return id;
|
|
245
297
|
}
|
|
@@ -15,11 +15,22 @@ function text(value) {
|
|
|
15
15
|
function pageRoute(node) {
|
|
16
16
|
const page = text(node?.page);
|
|
17
17
|
if (!page) return undefined;
|
|
18
|
-
|
|
18
|
+
const mode = text(node?.mode)?.toLowerCase() ?? 'lite';
|
|
19
|
+
if (!['lite', 'pro'].includes(mode)) {
|
|
20
|
+
throw new Error('mobile ui.navigate mode must be lite or pro.');
|
|
21
|
+
}
|
|
22
|
+
if (PAGE_ROUTES[page]) {
|
|
23
|
+
const route = PAGE_ROUTES[page];
|
|
24
|
+
return {
|
|
25
|
+
page,
|
|
26
|
+
...route,
|
|
27
|
+
params: page.startsWith('perps') ? { ...route.params, mode } : route.params,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
19
30
|
if (page === 'perps-market') {
|
|
20
31
|
const market = text(node.market) ?? text(node.symbol) ?? text(node.params?.market?.symbol);
|
|
21
32
|
if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
|
|
22
|
-
return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market } } };
|
|
33
|
+
return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market }, mode } };
|
|
23
34
|
}
|
|
24
35
|
throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
|
|
25
36
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import { isOpaqueMobileRuntime, lockWalletThroughNativeUi } from './native-ui.mjs';
|
|
3
|
+
|
|
4
|
+
runAdapter(async (input) => {
|
|
5
|
+
if (!isOpaqueMobileRuntime(input)) {
|
|
6
|
+
throw new Error('metamask.wallet.lock requires an opaque Mobile runtime.');
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
action: input.action,
|
|
10
|
+
...await lockWalletThroughNativeUi(input),
|
|
11
|
+
locked: true,
|
|
12
|
+
proofPath: 'mobile-visible-wallet-lock-native',
|
|
13
|
+
};
|
|
14
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
|
|
2
2
|
import { createAgentDeviceClient } from 'agent-device';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
nativeAgentDeviceStateDir,
|
|
5
|
+
nativeSessionName,
|
|
6
|
+
} from '../platform/native-session-name.mjs';
|
|
4
7
|
|
|
5
8
|
const TEST_IDS = {
|
|
6
9
|
existingWallet: 'wallet-setup-screen-have-an-existing-wallet-button-id',
|
|
@@ -85,6 +88,10 @@ function isWalletScreen(nodes) {
|
|
|
85
88
|
return nodes.some((node) => WALLET_SCREEN_IDS.includes(String(node.identifier ?? '')));
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
function visibleSnapshotNodes(nodes) {
|
|
92
|
+
return nodes.filter((node) => node.visibleToUser !== false);
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
function hasBlockingSystemUi(nodes) {
|
|
89
96
|
return nodes.some((node) =>
|
|
90
97
|
(node.bundleId === 'com.android.systemui' || PERMISSION_CONTROLLER_PACKAGES.has(node.bundleId)) &&
|
|
@@ -101,7 +108,7 @@ export async function importWalletThroughNativeUi(input, credentials, options) {
|
|
|
101
108
|
const native = nativeSession(input);
|
|
102
109
|
try {
|
|
103
110
|
const initial = await native.normalizeWalletSurface(options.timeoutMs);
|
|
104
|
-
if (initial.wallet) {
|
|
111
|
+
if (initial.wallet && !initial.visible.has(TEST_IDS.walletTourSkip)) {
|
|
105
112
|
if (options.requireFresh) {
|
|
106
113
|
throw new Error('metamask.wallet.import method=ui requires a fresh Mobile onboarding state.');
|
|
107
114
|
}
|
|
@@ -183,6 +190,27 @@ export async function unlockWalletThroughNativeUi(input, password, timeoutMs) {
|
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
export async function lockWalletThroughNativeUi(input) {
|
|
194
|
+
const native = nativeSession(input);
|
|
195
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
196
|
+
try {
|
|
197
|
+
const state = await native.normalizeWalletSurface(timeoutMs);
|
|
198
|
+
if (state.visible.has(TEST_IDS.login)) {
|
|
199
|
+
return { alreadyLocked: true, provider: 'android-native-accessibility' };
|
|
200
|
+
}
|
|
201
|
+
if (!state.wallet) throw new Error('Opaque Mobile wallet lock could not reach wallet home.');
|
|
202
|
+
await native.press(TEST_IDS.hamburger, timeoutMs);
|
|
203
|
+
await native.press(TEST_IDS.lock, timeoutMs);
|
|
204
|
+
if (await native.exists(TEST_IDS.confirmLock, 3000)) {
|
|
205
|
+
await native.press(TEST_IDS.confirmLock, timeoutMs);
|
|
206
|
+
}
|
|
207
|
+
await native.wait(TEST_IDS.login, timeoutMs);
|
|
208
|
+
return { alreadyLocked: false, provider: 'android-native-accessibility' };
|
|
209
|
+
} finally {
|
|
210
|
+
await native.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
186
214
|
export async function resetWalletThroughNativeUi(input) {
|
|
187
215
|
const native = nativeSession(input);
|
|
188
216
|
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
@@ -219,8 +247,10 @@ function nativeSession(input) {
|
|
|
219
247
|
if (!device) throw new Error('Opaque Mobile UI execution requires one explicit Android device serial.');
|
|
220
248
|
const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
|
|
221
249
|
const session = nativeSessionName(env, device, app);
|
|
250
|
+
const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
|
|
222
251
|
const client = createAgentDeviceClient({
|
|
223
252
|
session,
|
|
253
|
+
stateDir,
|
|
224
254
|
lockPolicy: 'reject',
|
|
225
255
|
lockPlatform: 'android',
|
|
226
256
|
});
|
|
@@ -230,6 +260,7 @@ function nativeSession(input) {
|
|
|
230
260
|
device,
|
|
231
261
|
app,
|
|
232
262
|
session,
|
|
263
|
+
stateDir,
|
|
233
264
|
client,
|
|
234
265
|
});
|
|
235
266
|
const context = {
|
|
@@ -240,7 +271,11 @@ function nativeSession(input) {
|
|
|
240
271
|
};
|
|
241
272
|
const execute = (action, node) => transport.execute(action, node, context);
|
|
242
273
|
return {
|
|
243
|
-
press: (testId, timeoutMs) => execute('ui.press', {
|
|
274
|
+
press: (testId, timeoutMs) => execute('ui.press', {
|
|
275
|
+
test_id: testId,
|
|
276
|
+
timeout_ms: timeoutMs,
|
|
277
|
+
settle: false,
|
|
278
|
+
}),
|
|
244
279
|
fill: (testId, value, timeoutMs) => execute('ui.set_input', {
|
|
245
280
|
test_id: testId,
|
|
246
281
|
value,
|
|
@@ -259,7 +294,7 @@ function nativeSession(input) {
|
|
|
259
294
|
interactiveOnly: false,
|
|
260
295
|
forceFull: true,
|
|
261
296
|
});
|
|
262
|
-
const actual = new Map(snapshot.nodes
|
|
297
|
+
const actual = new Map(visibleSnapshotNodes(snapshot.nodes)
|
|
263
298
|
.filter((node) => /^phrase-input-id_\d+$/u.test(String(node.identifier ?? '')))
|
|
264
299
|
.map((node) => [
|
|
265
300
|
Number(String(node.identifier).split('_').at(-1)),
|
|
@@ -288,11 +323,12 @@ function nativeSession(input) {
|
|
|
288
323
|
interactiveOnly: false,
|
|
289
324
|
forceFull: true,
|
|
290
325
|
});
|
|
326
|
+
const nodes = visibleSnapshotNodes(snapshot.nodes);
|
|
291
327
|
return {
|
|
292
|
-
nodes
|
|
293
|
-
visible: new Set(
|
|
294
|
-
wallet: isWalletScreen(
|
|
295
|
-
postPassword: isPostPasswordScreen(
|
|
328
|
+
nodes,
|
|
329
|
+
visible: new Set(nodes.map((node) => String(node.identifier ?? ''))),
|
|
330
|
+
wallet: isWalletScreen(nodes),
|
|
331
|
+
postPassword: isPostPasswordScreen(nodes) || isMetaMaskBiometricPrompt(nodes),
|
|
296
332
|
};
|
|
297
333
|
},
|
|
298
334
|
async normalizeWalletSurface(timeoutMs) {
|
|
@@ -333,13 +369,15 @@ function nativeSession(input) {
|
|
|
333
369
|
let walletTourSkipped = false;
|
|
334
370
|
let interestsHandled = false;
|
|
335
371
|
let awaitingNotificationPermission = false;
|
|
372
|
+
let stableWalletCaptures = 0;
|
|
336
373
|
while (Date.now() <= deadline) {
|
|
337
374
|
const snapshot = await client.capture.snapshot({
|
|
338
375
|
...selection,
|
|
339
376
|
interactiveOnly: false,
|
|
340
377
|
forceFull: true,
|
|
341
378
|
});
|
|
342
|
-
|
|
379
|
+
const nodes = visibleSnapshotNodes(snapshot.nodes);
|
|
380
|
+
if (isMetaMaskBiometricPrompt(nodes)) {
|
|
343
381
|
if (options.biometricsEnabled) {
|
|
344
382
|
throw new Error('Mobile biometrics=true requires manual device authentication and is not supported by an unattended recipe.');
|
|
345
383
|
}
|
|
@@ -351,7 +389,7 @@ function nativeSession(input) {
|
|
|
351
389
|
biometricPromptDismissed = true;
|
|
352
390
|
continue;
|
|
353
391
|
}
|
|
354
|
-
if (isMetaMaskNotificationPermissionPrompt(
|
|
392
|
+
if (isMetaMaskNotificationPermissionPrompt(nodes) && awaitingNotificationPermission) {
|
|
355
393
|
await client.command.alert({
|
|
356
394
|
...selection,
|
|
357
395
|
action: 'accept',
|
|
@@ -362,10 +400,12 @@ function nativeSession(input) {
|
|
|
362
400
|
notificationsEnabled = true;
|
|
363
401
|
continue;
|
|
364
402
|
}
|
|
365
|
-
if (hasBlockingSystemUi(
|
|
403
|
+
if (hasBlockingSystemUi(nodes)) {
|
|
366
404
|
throw new Error('Unexpected Android system UI blocked Mobile wallet creation; refusing to dismiss it as biometric authentication.');
|
|
367
405
|
}
|
|
368
|
-
const visible = new Set(
|
|
406
|
+
const visible = new Set(nodes.map((node) => String(node.identifier ?? '')));
|
|
407
|
+
const walletVisible = WALLET_SCREEN_IDS.some((testId) => visible.has(testId));
|
|
408
|
+
if (!walletVisible) stableWalletCaptures = 0;
|
|
369
409
|
if (visible.has(TEST_IDS.metricsContinue)) {
|
|
370
410
|
if (!options.metametricsEnabled) {
|
|
371
411
|
await this.press(TEST_IDS.metricsCheckbox, options.timeoutMs);
|
|
@@ -402,6 +442,7 @@ function nativeSession(input) {
|
|
|
402
442
|
continue;
|
|
403
443
|
}
|
|
404
444
|
if (visible.has(TEST_IDS.walletTourSkip)) {
|
|
445
|
+
stableWalletCaptures = 0;
|
|
405
446
|
if (options.skipWalletTour) {
|
|
406
447
|
await this.press(TEST_IDS.walletTourSkip, options.timeoutMs);
|
|
407
448
|
walletTourSkipped = true;
|
|
@@ -409,10 +450,15 @@ function nativeSession(input) {
|
|
|
409
450
|
}
|
|
410
451
|
throw new Error('Mobile wallet-home tour remains visible because skip_wallet_tour=false; wallet readiness cannot be proven.');
|
|
411
452
|
}
|
|
412
|
-
if (
|
|
453
|
+
if (walletVisible) {
|
|
413
454
|
if (options.interests.length > 0 && !interestsHandled) {
|
|
414
455
|
throw new Error('Mobile onboarding interests were requested, but the questionnaire is not enabled for this build.');
|
|
415
456
|
}
|
|
457
|
+
stableWalletCaptures += 1;
|
|
458
|
+
if (stableWalletCaptures < 2) {
|
|
459
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
416
462
|
return {
|
|
417
463
|
biometricsEnabled: options.biometricsEnabled,
|
|
418
464
|
biometricPromptDismissed,
|
|
@@ -433,8 +479,15 @@ function nativeSession(input) {
|
|
|
433
479
|
}),
|
|
434
480
|
async waitForWallet(timeoutMs) {
|
|
435
481
|
const deadline = Date.now() + timeoutMs;
|
|
482
|
+
let stableCaptures = 0;
|
|
436
483
|
while (Date.now() <= deadline) {
|
|
437
|
-
|
|
484
|
+
const state = await this.screenState();
|
|
485
|
+
if (state.wallet && state.visible.has(TEST_IDS.walletAddress)) {
|
|
486
|
+
stableCaptures += 1;
|
|
487
|
+
if (stableCaptures >= 2) return;
|
|
488
|
+
} else {
|
|
489
|
+
stableCaptures = 0;
|
|
490
|
+
}
|
|
438
491
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
439
492
|
}
|
|
440
493
|
throw new Error('Mobile wallet home did not become visible before the readiness timeout.');
|
|
@@ -132,6 +132,7 @@ function marketFromTestId(testId) {
|
|
|
132
132
|
for (const prefix of ['perps-watchlist-', 'explore-markets-', 'perps-market-row-item-', 'market-row-']) {
|
|
133
133
|
if (!testId.startsWith(prefix)) continue;
|
|
134
134
|
const raw = testId.slice(prefix.length);
|
|
135
|
+
if (/(?:-token-logo|-asset-label)$/u.test(raw)) return undefined;
|
|
135
136
|
if ((prefix === 'explore-markets-' || prefix === 'market-row-') && raw.startsWith('xyz-')) {
|
|
136
137
|
return `XYZ:${raw.slice(4).toUpperCase()}`;
|
|
137
138
|
}
|
|
@@ -173,18 +174,19 @@ function compactEntries(items, maximum) {
|
|
|
173
174
|
function visibleFeatures(items) {
|
|
174
175
|
const ids = new Set(items.map((item) => item.testId));
|
|
175
176
|
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);
|
|
177
|
+
const long = has(/(?:long-cta-button|direction-long|market-details-long-button)$/u);
|
|
178
|
+
const short = has(/(?:short-cta-button|direction-short|market-details-short-button)$/u);
|
|
178
179
|
return {
|
|
179
180
|
activity: has(/^(?:transaction-card-|perps-(?:recent-activity|activity))/u),
|
|
180
181
|
balance: has(/^perps-balance-dropdown/u),
|
|
181
|
-
chart: has(
|
|
182
|
+
chart: has(/^(?:chart_surface|tv_chart_container)$/u) ||
|
|
183
|
+
has(/perps-(?:candlestick-chart|market-detail-chart|market-details-fullscreen-chart|pro-market-chart)/u),
|
|
182
184
|
'long-short': long && short,
|
|
183
185
|
'market-list': has(/^(?:market-list-view|perps-market-list|perps-watchlist-|explore-markets-|market-row-)/u),
|
|
184
186
|
'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),
|
|
187
|
+
price: has(/perps-(?:market-detail-price|market-header-price|pro-market-price)$/u),
|
|
188
|
+
'price-change': has(/perps-(?:market-detail-change|market-header-price-change|pro-market-price-change)$/u),
|
|
189
|
+
stats: has(/perps-(?:stats-section|market-details-market-summary|market-detail-oracle-price|pro-market-stats)/u),
|
|
188
190
|
};
|
|
189
191
|
}
|
|
190
192
|
|