@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.39.0 - 2026-08-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add opaque Android release actions for token details, sorting, visibility, custom-token import, custom-network lifecycle, Perps search and watchlists, Swap Max, slippage, guarded submission, and wallet locking through native accessibility.
|
|
10
|
+
- Make shared Assets, Perps, and Swap release recipes executable on untouched Extension and Android release artifacts with parameterized visible-state assertions.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Keep physical-device actions on isolated semantic sessions, bypass biometric authentication with password unlock, verify every recovery-phrase field, and reject incorrect token metadata as a product failure.
|
|
15
|
+
- Prove Extension token visibility changes and parse visible custom-slippage labels consistently across both clients.
|
|
16
|
+
|
|
5
17
|
## 0.38.1 - 2026-08-14
|
|
6
18
|
|
|
7
19
|
### Fixed
|
package/dist/adapters.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
|
|
9
9
|
import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
10
10
|
import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
|
|
11
|
+
import { nativeAgentDeviceStateDir } from "../library/actions/mobile/platform/native-session-name.mjs";
|
|
11
12
|
import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
|
|
12
13
|
import { resolveWalletImportCredentials, validateWalletImportOptions } from "../library/actions/shared/wallet/import-source.mjs";
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
@@ -37,6 +38,7 @@ function simpleAdapter(action, executor) {
|
|
|
37
38
|
}
|
|
38
39
|
const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
|
|
39
40
|
"metamask.assets.read_visible_state",
|
|
41
|
+
"metamask.assets.open_details",
|
|
40
42
|
"metamask.assets.read_details",
|
|
41
43
|
"metamask.assets.verify_sorting",
|
|
42
44
|
"metamask.assets.set_token_visibility",
|
|
@@ -52,6 +54,7 @@ const LIVE_ONLY_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
52
54
|
"metamask.networks.add_chainlist",
|
|
53
55
|
"metamask.networks.remove_custom",
|
|
54
56
|
"metamask.perps.read_visible_state",
|
|
57
|
+
"metamask.perps.search_markets",
|
|
55
58
|
"metamask.perps.compare_provider_market",
|
|
56
59
|
"metamask.perps.read_eligibility",
|
|
57
60
|
"metamask.perps.select_market_filter",
|
|
@@ -250,10 +253,11 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
250
253
|
const bundledActions = [
|
|
251
254
|
...walletActions,
|
|
252
255
|
"metamask.assets.read_visible_state",
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
"metamask.assets.open_details",
|
|
257
|
+
"metamask.assets.read_details",
|
|
258
|
+
"metamask.assets.verify_sorting",
|
|
259
|
+
"metamask.assets.set_token_visibility",
|
|
260
|
+
"metamask.assets.import_custom_token",
|
|
257
261
|
...platform === "extension" ? [
|
|
258
262
|
"metamask.assets.prepare_send",
|
|
259
263
|
"metamask.assets.review_send",
|
|
@@ -261,10 +265,14 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
261
265
|
"metamask.assets.set_custom_gas",
|
|
262
266
|
"metamask.assets.select_network_scope"
|
|
263
267
|
] : [],
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
268
|
+
"metamask.wallet.lock",
|
|
269
|
+
"metamask.networks.read_visible_state",
|
|
270
|
+
"metamask.networks.add_custom",
|
|
271
|
+
...platform === "extension" ? ["metamask.networks.add_chainlist"] : [],
|
|
272
|
+
"metamask.networks.remove_custom",
|
|
267
273
|
"metamask.perps.read_visible_state",
|
|
274
|
+
"metamask.perps.search_markets",
|
|
275
|
+
"metamask.perps.set_market_favorite",
|
|
268
276
|
...platform === "extension" ? ["metamask.perps.compare_provider_market"] : [],
|
|
269
277
|
...platform === "extension" ? ["metamask.perps.read_eligibility"] : [],
|
|
270
278
|
...platform === "extension" ? [
|
|
@@ -275,9 +283,9 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
275
283
|
"metamask.swap_bridge.read_visible_state",
|
|
276
284
|
"metamask.swap_bridge.select_assets",
|
|
277
285
|
"metamask.swap_bridge.set_amount",
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
"metamask.swap_bridge.set_max_amount",
|
|
287
|
+
"metamask.swap_bridge.set_slippage",
|
|
288
|
+
"metamask.swap_bridge.submit_transaction",
|
|
281
289
|
...platform === "extension" ? ["metamask.settings.set_basic_functionality"] : [],
|
|
282
290
|
"metamask.perps.read_positions",
|
|
283
291
|
"metamask.perps.ensure_positions",
|
|
@@ -900,9 +908,10 @@ async function executeNativeProviderAction(action, node, context, createTranspor
|
|
|
900
908
|
}
|
|
901
909
|
const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
|
|
902
910
|
const session = `mm-harness-${process.pid}-${context.nodeId}`.replace(/[^a-zA-Z0-9._-]/gu, "-");
|
|
911
|
+
const stateDir = nativeAgentDeviceStateDir(context.env ?? {}, context.projectRoot, String(device));
|
|
903
912
|
let transport;
|
|
904
913
|
try {
|
|
905
|
-
transport = await createTransport({ platform, device, app, session });
|
|
914
|
+
transport = await createTransport({ platform, device, app, session, stateDir });
|
|
906
915
|
} catch (error) {
|
|
907
916
|
throw new Error(
|
|
908
917
|
`${action} could not start the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
|
|
3
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
4
|
+
const symbol = String(input.node?.symbol ?? '').trim().toUpperCase();
|
|
5
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
6
|
+
if (!/^[A-Z0-9._-]{2,20}$/u.test(symbol)) {
|
|
7
|
+
throw new Error('metamask.assets.open_details requires a valid token symbol.');
|
|
8
|
+
}
|
|
9
|
+
const selector = await page.evaluate(`(() => {
|
|
10
|
+
const symbol = ${JSON.stringify(symbol)};
|
|
11
|
+
for (const [index, row] of [...document.querySelectorAll('[data-testid="multichain-token-list-button"]')].entries()) {
|
|
12
|
+
const amount = String(row.querySelector('[data-testid="multichain-token-list-item-value"]')?.innerText || '').trim();
|
|
13
|
+
const observed = amount.match(/\\b([A-Za-z][A-Za-z0-9._-]{1,19})$/u)?.[1]?.toUpperCase();
|
|
14
|
+
if (observed !== symbol) continue;
|
|
15
|
+
row.setAttribute('data-mm-harness-asset-index', String(index));
|
|
16
|
+
return '[data-mm-harness-asset-index="' + index + '"]';
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
})()`);
|
|
20
|
+
if (!selector) throw new Error(`Visible asset ${symbol} was not found.`);
|
|
21
|
+
await page.click(selector);
|
|
22
|
+
const deadline = Date.now() + timeoutMs;
|
|
23
|
+
while (Date.now() <= deadline) {
|
|
24
|
+
if (String(await page.evaluate('location.hash')).startsWith('#/asset/')) {
|
|
25
|
+
return { action: input.action, symbol, proofPath: 'trusted-pointer-visible-ui' };
|
|
26
|
+
}
|
|
27
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Asset details for ${symbol} did not open.`);
|
|
30
|
+
}));
|
|
@@ -75,6 +75,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
75
75
|
}
|
|
76
76
|
const requested = input.node.visible;
|
|
77
77
|
const requireUnchanged = input.node?.require_unchanged === true;
|
|
78
|
+
const requireChange = input.node?.require_change === true;
|
|
78
79
|
const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
|
|
79
80
|
|
|
80
81
|
if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
|
|
@@ -91,6 +92,9 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
91
92
|
if (requireUnchanged && before.enabled !== requested) {
|
|
92
93
|
throw new Error(`${query} token visibility did not persist as ${requested ? 'enabled' : 'disabled'}.`);
|
|
93
94
|
}
|
|
95
|
+
if (requireChange && before.enabled === requested) {
|
|
96
|
+
throw new Error(`${query} token visibility was already ${requested ? 'enabled' : 'disabled'}.`);
|
|
97
|
+
}
|
|
94
98
|
if (before.enabled !== requested) {
|
|
95
99
|
await page.click(dataTestId(before.testId));
|
|
96
100
|
}
|
|
@@ -120,6 +124,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
120
124
|
query,
|
|
121
125
|
requested,
|
|
122
126
|
requireUnchanged,
|
|
127
|
+
requireChange,
|
|
123
128
|
changed: before.enabled !== after.enabled,
|
|
124
129
|
searchResult: after.label,
|
|
125
130
|
toggleTestId: after.testId,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
|
|
3
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
4
|
+
const query = String(input.node?.query ?? '').trim();
|
|
5
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
6
|
+
if (!query) throw new Error('metamask.perps.search_markets requires a non-empty query.');
|
|
7
|
+
await page.setInput(dataTestId('search-input'), query);
|
|
8
|
+
const deadline = Date.now() + timeoutMs;
|
|
9
|
+
let marketIds = [];
|
|
10
|
+
while (Date.now() <= deadline) {
|
|
11
|
+
marketIds = await page.evaluate(`(() => [...document.querySelectorAll('[data-testid^="market-row-"]')]
|
|
12
|
+
.filter((element) => {
|
|
13
|
+
const style = getComputedStyle(element);
|
|
14
|
+
const rect = element.getBoundingClientRect();
|
|
15
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
16
|
+
})
|
|
17
|
+
.map((element) => String(element.getAttribute('data-testid') || ''))
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.slice(0, 100))()`);
|
|
20
|
+
if (marketIds.length > 0) break;
|
|
21
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
|
|
22
|
+
}
|
|
23
|
+
if (marketIds.length === 0) {
|
|
24
|
+
throw new Error(`Perps market search returned no visible rows for ${JSON.stringify(query)}.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
action: input.action,
|
|
28
|
+
query,
|
|
29
|
+
marketIds,
|
|
30
|
+
proofPath: 'trusted-keyboard-visible-ui',
|
|
31
|
+
};
|
|
32
|
+
}));
|
|
@@ -9,6 +9,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
9
9
|
throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
|
|
10
10
|
}
|
|
11
11
|
const desired = input.node.favorite;
|
|
12
|
+
const requireUnchanged = input.node?.require_unchanged === true;
|
|
12
13
|
const selector = dataTestId('perps-market-detail-favorite-button');
|
|
13
14
|
const readState = () => page.evaluate(`(() => {
|
|
14
15
|
const market = ${JSON.stringify(market)};
|
|
@@ -35,11 +36,15 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
35
36
|
if (after.favorite !== desired) {
|
|
36
37
|
throw new Error(`Perps favorite state did not become ${desired}.`);
|
|
37
38
|
}
|
|
39
|
+
const changed = before.favorite !== after.favorite;
|
|
40
|
+
if (requireUnchanged && changed) {
|
|
41
|
+
throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
|
|
42
|
+
}
|
|
38
43
|
return {
|
|
39
44
|
action: input.action,
|
|
40
45
|
market,
|
|
41
46
|
requestedFavorite: desired,
|
|
42
|
-
changed
|
|
47
|
+
changed,
|
|
43
48
|
before,
|
|
44
49
|
after,
|
|
45
50
|
proofPath: 'trusted-pointer-visible-ui',
|
|
@@ -13,6 +13,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
13
13
|
'bridge-source-button',
|
|
14
14
|
'bridge-destination-button',
|
|
15
15
|
'bridge__header-settings-button',
|
|
16
|
+
'slippage-edit-button',
|
|
16
17
|
'prepare-swap-page-error',
|
|
17
18
|
'bridge-missing-price-banner',
|
|
18
19
|
];
|
|
@@ -39,6 +40,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
39
40
|
route: location.hash,
|
|
40
41
|
title: document.title,
|
|
41
42
|
items,
|
|
43
|
+
slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
|
|
42
44
|
quote: {
|
|
43
45
|
slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
|
|
44
46
|
networkFee: text('[data-testid="network-fees-sponsored"], [data-testid="network-fees-included"], [data-testid="network-fees"]') || null,
|
|
@@ -52,16 +54,10 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
52
54
|
})()`);
|
|
53
55
|
const state = normalizeVisibleSwapBridgeState(raw, options, 'extension');
|
|
54
56
|
const requireQuote = input.node?.require_quote === true;
|
|
55
|
-
const expectedSlippage = input.node?.expected_slippage == null
|
|
56
|
-
? null
|
|
57
|
-
: Number(input.node.expected_slippage);
|
|
58
57
|
if (requireQuote && (!raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
|
|
59
58
|
|| !/^(?:Swap|Bridge)$/iu.test(String(raw.quote.cta)))) {
|
|
60
59
|
throw new Error('Swap quote is not ready for review.');
|
|
61
60
|
}
|
|
62
|
-
if (expectedSlippage !== null && raw.quote.slippage !== `${expectedSlippage}%`) {
|
|
63
|
-
throw new Error(`Swap slippage mismatch: expected ${expectedSlippage}%, observed ${raw.quote.slippage ?? 'none'}.`);
|
|
64
|
-
}
|
|
65
61
|
return {
|
|
66
62
|
action: input.action,
|
|
67
63
|
...state,
|
|
@@ -7,6 +7,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
7
7
|
await page.setInput(source, options.amount);
|
|
8
8
|
const deadline = Date.now() + options.timeoutMs;
|
|
9
9
|
let observed;
|
|
10
|
+
let lastRefreshAt = 0;
|
|
10
11
|
do {
|
|
11
12
|
observed = await page.evaluate(`(() => {
|
|
12
13
|
const value = (testId) => {
|
|
@@ -23,13 +24,21 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
23
24
|
noQuote: document.body.innerText.includes("This trade route isn't available right now"),
|
|
24
25
|
};
|
|
25
26
|
})()`);
|
|
26
|
-
if (!options.waitForQuote || observed.noQuote
|
|
27
|
+
if (!options.waitForQuote || observed.noQuote) break;
|
|
28
|
+
if (/^(?:Get new quote|Refresh quote)$/iu.test(String(observed.cta))
|
|
29
|
+
&& observed.ctaEnabled && Date.now() - lastRefreshAt >= 1000) {
|
|
30
|
+
await page.click(dataTestId('bridge-cta-button'));
|
|
31
|
+
lastRefreshAt = Date.now();
|
|
32
|
+
}
|
|
33
|
+
if (Number(observed.destinationAmount) > 0 && observed.ctaEnabled
|
|
34
|
+
&& /^(?:Swap|Bridge)$/iu.test(String(observed.cta))) break;
|
|
27
35
|
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
|
|
28
36
|
} while (Date.now() < deadline);
|
|
29
37
|
if (observed.sourceAmount !== options.amount) {
|
|
30
38
|
throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
|
|
31
39
|
}
|
|
32
|
-
if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
|
|
40
|
+
if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
|
|
41
|
+
&& observed.ctaEnabled && /^(?:Swap|Bridge)$/iu.test(String(observed.cta)))) {
|
|
33
42
|
throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
|
|
34
43
|
}
|
|
35
44
|
return { action: input.action, ...observed, proofPath: 'visible-ui-amount' };
|
|
@@ -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
|
+
});
|