@deeeed/metamask-harness 0.38.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/adapters/extension/lib/playwright-cdp.cjs +17 -1
- package/adapters/extension/sidepanel-toggle.sh +135 -13
- package/dist/adapters/mobile/release-artifact.js +65 -0
- package/dist/adapters.js +27 -17
- package/dist/command-contract.js +10 -2
- package/dist/commands/call.js +7 -4
- package/dist/commands/launch/index.js +55 -3
- package/dist/commands/parse-args.js +6 -0
- package/dist/commands/run-engine.js +75 -39
- package/dist/funding-execution-context.js +1386 -0
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +170 -3
- package/dist/mm-harness-cli.js +8 -2
- package/dist/recipe-security.js +5 -12
- package/dist/runner.js +102 -6
- package/docs/RECIPES.md +74 -1
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +6 -5
- 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/networks/add_chainlist.mjs +4 -0
- package/library/actions/extension/networks/add_custom.mjs +4 -0
- package/library/actions/extension/networks/custom_network.mjs +48 -1
- package/library/actions/extension/perps/assert_visible_consistency.mjs +366 -0
- package/library/actions/extension/perps/close_visible_position.mjs +299 -37
- package/library/actions/extension/perps/compare_provider_market.mjs +77 -0
- package/library/actions/extension/perps/edit_margin.mjs +285 -44
- package/library/actions/extension/perps/mutation-receipt.mjs +781 -0
- package/library/actions/extension/perps/perps.mjs +1029 -363
- package/library/actions/extension/perps/read_funds_confirmation.mjs +126 -13
- package/library/actions/extension/perps/read_snapshot.mjs +124 -3
- package/library/actions/extension/perps/read_visible_state.mjs +63 -5
- package/library/actions/extension/perps/search_markets.mjs +32 -0
- package/library/actions/extension/perps/select_activity_filter.mjs +81 -18
- package/library/actions/extension/perps/select_market_filter.mjs +48 -4
- package/library/actions/extension/perps/set_market_favorite.mjs +6 -1
- package/library/actions/extension/perps/update_position_tpsl.mjs +251 -69
- package/library/actions/extension/perps/visible-mutation-identity.mjs +164 -0
- package/library/actions/extension/platform/cdp.mjs +88 -9
- package/library/actions/extension/settings/set_basic_functionality.mjs +35 -7
- 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/extension/ui/navigate.mjs +45 -5
- package/library/actions/extension/wallet/import.mjs +70 -29
- package/library/actions/extension/wallet/lock.mjs +62 -5
- package/library/actions/extension/wallet/visible-session.mjs +218 -0
- 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 +120 -23
- package/library/actions/mobile/ui/navigate.mjs +13 -2
- package/library/actions/mobile/wallet/import.mjs +17 -10
- package/library/actions/mobile/wallet/lock.mjs +14 -0
- package/library/actions/mobile/wallet/native-ui.mjs +88 -15
- package/library/actions/shared/perps/visible-state.mjs +33 -11
- package/library/actions/shared/swap-bridge/visible-state.mjs +15 -0
- package/library/manifests/extension.action-manifest.json +1707 -1241
- 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/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +1 -0
- package/library/recipes/{perps → extension/perps}/release-activity-filters.recipe.json +5 -5
- package/library/recipes/{perps → extension/perps}/release-funds-flows.recipe.json +3 -3
- package/library/recipes/extension/perps/release-live-limit-order.recipe.json +24 -19
- package/library/recipes/extension/perps/release-market-filters.recipe.json +97 -0
- package/library/recipes/extension/perps/release-order-entry.recipe.json +4 -1
- package/library/recipes/extension/perps/release-order-validation.recipe.json +53 -14
- package/library/recipes/extension/perps/release-visible-consistency.recipe.json +47 -0
- package/library/recipes/extension/perps/source-dev-snapshot-consistency.recipe.json +76 -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-live-market-order.recipe.json +30 -13
- package/library/recipes/perps/release-market-details.recipe.json +1 -1
- 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/library/recipes/swap-bridge/release-quote.recipe.json +1 -0
- package/library/recipes/wallet/import.recipe.json +4 -14
- package/package.json +1 -1
- package/library/actions/extension/assets/finish_send.mjs +0 -128
- package/library/actions/extension/swap_bridge/submit_transaction.mjs +0 -363
- package/library/actions/extension/ui/activate_and_observe.mjs +0 -96
- package/library/recipes/assets/release-custom-gas-send.recipe.json +0 -88
- package/library/recipes/assets/release-native-max-cancel.recipe.json +0 -70
- package/library/recipes/assets/release-native-max-send.recipe.json +0 -78
- package/library/recipes/assets/release-own-account-send.recipe.json +0 -80
- package/library/recipes/extension/runner/action-validation.recipe.json +0 -343
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ACCOUNT_SEARCH = 'input[data-testid="multichain-account-list-search"], [data-testid="multichain-account-list-search"] input';
|
|
4
|
+
const ONBOARDING_COMPLETE = '[data-testid="onboarding-complete-done"]';
|
|
5
|
+
const HOME_BOUNDARY = '[data-testid="bottom-nav-home"], [data-testid="account-overview__asset-tab"]';
|
|
6
|
+
|
|
7
|
+
async function hasVisibleSelector(page, selector, deadline) {
|
|
8
|
+
const expression = `(() => {
|
|
9
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
10
|
+
if (!element) return false;
|
|
11
|
+
const style = getComputedStyle(element);
|
|
12
|
+
const rect = element.getBoundingClientRect();
|
|
13
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
14
|
+
})()`;
|
|
15
|
+
if (deadline === undefined) return page.evaluate(expression);
|
|
16
|
+
const result = await page.session.call('Runtime.evaluate', {
|
|
17
|
+
expression,
|
|
18
|
+
awaitPromise: true,
|
|
19
|
+
returnByValue: true,
|
|
20
|
+
}, { timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())) });
|
|
21
|
+
if (result.exceptionDetails) throw new Error('Visible wallet selector probe failed.');
|
|
22
|
+
return result.result?.value === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function dismissVisibleOnboardingCompletion(page) {
|
|
26
|
+
if (!(await hasVisibleSelector(page, ONBOARDING_COMPLETE))) return false;
|
|
27
|
+
await page.click(ONBOARDING_COMPLETE);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function accountProbeAddress(expectedAddress) {
|
|
32
|
+
const replacement = expectedAddress.endsWith('0') ? '1' : '0';
|
|
33
|
+
return `${expectedAddress.slice(0, -1)}${replacement}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function currentRoute(page, deadline) {
|
|
37
|
+
const history = await page.session.call('Page.getNavigationHistory', {}, {
|
|
38
|
+
timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())),
|
|
39
|
+
});
|
|
40
|
+
const url = String(history.entries?.[history.currentIndex]?.url ?? '');
|
|
41
|
+
const route = new URL(url).hash.split('?')[0];
|
|
42
|
+
return route.replace(/0x[0-9a-f]{40}/giu, '<redacted-address>').slice(0, 200);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function routeError(page, message, deadline) {
|
|
46
|
+
let route = '<deadline-expired>';
|
|
47
|
+
if (Date.now() < deadline) {
|
|
48
|
+
try {
|
|
49
|
+
route = await currentRoute(page, deadline);
|
|
50
|
+
} catch {
|
|
51
|
+
route = '<unavailable>';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return new Error(`${message} (route ${JSON.stringify(route)}).`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function visibleAccountMatch(page, deadline) {
|
|
58
|
+
const result = await page.session.call('Runtime.evaluate', {
|
|
59
|
+
expression: `(() => {
|
|
60
|
+
const input = document.querySelector(${JSON.stringify(ACCOUNT_SEARCH)});
|
|
61
|
+
const query = String(input?.value || '').trim().toLowerCase();
|
|
62
|
+
const rows = [...document.querySelectorAll('.multichain-account-cell[data-testid^="multichain-account-cell-"]')]
|
|
63
|
+
.filter((element) => {
|
|
64
|
+
const style = getComputedStyle(element);
|
|
65
|
+
const rect = element.getBoundingClientRect();
|
|
66
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
67
|
+
});
|
|
68
|
+
const row = rows.length === 1 ? rows[0] : null;
|
|
69
|
+
const accountName = String(row?.querySelector('.multichain-account-cell__account-name')?.textContent || '')
|
|
70
|
+
.trim()
|
|
71
|
+
.toLowerCase();
|
|
72
|
+
return {
|
|
73
|
+
query,
|
|
74
|
+
count: rows.length,
|
|
75
|
+
testId: row?.getAttribute('data-testid') || null,
|
|
76
|
+
selected: Boolean(row?.classList.contains('is-selected')),
|
|
77
|
+
addressMatchedBySearch: Boolean(/^0x[0-9a-f]{40}$/u.test(query) && !accountName.includes(query)),
|
|
78
|
+
};
|
|
79
|
+
})()`,
|
|
80
|
+
awaitPromise: true,
|
|
81
|
+
returnByValue: true,
|
|
82
|
+
}, { timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())) });
|
|
83
|
+
if (result.exceptionDetails || !result.result?.value) {
|
|
84
|
+
throw new Error('Visible wallet account probe failed.');
|
|
85
|
+
}
|
|
86
|
+
return result.result.value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function navigateHashThroughPage(page, hash, deadline) {
|
|
90
|
+
const pathname = new URL(String(page.target?.url ?? `${page.origin}/home.html`)).pathname;
|
|
91
|
+
const walletPath = pathname === '/sidepanel.html' ? pathname : '/home.html';
|
|
92
|
+
await page.session.call('Page.navigate', { url: `${page.origin}${walletPath}${hash}` }, {
|
|
93
|
+
timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function withinDeadline(promise, deadline) {
|
|
98
|
+
const timeoutMs = Math.max(1, deadline - Date.now());
|
|
99
|
+
let timer;
|
|
100
|
+
try {
|
|
101
|
+
return await Promise.race([
|
|
102
|
+
promise,
|
|
103
|
+
new Promise((_, reject) => {
|
|
104
|
+
timer = setTimeout(() => reject(new Error('Visible wallet stage exceeded its shared deadline.')), timeoutMs);
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function leavePerpsForWalletHome(page, deadline) {
|
|
113
|
+
if (!(await currentRoute(page, deadline)).includes('/perps')) return;
|
|
114
|
+
while (Date.now() <= deadline) {
|
|
115
|
+
try {
|
|
116
|
+
await navigateHashThroughPage(page, '#/', deadline);
|
|
117
|
+
if (await hasVisibleSelector(page, HOME_BOUNDARY, deadline)) return;
|
|
118
|
+
} catch {
|
|
119
|
+
// Retry within the shared deadline while the authenticated Home surface settles.
|
|
120
|
+
}
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
122
|
+
}
|
|
123
|
+
throw await routeError(page, 'Visible wallet session did not reach Home before account-list navigation', deadline);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function openAccountList(page, deadline) {
|
|
127
|
+
await leavePerpsForWalletHome(page, deadline);
|
|
128
|
+
while (Date.now() <= deadline) {
|
|
129
|
+
try {
|
|
130
|
+
await navigateHashThroughPage(page, '#/account-list', deadline);
|
|
131
|
+
if (await hasVisibleSelector(page, ACCOUNT_SEARCH, deadline)) return;
|
|
132
|
+
} catch {
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw await routeError(page, 'Visible wallet session did not reach the authenticated account list', deadline);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function proveAddressSearchControl(page, expectedAddress, deadline) {
|
|
140
|
+
const probeAddress = accountProbeAddress(expectedAddress);
|
|
141
|
+
try {
|
|
142
|
+
await withinDeadline(page.setInput(ACCOUNT_SEARCH, probeAddress), deadline);
|
|
143
|
+
} catch {
|
|
144
|
+
throw await routeError(page, 'Visible wallet account search could not enter the alternate-address control', deadline);
|
|
145
|
+
}
|
|
146
|
+
let consecutiveEmpty = 0;
|
|
147
|
+
while (Date.now() <= deadline) {
|
|
148
|
+
let observed;
|
|
149
|
+
try {
|
|
150
|
+
observed = await visibleAccountMatch(page, deadline);
|
|
151
|
+
} catch {
|
|
152
|
+
throw await routeError(page, 'Visible wallet alternate-address result did not become readable', deadline);
|
|
153
|
+
}
|
|
154
|
+
if (observed.query === probeAddress && observed.count === 0) {
|
|
155
|
+
consecutiveEmpty += 1;
|
|
156
|
+
if (consecutiveEmpty === 2) return;
|
|
157
|
+
} else {
|
|
158
|
+
consecutiveEmpty = 0;
|
|
159
|
+
}
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
161
|
+
}
|
|
162
|
+
throw await routeError(page, 'Visible wallet account search did not reject the alternate address control', deadline);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function queryExpectedAccount(page, expectedAddress, deadline, requireSelected) {
|
|
166
|
+
await proveAddressSearchControl(page, expectedAddress, deadline);
|
|
167
|
+
try {
|
|
168
|
+
await withinDeadline(page.setInput(ACCOUNT_SEARCH, expectedAddress), deadline);
|
|
169
|
+
} catch {
|
|
170
|
+
throw await routeError(page, 'Visible wallet account search could not enter the derived release identity', deadline);
|
|
171
|
+
}
|
|
172
|
+
let observed;
|
|
173
|
+
while (Date.now() <= deadline) {
|
|
174
|
+
try {
|
|
175
|
+
observed = await visibleAccountMatch(page, deadline);
|
|
176
|
+
} catch {
|
|
177
|
+
throw await routeError(page, 'Visible wallet derived-account result did not become readable', deadline);
|
|
178
|
+
}
|
|
179
|
+
const exactMatch = observed.query === expectedAddress &&
|
|
180
|
+
observed.count === 1 &&
|
|
181
|
+
observed.testId &&
|
|
182
|
+
observed.addressMatchedBySearch;
|
|
183
|
+
if (exactMatch && (!requireSelected || observed.selected)) return observed;
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
185
|
+
}
|
|
186
|
+
if (requireSelected) {
|
|
187
|
+
throw await routeError(page, 'Visible wallet session did not converge to the derived release account', deadline);
|
|
188
|
+
}
|
|
189
|
+
throw await routeError(page, 'Visible wallet session did not resolve the derived release account to one rendered account row', deadline);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function ensureVisibleSelectedAccount(page, expectedAddress, timeoutMs) {
|
|
193
|
+
if (!/^0x[0-9a-f]{40}$/u.test(expectedAddress)) {
|
|
194
|
+
throw new Error('Visible wallet session requires a derived EVM account address.');
|
|
195
|
+
}
|
|
196
|
+
const deadline = Date.now() + timeoutMs;
|
|
197
|
+
await openAccountList(page, deadline);
|
|
198
|
+
const initial = await queryExpectedAccount(page, expectedAddress, deadline, false);
|
|
199
|
+
let selectionPerformed = false;
|
|
200
|
+
if (!initial.selected) {
|
|
201
|
+
selectionPerformed = true;
|
|
202
|
+
try {
|
|
203
|
+
await withinDeadline(page.click(`[data-testid=${JSON.stringify(initial.testId)}]`), deadline);
|
|
204
|
+
} catch {
|
|
205
|
+
throw await routeError(page, 'Visible wallet session could not select the derived release account', deadline);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await openAccountList(page, deadline);
|
|
210
|
+
await queryExpectedAccount(page, expectedAddress, deadline, true);
|
|
211
|
+
return {
|
|
212
|
+
selectedIdentitySha256: createHash('sha256').update(expectedAddress).digest('hex'),
|
|
213
|
+
selectedAddressMatched: true,
|
|
214
|
+
selectionPerformed,
|
|
215
|
+
accountSurface: 'visible-account-list',
|
|
216
|
+
redacted: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
async function waitForSnapshot(session, timeoutMs, predicate, message) {
|
|
10
|
+
const deadline = Date.now() + timeoutMs;
|
|
11
|
+
while (Date.now() <= deadline) {
|
|
12
|
+
const snapshot = await session.snapshot();
|
|
13
|
+
const value = predicate(snapshot);
|
|
14
|
+
if (value) return { snapshot, value };
|
|
15
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
16
|
+
}
|
|
17
|
+
throw new Error(message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function openCustomToken(session, timeoutMs) {
|
|
21
|
+
let snapshot = await session.snapshot();
|
|
22
|
+
if (nativeNode(snapshot, 'input-token-address')) return snapshot;
|
|
23
|
+
if (!nativeNode(snapshot, 'add-token-screen')) {
|
|
24
|
+
if (!nativeNode(snapshot, 'token-list')) {
|
|
25
|
+
await session.execute('ui.press', {
|
|
26
|
+
test_id: 'homepage-section-title-tokens',
|
|
27
|
+
timeout_ms: timeoutMs,
|
|
28
|
+
settle: false,
|
|
29
|
+
});
|
|
30
|
+
await waitForSnapshot(
|
|
31
|
+
session,
|
|
32
|
+
timeoutMs,
|
|
33
|
+
(current) => nativeNode(current, 'token-list'),
|
|
34
|
+
'The Mobile token list did not open.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
await session.execute('ui.press', {
|
|
38
|
+
test_id: 'import-token-button',
|
|
39
|
+
timeout_ms: timeoutMs,
|
|
40
|
+
settle: false,
|
|
41
|
+
});
|
|
42
|
+
snapshot = (await waitForSnapshot(
|
|
43
|
+
session,
|
|
44
|
+
timeoutMs,
|
|
45
|
+
(current) => nativeNode(current, 'add-token-screen'),
|
|
46
|
+
'The Mobile token import screen did not open.',
|
|
47
|
+
)).snapshot;
|
|
48
|
+
}
|
|
49
|
+
if (!nativeNode(snapshot, 'input-token-address')) {
|
|
50
|
+
await session.execute('ui.press', { text: 'Custom token', timeout_ms: timeoutMs, settle: false });
|
|
51
|
+
snapshot = (await waitForSnapshot(
|
|
52
|
+
session,
|
|
53
|
+
timeoutMs,
|
|
54
|
+
(current) => nativeNode(current, 'input-token-address'),
|
|
55
|
+
'The Mobile custom-token form did not open.',
|
|
56
|
+
)).snapshot;
|
|
57
|
+
}
|
|
58
|
+
return snapshot;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function selectNetwork(session, network, timeoutMs) {
|
|
62
|
+
let snapshot = await session.snapshot();
|
|
63
|
+
const selected = (current) =>
|
|
64
|
+
nativeNode(current, 'select-network-button') &&
|
|
65
|
+
current.nodes.some((node) => node.visibleToUser !== false && nativeLabel(node) === network);
|
|
66
|
+
if (selected(snapshot)) return;
|
|
67
|
+
await session.execute('ui.press', {
|
|
68
|
+
test_id: 'select-network-button',
|
|
69
|
+
timeout_ms: timeoutMs,
|
|
70
|
+
settle: false,
|
|
71
|
+
});
|
|
72
|
+
await waitForSnapshot(
|
|
73
|
+
session,
|
|
74
|
+
timeoutMs,
|
|
75
|
+
(current) => current.nodes.find((node) =>
|
|
76
|
+
node.visibleToUser !== false && node.hittable === true && nativeLabel(node) === network
|
|
77
|
+
),
|
|
78
|
+
`${network} was not available in the Mobile token network selector.`,
|
|
79
|
+
);
|
|
80
|
+
await session.execute('ui.press', { text: network, timeout_ms: timeoutMs, settle: false });
|
|
81
|
+
snapshot = (await waitForSnapshot(
|
|
82
|
+
session,
|
|
83
|
+
timeoutMs,
|
|
84
|
+
selected,
|
|
85
|
+
`The Mobile token importer did not select ${network}.`,
|
|
86
|
+
)).snapshot;
|
|
87
|
+
return snapshot;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function findTokenRow(session, symbol, timeoutMs) {
|
|
91
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
92
|
+
const snapshot = await session.snapshot();
|
|
93
|
+
const row = nativeNode(snapshot, `asset-${symbol}`);
|
|
94
|
+
if (row) return row;
|
|
95
|
+
await session.execute('ui.scroll', {
|
|
96
|
+
direction: 'down',
|
|
97
|
+
amount: 0.75,
|
|
98
|
+
timeout_ms: timeoutMs,
|
|
99
|
+
settle: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`${symbol} did not appear in the Mobile token list after import.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
runAdapter(async (input) => {
|
|
106
|
+
const network = String(input.node?.network ?? '').trim();
|
|
107
|
+
const chainId = Number(input.node?.chain_id);
|
|
108
|
+
const address = String(input.node?.address ?? '').trim();
|
|
109
|
+
const expectedSymbol = String(input.node?.expected_symbol ?? '').trim().toUpperCase();
|
|
110
|
+
const expectedDecimals = input.node?.decimals == null ? null : String(input.node.decimals);
|
|
111
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
112
|
+
if (!network || !Number.isInteger(chainId) || chainId <= 0 || !/^0x[0-9a-f]{40}$/iu.test(address) || !expectedSymbol) {
|
|
113
|
+
throw new Error('metamask.assets.import_custom_token requires network, positive chain_id, ERC-20 address, and expected_symbol.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session = createNativeSession(input, 'Assets', `custom-token-${process.pid}`);
|
|
117
|
+
try {
|
|
118
|
+
await session.open();
|
|
119
|
+
await openCustomToken(session, timeoutMs);
|
|
120
|
+
await selectNetwork(session, network, timeoutMs);
|
|
121
|
+
await openCustomToken(session, timeoutMs);
|
|
122
|
+
await session.execute('ui.set_input', {
|
|
123
|
+
test_id: 'input-token-address',
|
|
124
|
+
value: address,
|
|
125
|
+
timeout_ms: timeoutMs,
|
|
126
|
+
settle: false,
|
|
127
|
+
});
|
|
128
|
+
const metadata = (await waitForSnapshot(
|
|
129
|
+
session,
|
|
130
|
+
Math.min(timeoutMs, 20_000),
|
|
131
|
+
(snapshot) => {
|
|
132
|
+
const symbol = nativeLabel(nativeNode(snapshot, 'input-token-symbol'));
|
|
133
|
+
const decimals = nativeLabel(nativeNode(snapshot, 'input-token-decimal'));
|
|
134
|
+
return symbol ? { symbol, decimals } : null;
|
|
135
|
+
},
|
|
136
|
+
'Mobile custom-token metadata did not resolve.',
|
|
137
|
+
)).value;
|
|
138
|
+
if (metadata.symbol.toUpperCase() !== expectedSymbol) {
|
|
139
|
+
throw new Error(`Expected token symbol ${expectedSymbol}, but Mobile metadata resolved ${metadata.symbol}.`);
|
|
140
|
+
}
|
|
141
|
+
if (expectedDecimals !== null && metadata.decimals !== expectedDecimals) {
|
|
142
|
+
throw new Error(`Expected token decimals ${expectedDecimals}, but Mobile metadata resolved ${metadata.decimals}.`);
|
|
143
|
+
}
|
|
144
|
+
await waitForSnapshot(
|
|
145
|
+
session,
|
|
146
|
+
timeoutMs,
|
|
147
|
+
(snapshot) => nativeNode(snapshot, 'token-import-next-button')?.enabled === true,
|
|
148
|
+
'Mobile custom-token metadata did not enable the import action.',
|
|
149
|
+
);
|
|
150
|
+
await session.execute('ui.press', {
|
|
151
|
+
test_id: 'token-import-next-button',
|
|
152
|
+
timeout_ms: timeoutMs,
|
|
153
|
+
settle: false,
|
|
154
|
+
});
|
|
155
|
+
await waitForSnapshot(
|
|
156
|
+
session,
|
|
157
|
+
timeoutMs,
|
|
158
|
+
(snapshot) => nativeNode(snapshot, 'add-confirm-custom-asset'),
|
|
159
|
+
'The Mobile custom-token confirmation did not open.',
|
|
160
|
+
);
|
|
161
|
+
await session.execute('ui.press', { text: 'Import', timeout_ms: timeoutMs, settle: false });
|
|
162
|
+
await waitForSnapshot(
|
|
163
|
+
session,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
(snapshot) => nativeNode(snapshot, 'token-list'),
|
|
166
|
+
'The Mobile token list did not reopen after import.',
|
|
167
|
+
);
|
|
168
|
+
const token = await findTokenRow(session, expectedSymbol, timeoutMs);
|
|
169
|
+
return {
|
|
170
|
+
action: input.action,
|
|
171
|
+
network,
|
|
172
|
+
chainId,
|
|
173
|
+
address,
|
|
174
|
+
metadata,
|
|
175
|
+
token: nativeLabel(token),
|
|
176
|
+
proofPath: 'native-accessibility-custom-token-import',
|
|
177
|
+
};
|
|
178
|
+
} finally {
|
|
179
|
+
await closeNativeSession(session);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
runAdapter(async (input) => {
|
|
10
|
+
const symbol = String(input.node?.symbol ?? '').trim().toUpperCase();
|
|
11
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
12
|
+
if (!/^[A-Z0-9._-]{2,20}$/u.test(symbol)) {
|
|
13
|
+
throw new Error('metamask.assets.open_details requires a valid token symbol.');
|
|
14
|
+
}
|
|
15
|
+
const session = createNativeSession(input, 'Assets', `details-${process.pid}`);
|
|
16
|
+
try {
|
|
17
|
+
await session.open();
|
|
18
|
+
let snapshot = await session.snapshot();
|
|
19
|
+
if (!nativeNode(snapshot, 'token-asset-overview')) {
|
|
20
|
+
await session.execute('ui.press', {
|
|
21
|
+
test_id: `asset-${symbol}`,
|
|
22
|
+
timeout_ms: timeoutMs,
|
|
23
|
+
settle: false,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const deadline = Date.now() + timeoutMs;
|
|
27
|
+
while (Date.now() <= deadline) {
|
|
28
|
+
snapshot = await session.snapshot();
|
|
29
|
+
const symbolVisible = snapshot.nodes.some((node) => nativeLabel(node).toUpperCase() === symbol);
|
|
30
|
+
if (nativeNode(snapshot, 'token-asset-overview') && symbolVisible) {
|
|
31
|
+
return {
|
|
32
|
+
action: input.action,
|
|
33
|
+
symbol,
|
|
34
|
+
proofPath: 'native-accessibility-token-details-navigation',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Asset details for ${symbol} did not become visible.`);
|
|
40
|
+
} finally {
|
|
41
|
+
await closeNativeSession(session);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
const NON_SYMBOL_LABELS = new Set([
|
|
10
|
+
'BUY',
|
|
11
|
+
'COPY',
|
|
12
|
+
'MORE',
|
|
13
|
+
'RECEIVE',
|
|
14
|
+
'SEND',
|
|
15
|
+
'SHARE',
|
|
16
|
+
'SWAP',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function observedSymbol(labels, expectedSymbol) {
|
|
20
|
+
if (expectedSymbol) {
|
|
21
|
+
return labels.find((label) => label.toUpperCase() === expectedSymbol) ?? null;
|
|
22
|
+
}
|
|
23
|
+
return labels.find((label) => {
|
|
24
|
+
const candidate = label.trim();
|
|
25
|
+
return candidate === candidate.toUpperCase()
|
|
26
|
+
&& /^[A-Z][A-Z0-9._-]{1,19}$/u.test(candidate)
|
|
27
|
+
&& !NON_SYMBOL_LABELS.has(candidate);
|
|
28
|
+
}) ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
runAdapter(async (input) => {
|
|
32
|
+
const expectedSymbol = String(input.node?.expected_symbol ?? '').trim().toUpperCase();
|
|
33
|
+
const requireChart = input.node?.require_chart === true;
|
|
34
|
+
const requireMarketCap = input.node?.require_market_cap === true;
|
|
35
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
36
|
+
const session = createNativeSession(input, 'Assets', `read-details-${process.pid}`);
|
|
37
|
+
try {
|
|
38
|
+
await session.open();
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
let snapshot;
|
|
41
|
+
let labels = [];
|
|
42
|
+
let symbol = null;
|
|
43
|
+
let price = null;
|
|
44
|
+
let chartRendered = false;
|
|
45
|
+
let chartEmpty = false;
|
|
46
|
+
let marketCap = null;
|
|
47
|
+
let malformed = false;
|
|
48
|
+
while (Date.now() <= deadline) {
|
|
49
|
+
snapshot = await session.snapshot();
|
|
50
|
+
labels = snapshot.nodes.map(nativeLabel).filter(Boolean);
|
|
51
|
+
symbol = observedSymbol(labels, expectedSymbol);
|
|
52
|
+
price = nativeLabel(nativeNode(snapshot, 'token-price')) || null;
|
|
53
|
+
chartRendered = Boolean(
|
|
54
|
+
nativeNode(snapshot, 'chart_surface')
|
|
55
|
+
?? nativeNode(snapshot, 'tv_chart_container'),
|
|
56
|
+
);
|
|
57
|
+
chartEmpty = labels.some((label) => /no chart data/iu.test(label));
|
|
58
|
+
marketCap = labels.find((label) => /market cap/iu.test(label)) ?? null;
|
|
59
|
+
malformed = labels.some((label) => /(?:\bNaN\b|\bundefined\b|\bnull\b)/u.test(label));
|
|
60
|
+
const ready = nativeNode(snapshot, 'token-asset-overview') && symbol && price
|
|
61
|
+
&& (!requireChart || chartRendered || chartEmpty)
|
|
62
|
+
&& (!requireMarketCap || marketCap);
|
|
63
|
+
if (ready) break;
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
65
|
+
}
|
|
66
|
+
if (!nativeNode(snapshot, 'token-asset-overview')) {
|
|
67
|
+
throw new Error('Expected the visible Mobile token details surface.');
|
|
68
|
+
}
|
|
69
|
+
if (expectedSymbol && !symbol) {
|
|
70
|
+
throw new Error(`Expected ${expectedSymbol} asset details, but the symbol was not visible.`);
|
|
71
|
+
}
|
|
72
|
+
if (!price) throw new Error('Expected a formatted current token price.');
|
|
73
|
+
if (requireChart && (!chartRendered || chartEmpty)) {
|
|
74
|
+
throw new Error('Expected a rendered asset price chart without an empty state.');
|
|
75
|
+
}
|
|
76
|
+
if (requireMarketCap && !marketCap) {
|
|
77
|
+
throw new Error('Expected a formatted asset market cap.');
|
|
78
|
+
}
|
|
79
|
+
if (malformed) throw new Error('Asset details contain a malformed visible value.');
|
|
80
|
+
return {
|
|
81
|
+
action: input.action,
|
|
82
|
+
source: 'visible-ui',
|
|
83
|
+
platform: 'mobile',
|
|
84
|
+
surface: 'asset-details',
|
|
85
|
+
symbol,
|
|
86
|
+
price,
|
|
87
|
+
marketCap,
|
|
88
|
+
chartRendered,
|
|
89
|
+
chartEmpty,
|
|
90
|
+
malformed,
|
|
91
|
+
proofPath: 'native-accessibility-token-details',
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
await closeNativeSession(session);
|
|
95
|
+
}
|
|
96
|
+
});
|