@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,43 @@
|
|
|
1
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import {
|
|
3
|
+
closeNativeSession,
|
|
4
|
+
createNativeSession,
|
|
5
|
+
} from '../platform/native-session.mjs';
|
|
6
|
+
import { resolveVisibleNetworkOptions } from '../../shared/networks/visible-state.mjs';
|
|
7
|
+
import { findNetwork, openNetworkManager } from './network-management.mjs';
|
|
8
|
+
|
|
9
|
+
runAdapter(async (input) => {
|
|
10
|
+
const options = resolveVisibleNetworkOptions(input.node);
|
|
11
|
+
if (!options.chainId) {
|
|
12
|
+
throw new Error('metamask.networks.read_visible_state requires chain_id on Mobile.');
|
|
13
|
+
}
|
|
14
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
15
|
+
const session = createNativeSession(input, 'Networks', `read-${process.pid}`);
|
|
16
|
+
try {
|
|
17
|
+
await session.open();
|
|
18
|
+
await openNetworkManager(session, timeoutMs);
|
|
19
|
+
const item = await findNetwork(session, options.network, options.chainId, timeoutMs);
|
|
20
|
+
if (!item) {
|
|
21
|
+
throw new Error(`${options.network}${options.chainId ? ` (${options.chainId})` : ''} was absent from Mobile Networks settings.`);
|
|
22
|
+
}
|
|
23
|
+
if (options.settleMs > 0) {
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, options.settleMs));
|
|
25
|
+
if (!await findNetwork(session, options.network, options.chainId, timeoutMs)) {
|
|
26
|
+
throw new Error(`${options.network} did not persist across the settle window.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
action: input.action,
|
|
31
|
+
source: 'visible-ui',
|
|
32
|
+
platform: 'mobile',
|
|
33
|
+
network: options.network,
|
|
34
|
+
...(options.chainId ? { chainId: options.chainId } : {}),
|
|
35
|
+
item,
|
|
36
|
+
settleMs: options.settleMs,
|
|
37
|
+
stable: true,
|
|
38
|
+
proofPath: 'native-accessibility-network-settings',
|
|
39
|
+
};
|
|
40
|
+
} finally {
|
|
41
|
+
await closeNativeSession(session);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import {
|
|
3
|
+
closeNativeSession,
|
|
4
|
+
createNativeSession,
|
|
5
|
+
nativeLabel,
|
|
6
|
+
nativeNode,
|
|
7
|
+
} from '../platform/native-session.mjs';
|
|
8
|
+
import {
|
|
9
|
+
findNetwork,
|
|
10
|
+
openNetworkManager,
|
|
11
|
+
waitForSnapshot,
|
|
12
|
+
} from './network-management.mjs';
|
|
13
|
+
|
|
14
|
+
runAdapter(async (input) => {
|
|
15
|
+
const network = String(input.node?.network ?? '').trim();
|
|
16
|
+
const chainId = Number(input.node?.chain_id);
|
|
17
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
18
|
+
if (!network || !Number.isInteger(chainId) || chainId <= 0) {
|
|
19
|
+
throw new Error('metamask.networks.remove_custom requires network and positive chain_id.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const session = createNativeSession(input, 'Networks', `remove-${process.pid}`);
|
|
23
|
+
try {
|
|
24
|
+
await session.open();
|
|
25
|
+
await openNetworkManager(session, timeoutMs);
|
|
26
|
+
const item = await findNetwork(session, network, chainId, timeoutMs);
|
|
27
|
+
if (!item) throw new Error(`${network} (${chainId}) is not present for removal.`);
|
|
28
|
+
await session.execute('ui.press', { test_id: item.id, timeout_ms: timeoutMs, settle: false });
|
|
29
|
+
const details = await waitForSnapshot(
|
|
30
|
+
session,
|
|
31
|
+
timeoutMs,
|
|
32
|
+
(snapshot) => {
|
|
33
|
+
const container = nativeNode(snapshot, 'network-details-view-container');
|
|
34
|
+
if (!container) return null;
|
|
35
|
+
const labeled = snapshot.nodes.find((node) =>
|
|
36
|
+
node.hittable === true && /delete|remove|trash/iu.test(nativeLabel(node))
|
|
37
|
+
);
|
|
38
|
+
if (labeled) return labeled;
|
|
39
|
+
const unlabeled = snapshot.nodes.filter((node) =>
|
|
40
|
+
node.parentIndex === container.index &&
|
|
41
|
+
node.hittable === true &&
|
|
42
|
+
/ViewGroup$/u.test(String(node.type)) &&
|
|
43
|
+
!node.identifier &&
|
|
44
|
+
!nativeLabel(node) &&
|
|
45
|
+
node.ref
|
|
46
|
+
);
|
|
47
|
+
return unlabeled.length === 1 ? unlabeled[0] : null;
|
|
48
|
+
},
|
|
49
|
+
`${network} details did not expose one unambiguous delete control.`,
|
|
50
|
+
);
|
|
51
|
+
const deleteControl = details.value;
|
|
52
|
+
if (deleteControl?.identifier) {
|
|
53
|
+
await session.execute('ui.press', {
|
|
54
|
+
test_id: deleteControl.identifier,
|
|
55
|
+
timeout_ms: timeoutMs,
|
|
56
|
+
settle: false,
|
|
57
|
+
});
|
|
58
|
+
} else if (deleteControl?.ref) {
|
|
59
|
+
await session.client.interactions.press({
|
|
60
|
+
...session.selection,
|
|
61
|
+
ref: `@${deleteControl.ref}`,
|
|
62
|
+
settle: true,
|
|
63
|
+
verify: true,
|
|
64
|
+
timeoutMs,
|
|
65
|
+
responseLevel: 'digest',
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error('The custom-network details header did not expose one unambiguous delete control.');
|
|
69
|
+
}
|
|
70
|
+
await waitForSnapshot(
|
|
71
|
+
session,
|
|
72
|
+
timeoutMs,
|
|
73
|
+
(snapshot) => nativeNode(snapshot, 'networks-settings-delete-confirm-button'),
|
|
74
|
+
`The ${network} delete confirmation did not open.`,
|
|
75
|
+
);
|
|
76
|
+
await session.execute('ui.press', {
|
|
77
|
+
test_id: 'networks-settings-delete-confirm-button',
|
|
78
|
+
timeout_ms: timeoutMs,
|
|
79
|
+
settle: false,
|
|
80
|
+
});
|
|
81
|
+
await waitForSnapshot(
|
|
82
|
+
session,
|
|
83
|
+
timeoutMs,
|
|
84
|
+
(snapshot) => nativeNode(snapshot, 'networks-settings-container'),
|
|
85
|
+
'Mobile Networks settings did not reopen after deletion.',
|
|
86
|
+
);
|
|
87
|
+
if (await findNetwork(session, network, chainId, timeoutMs)) {
|
|
88
|
+
throw new Error(`${network} (${chainId}) remained after deletion.`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
action: input.action,
|
|
92
|
+
network,
|
|
93
|
+
chainId,
|
|
94
|
+
removed: true,
|
|
95
|
+
proofPath: 'native-accessibility-network-settings-removal',
|
|
96
|
+
};
|
|
97
|
+
} finally {
|
|
98
|
+
await closeNativeSession(session);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
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 query = String(input.node?.query ?? '').trim();
|
|
11
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
12
|
+
if (!query) throw new Error('metamask.perps.search_markets requires a non-empty query.');
|
|
13
|
+
const session = createNativeSession(input, 'Perps', `market-search-${process.pid}`);
|
|
14
|
+
try {
|
|
15
|
+
await session.open();
|
|
16
|
+
await session.execute('ui.set_input', {
|
|
17
|
+
test_id: 'perps-market-list-search-bar',
|
|
18
|
+
value: query,
|
|
19
|
+
timeout_ms: timeoutMs,
|
|
20
|
+
settle: false,
|
|
21
|
+
});
|
|
22
|
+
const deadline = Date.now() + timeoutMs;
|
|
23
|
+
let snapshot;
|
|
24
|
+
let marketIds = [];
|
|
25
|
+
while (Date.now() <= deadline) {
|
|
26
|
+
snapshot = await session.snapshot();
|
|
27
|
+
marketIds = snapshot.nodes
|
|
28
|
+
.map((node) => String(node.identifier ?? ''))
|
|
29
|
+
.filter((id) => /^perps-market-row-item-(?!.*(?:-asset-label|-token-logo)$)/u.test(id));
|
|
30
|
+
const field = nativeNode(snapshot, 'perps-market-list-search-bar');
|
|
31
|
+
if (nativeLabel(field).toLowerCase().includes(query.toLowerCase()) && marketIds.length > 0) break;
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
33
|
+
}
|
|
34
|
+
if (marketIds.length === 0) {
|
|
35
|
+
throw new Error(`Perps market search returned no visible rows for ${JSON.stringify(query)}.`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
action: input.action,
|
|
39
|
+
query,
|
|
40
|
+
marketIds: marketIds.slice(0, 100),
|
|
41
|
+
proofPath: 'native-accessibility-search',
|
|
42
|
+
};
|
|
43
|
+
} finally {
|
|
44
|
+
await closeNativeSession(session);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
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 FAVORITE_IDS = [
|
|
10
|
+
'perps-market-header-favorite-button',
|
|
11
|
+
'perps-pro-market-header-favorite-button',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function favoriteControl(snapshot) {
|
|
15
|
+
for (const testId of FAVORITE_IDS) {
|
|
16
|
+
const node = nativeNode(snapshot, testId);
|
|
17
|
+
if (/(?:add to|remove from) watchlist/iu.test(nativeLabel(node))) {
|
|
18
|
+
return { node, testId };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function favoriteState(control) {
|
|
25
|
+
const label = nativeLabel(control?.node);
|
|
26
|
+
if (/remove from watchlist/iu.test(label)) return true;
|
|
27
|
+
if (/add to watchlist/iu.test(label)) return false;
|
|
28
|
+
throw new Error(`Perps favorite control has an unsupported label: ${JSON.stringify(label)}.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
runAdapter(async (input) => {
|
|
32
|
+
const market = String(input.node?.market ?? '').trim().toUpperCase();
|
|
33
|
+
const desired = input.node?.favorite;
|
|
34
|
+
const requireUnchanged = input.node?.require_unchanged === true;
|
|
35
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
36
|
+
if (!/^[A-Z0-9:_-]{2,20}$/u.test(market)) {
|
|
37
|
+
throw new Error('metamask.perps.set_market_favorite requires a valid market symbol.');
|
|
38
|
+
}
|
|
39
|
+
if (typeof desired !== 'boolean') {
|
|
40
|
+
throw new Error('metamask.perps.set_market_favorite requires favorite=true or false.');
|
|
41
|
+
}
|
|
42
|
+
let session = createNativeSession(input, 'Perps', `favorite-${process.pid}`);
|
|
43
|
+
try {
|
|
44
|
+
await session.open();
|
|
45
|
+
let snapshot = await session.snapshot();
|
|
46
|
+
const subtitle = nativeLabel(
|
|
47
|
+
nativeNode(snapshot, 'perps-market-header-subtitle')
|
|
48
|
+
?? nativeNode(snapshot, 'perps-pro-market-header-subtitle'),
|
|
49
|
+
).toUpperCase();
|
|
50
|
+
if (!subtitle.startsWith(`${market}-`)) {
|
|
51
|
+
throw new Error(`Visible Perps market ${JSON.stringify(subtitle)} does not match ${market}.`);
|
|
52
|
+
}
|
|
53
|
+
let control = favoriteControl(snapshot);
|
|
54
|
+
if (!control && nativeNode(snapshot, 'perps-mode-toggle-lite')?.enabled !== false) {
|
|
55
|
+
try {
|
|
56
|
+
await session.execute('ui.press', {
|
|
57
|
+
test_id: 'perps-mode-toggle-lite',
|
|
58
|
+
timeout_ms: timeoutMs,
|
|
59
|
+
settle: false,
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!/Selector did not match/iu.test(String(error?.message ?? error))) throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const controlDeadline = Date.now() + timeoutMs;
|
|
66
|
+
let lastControls = [];
|
|
67
|
+
while (Date.now() <= controlDeadline && !control) {
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
69
|
+
snapshot = await session.snapshot();
|
|
70
|
+
lastControls = snapshot.nodes
|
|
71
|
+
.filter((node) => /(?:favorite|mode-toggle|market-header)/iu.test(String(node.identifier ?? '')))
|
|
72
|
+
.map((node) => ({ id: String(node.identifier ?? ''), label: nativeLabel(node) }))
|
|
73
|
+
.slice(0, 20);
|
|
74
|
+
control = favoriteControl(snapshot);
|
|
75
|
+
}
|
|
76
|
+
if (!control) {
|
|
77
|
+
throw new Error(`Perps favorite control is not visible in Lite or Pro mode; observed ${JSON.stringify(lastControls)}.`);
|
|
78
|
+
}
|
|
79
|
+
const before = favoriteState(control);
|
|
80
|
+
if (before !== desired) {
|
|
81
|
+
await session.execute('ui.press', {
|
|
82
|
+
test_id: control.testId,
|
|
83
|
+
timeout_ms: timeoutMs,
|
|
84
|
+
settle: false,
|
|
85
|
+
});
|
|
86
|
+
await closeNativeSession(session);
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
88
|
+
session = createNativeSession(input, 'Perps', `favorite-proof-${process.pid}`);
|
|
89
|
+
await session.open();
|
|
90
|
+
}
|
|
91
|
+
const deadline = Date.now() + timeoutMs;
|
|
92
|
+
let after = before;
|
|
93
|
+
while (Date.now() <= deadline) {
|
|
94
|
+
snapshot = await session.snapshot();
|
|
95
|
+
control = favoriteControl(snapshot);
|
|
96
|
+
if (control) after = favoriteState(control);
|
|
97
|
+
if (after === desired) break;
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
99
|
+
}
|
|
100
|
+
if (after !== desired) throw new Error(`Perps favorite state did not become ${desired}.`);
|
|
101
|
+
const changed = before !== after;
|
|
102
|
+
if (requireUnchanged && changed) {
|
|
103
|
+
throw new Error(`Perps favorite state for ${market} did not persist across navigation.`);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
action: input.action,
|
|
107
|
+
market,
|
|
108
|
+
requestedFavorite: desired,
|
|
109
|
+
changed,
|
|
110
|
+
before,
|
|
111
|
+
after,
|
|
112
|
+
proofPath: 'native-accessibility-watchlist',
|
|
113
|
+
};
|
|
114
|
+
} finally {
|
|
115
|
+
await closeNativeSession(session);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
@@ -1,5 +1,19 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
1
3
|
export function nativeSessionName(env, device, app = 'io.metamask') {
|
|
2
4
|
const configured = String(env.METAMASK_RECIPE_MOBILE_SESSION ?? '').trim();
|
|
3
5
|
const source = configured || `mm-harness-${app}-${device}`;
|
|
4
6
|
return source.replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 120);
|
|
5
7
|
}
|
|
8
|
+
|
|
9
|
+
export function nativeAgentDeviceStateDir(env, projectRoot, device) {
|
|
10
|
+
const configured = String(
|
|
11
|
+
env.FARMSLOT_AGENT_DEVICE_STATE_DIR ?? env.AGENT_DEVICE_STATE_DIR ?? '',
|
|
12
|
+
).trim();
|
|
13
|
+
if (configured) return path.resolve(configured);
|
|
14
|
+
if (!String(projectRoot ?? '').trim()) {
|
|
15
|
+
throw new Error('Opaque Mobile UI execution requires a project root for Agent Device isolation.');
|
|
16
|
+
}
|
|
17
|
+
const deviceKey = String(device).replace(/[^A-Za-z0-9._-]/gu, '-');
|
|
18
|
+
return path.join(path.resolve(projectRoot), 'temp', 'recipe', 'runtime', 'agent-device', deviceKey);
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createAgentDeviceUiTransport } from '@farmslot/expo-recipe';
|
|
2
|
+
import { createAgentDeviceClient } from 'agent-device';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
nativeAgentDeviceStateDir,
|
|
6
|
+
nativeSessionName,
|
|
7
|
+
} from './native-session-name.mjs';
|
|
8
|
+
|
|
9
|
+
export function createNativeSession(input, domain, sessionSuffix = '') {
|
|
10
|
+
const env = { ...process.env, ...(input.context?.env ?? {}) };
|
|
11
|
+
const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
|
|
12
|
+
if (!device) throw new Error(`Opaque Mobile ${domain} actions require one explicit Android device serial.`);
|
|
13
|
+
const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
|
|
14
|
+
const baseSession = nativeSessionName(env, device, app);
|
|
15
|
+
const session = sessionSuffix
|
|
16
|
+
? `${baseSession}-${String(sessionSuffix).replace(/[^A-Za-z0-9._-]/gu, '-')}`.slice(0, 120)
|
|
17
|
+
: baseSession;
|
|
18
|
+
const stateDir = nativeAgentDeviceStateDir(env, input.context.projectRoot, device);
|
|
19
|
+
const client = createAgentDeviceClient({ session, stateDir, lockPolicy: 'reject', lockPlatform: 'android' });
|
|
20
|
+
const selection = { platform: 'android', target: 'mobile', serial: device, session };
|
|
21
|
+
const transport = createAgentDeviceUiTransport({ platform: 'android', device, app, session, stateDir, client });
|
|
22
|
+
const context = {
|
|
23
|
+
nodeId: String(input.context?.nodeId ?? 'action'),
|
|
24
|
+
projectRoot: input.context.projectRoot,
|
|
25
|
+
artifactsDir: input.context.artifactsDir,
|
|
26
|
+
env,
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
client,
|
|
30
|
+
transport,
|
|
31
|
+
selection,
|
|
32
|
+
context,
|
|
33
|
+
app,
|
|
34
|
+
open: () => client.apps.open({ ...selection, app, noRecord: true }),
|
|
35
|
+
execute: (action, node) => transport.execute(action, node, context),
|
|
36
|
+
snapshot: () => client.capture.snapshot({ ...selection, app, interactiveOnly: false, forceFull: true }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function closeNativeSession(session) {
|
|
41
|
+
try {
|
|
42
|
+
await session.transport.close();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await session.client.sessions.close();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function nativeNode(snapshot, identifier) {
|
|
54
|
+
return snapshot.nodes.find((node) => String(node.identifier ?? '') === identifier);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nativeLabel(node) {
|
|
58
|
+
return String(node?.label ?? node?.value ?? '').replace(/\s+/gu, ' ').trim();
|
|
59
|
+
}
|
|
@@ -3,7 +3,10 @@ import { promisify } from 'node:util';
|
|
|
3
3
|
import { createAgentDeviceClient } from 'agent-device';
|
|
4
4
|
|
|
5
5
|
import { bridgeCommand } from './bridge.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
nativeAgentDeviceStateDir,
|
|
8
|
+
nativeSessionName,
|
|
9
|
+
} from './native-session-name.mjs';
|
|
7
10
|
import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
|
|
8
11
|
|
|
9
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -67,6 +70,9 @@ async function readNativeHierarchy(payload, context) {
|
|
|
67
70
|
...process.env,
|
|
68
71
|
...record(context?.env),
|
|
69
72
|
};
|
|
73
|
+
if (String(context?.projectRoot ?? '').trim()) {
|
|
74
|
+
env.METAMASK_RECIPE_PROJECT_ROOT = String(context.projectRoot);
|
|
75
|
+
}
|
|
70
76
|
const platform = resolvePlatform(node, env);
|
|
71
77
|
if (platform === 'android') return readAndroidHierarchy(node, env);
|
|
72
78
|
return readIosHierarchy(node, env);
|
|
@@ -143,8 +149,10 @@ async function readAndroidHierarchy(node, env) {
|
|
|
143
149
|
async function readAndroidAgentDeviceHierarchy(serial, env) {
|
|
144
150
|
const app = text(env.ANDROID_PACKAGE_ID) ?? 'io.metamask';
|
|
145
151
|
const session = nativeSessionName(env, serial, app);
|
|
152
|
+
const stateDir = nativeAgentDeviceStateDir(env, env.METAMASK_RECIPE_PROJECT_ROOT ?? process.cwd(), serial);
|
|
146
153
|
const client = createAgentDeviceClient({
|
|
147
154
|
session,
|
|
155
|
+
stateDir,
|
|
148
156
|
lockPolicy: 'reject',
|
|
149
157
|
lockPlatform: 'android',
|
|
150
158
|
});
|
|
@@ -1,45 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const client = createAgentDeviceClient({ session, lockPolicy: 'reject', lockPlatform: 'android' });
|
|
12
|
-
const selection = { platform: 'android', target: 'mobile', serial: device, session };
|
|
13
|
-
const transport = createAgentDeviceUiTransport({ platform: 'android', device, app, session, client });
|
|
14
|
-
const context = {
|
|
15
|
-
nodeId: String(input.context?.nodeId ?? 'action'),
|
|
16
|
-
projectRoot: input.context.projectRoot,
|
|
17
|
-
artifactsDir: input.context.artifactsDir,
|
|
18
|
-
env,
|
|
19
|
-
};
|
|
20
|
-
return {
|
|
21
|
-
client,
|
|
22
|
-
transport,
|
|
23
|
-
selection,
|
|
24
|
-
context,
|
|
25
|
-
app,
|
|
26
|
-
execute: (action, node) => transport.execute(action, node, context),
|
|
27
|
-
snapshot: () => client.capture.snapshot({ ...selection, app, interactiveOnly: false, forceFull: true }),
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function closeSwapNativeSession(session) {
|
|
32
|
-
try {
|
|
33
|
-
await session.transport.close();
|
|
34
|
-
} catch (error) {
|
|
35
|
-
if (!/No active session|SESSION_NOT_FOUND/iu.test(String(error?.message ?? error))) throw error;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function nativeNode(snapshot, identifier) {
|
|
40
|
-
return snapshot.nodes.find((node) => String(node.identifier ?? '') === identifier);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function nativeLabel(node) {
|
|
44
|
-
return String(node?.label ?? node?.value ?? '').replace(/\s+/gu, ' ').trim();
|
|
45
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
closeNativeSession,
|
|
3
|
+
createNativeSession,
|
|
4
|
+
nativeLabel,
|
|
5
|
+
nativeNode,
|
|
6
|
+
} from '../platform/native-session.mjs';
|
|
7
|
+
|
|
8
|
+
export { nativeLabel, nativeNode };
|
|
9
|
+
export const createSwapNativeSession = (input) => createNativeSession(input, 'Swap');
|
|
10
|
+
export const closeSwapNativeSession = closeNativeSession;
|
|
@@ -21,11 +21,17 @@ function androidChainId(chain) {
|
|
|
21
21
|
return match ? `0x${Number(match[1]).toString(16)}` : String(chain);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function chainFilter(chain) {
|
|
25
|
+
return {
|
|
26
|
+
'eip155:1': 'Ethereum',
|
|
27
|
+
'eip155:42161': 'Arbitrum',
|
|
28
|
+
}[chain] ?? 'All';
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
async function selectAsset(session, role, asset, timeoutMs, pickerAlreadyOpen = false) {
|
|
25
32
|
const picker = role === 'source' ? 'source-token-area' : 'dest-token-area';
|
|
26
33
|
if (!pickerAlreadyOpen) await session.execute('ui.press', { test_id: picker, timeout_ms: timeoutMs });
|
|
27
34
|
await waitFor(session, 'bridge-token-search-input', timeoutMs);
|
|
28
|
-
await session.execute('ui.press', { text: 'All', timeout_ms: timeoutMs });
|
|
29
35
|
await session.execute('ui.set_input', {
|
|
30
36
|
test_id: 'bridge-token-search-input',
|
|
31
37
|
value: asset.query,
|
|
@@ -33,6 +39,14 @@ async function selectAsset(session, role, asset, timeoutMs, pickerAlreadyOpen =
|
|
|
33
39
|
});
|
|
34
40
|
const row = `asset-${androidChainId(asset.chain)}-${asset.symbol}`;
|
|
35
41
|
await session.execute('ui.key_press', { key: 'BACK', timeout_ms: timeoutMs });
|
|
42
|
+
let snapshot = await session.snapshot();
|
|
43
|
+
const filter = chainFilter(asset.chain);
|
|
44
|
+
if (
|
|
45
|
+
!nativeNode(snapshot, row) &&
|
|
46
|
+
snapshot.nodes.some((node) => node.visibleToUser !== false && nativeLabel(node) === filter)
|
|
47
|
+
) {
|
|
48
|
+
await session.execute('ui.press', { text: filter, timeout_ms: timeoutMs });
|
|
49
|
+
}
|
|
36
50
|
await revealAssetRow(session, row, timeoutMs);
|
|
37
51
|
await session.execute('ui.press', { test_id: row, timeout_ms: timeoutMs });
|
|
38
52
|
await waitFor(session, picker, timeoutMs);
|
|
@@ -60,7 +60,7 @@ runAdapter(async (input) => {
|
|
|
60
60
|
if (!options.waitForQuote || observed.noQuote || (Number(destinationAmount) > 0 && cta)) break;
|
|
61
61
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
62
62
|
} while (Date.now() < deadline);
|
|
63
|
-
if (observed.sourceAmount !== options.amount) {
|
|
63
|
+
if (Number(observed.sourceAmount) !== Number(options.amount)) {
|
|
64
64
|
throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
|
|
65
65
|
}
|
|
66
66
|
if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0 && observed.cta)) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import { resolveAmountOptions } from '../../shared/swap-bridge/transaction.mjs';
|
|
3
|
+
import {
|
|
4
|
+
closeNativeSession,
|
|
5
|
+
createNativeSession,
|
|
6
|
+
nativeLabel,
|
|
7
|
+
nativeNode,
|
|
8
|
+
} from '../platform/native-session.mjs';
|
|
9
|
+
|
|
10
|
+
runAdapter(async (input) => {
|
|
11
|
+
const options = resolveAmountOptions({
|
|
12
|
+
amount: '1',
|
|
13
|
+
wait_for_quote: input.node?.wait_for_quote,
|
|
14
|
+
timeout_ms: input.node?.timeout_ms,
|
|
15
|
+
});
|
|
16
|
+
const session = createNativeSession(input, 'Swap', `max-${process.pid}`);
|
|
17
|
+
try {
|
|
18
|
+
await session.open();
|
|
19
|
+
await session.execute('ui.press', {
|
|
20
|
+
test_id: 'token-input-area-max-button',
|
|
21
|
+
timeout_ms: options.timeoutMs,
|
|
22
|
+
settle: false,
|
|
23
|
+
});
|
|
24
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
25
|
+
let observed;
|
|
26
|
+
do {
|
|
27
|
+
const snapshot = await session.snapshot();
|
|
28
|
+
const cta = nativeNode(snapshot, 'bridge-confirm-button')
|
|
29
|
+
?? nativeNode(snapshot, 'bridge-confirm-button-keypad');
|
|
30
|
+
observed = {
|
|
31
|
+
sourceAmount: nativeLabel(nativeNode(snapshot, 'source-token-area-input')),
|
|
32
|
+
destinationAmount: nativeLabel(nativeNode(snapshot, 'dest-token-area-input')),
|
|
33
|
+
cta: nativeLabel(cta) || null,
|
|
34
|
+
ctaEnabled: cta?.enabled !== false,
|
|
35
|
+
noQuote: Boolean(nativeNode(snapshot, 'bridge-no-quotes')),
|
|
36
|
+
};
|
|
37
|
+
if (Number(observed.sourceAmount) > 0 && (
|
|
38
|
+
!options.waitForQuote
|
|
39
|
+
|| observed.noQuote
|
|
40
|
+
|| (Number(observed.destinationAmount) > 0 && cta)
|
|
41
|
+
)) break;
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
43
|
+
} while (Date.now() < deadline);
|
|
44
|
+
if (!(Number(observed?.sourceAmount) > 0)) {
|
|
45
|
+
throw new Error('Swap Max did not populate a positive source amount.');
|
|
46
|
+
}
|
|
47
|
+
if (options.waitForQuote && !observed.noQuote
|
|
48
|
+
&& (!(Number(observed.destinationAmount) > 0) || !observed.cta)) {
|
|
49
|
+
throw new Error(`Swap Max quote did not resolve within ${options.timeoutMs}ms.`);
|
|
50
|
+
}
|
|
51
|
+
return { action: input.action, ...observed, proofPath: 'native-accessibility-max-quote' };
|
|
52
|
+
} finally {
|
|
53
|
+
await closeNativeSession(session);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
@@ -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
|
+
async function waitForNode(session, testId, timeoutMs) {
|
|
10
|
+
const deadline = Date.now() + timeoutMs;
|
|
11
|
+
let snapshot;
|
|
12
|
+
while (Date.now() <= deadline) {
|
|
13
|
+
snapshot = await session.snapshot();
|
|
14
|
+
const node = nativeNode(snapshot, testId);
|
|
15
|
+
if (node) return { node, snapshot };
|
|
16
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Swap control ${testId} did not become visible.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function replaceSlippage(session, percent, timeoutMs) {
|
|
22
|
+
const inputId = 'input-stepper-input';
|
|
23
|
+
await waitForNode(session, inputId, timeoutMs);
|
|
24
|
+
await session.execute('ui.press', { test_id: inputId, timeout_ms: timeoutMs, settle: false });
|
|
25
|
+
let current = nativeLabel(nativeNode(await session.snapshot(), inputId));
|
|
26
|
+
for (let index = 0; index < Math.min(20, current.length + 2); index += 1) {
|
|
27
|
+
await session.execute('ui.press', {
|
|
28
|
+
test_id: 'keypad-delete-button',
|
|
29
|
+
timeout_ms: timeoutMs,
|
|
30
|
+
settle: false,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
current = nativeLabel(nativeNode(await session.snapshot(), inputId));
|
|
34
|
+
const value = String(percent);
|
|
35
|
+
const keys = value.startsWith('0.') && current === '0' ? value.slice(1) : value;
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
if (!/[0-9.]/u.test(key)) {
|
|
38
|
+
throw new Error(`Swap slippage contains unsupported keypad character ${JSON.stringify(key)}.`);
|
|
39
|
+
}
|
|
40
|
+
await session.execute('ui.press', {
|
|
41
|
+
test_id: key === '.' ? 'keypad-key-dot' : `keypad-key-${key}`,
|
|
42
|
+
timeout_ms: timeoutMs,
|
|
43
|
+
settle: false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const observed = nativeLabel(nativeNode(await session.snapshot(), inputId));
|
|
47
|
+
if (Number(observed) !== percent) {
|
|
48
|
+
throw new Error(`Swap slippage input mismatch: expected ${percent}, observed ${JSON.stringify(observed)}.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
runAdapter(async (input) => {
|
|
53
|
+
const percent = Number(input.node?.percent);
|
|
54
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
55
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent > 50) {
|
|
56
|
+
throw new Error('metamask.swap_bridge.set_slippage requires percent greater than 0 and at most 50.');
|
|
57
|
+
}
|
|
58
|
+
const session = createNativeSession(input, 'Swap', `slippage-${process.pid}`);
|
|
59
|
+
try {
|
|
60
|
+
await session.open();
|
|
61
|
+
const initial = await session.snapshot();
|
|
62
|
+
const opener = nativeNode(initial, 'edit-slippage-button')
|
|
63
|
+
? 'edit-slippage-button'
|
|
64
|
+
: 'bridge-slippage-settings-button';
|
|
65
|
+
await session.execute('ui.press', { test_id: opener, timeout_ms: timeoutMs, settle: false });
|
|
66
|
+
await session.execute('ui.press', { text: 'Custom', timeout_ms: timeoutMs, settle: false });
|
|
67
|
+
await replaceSlippage(session, percent, timeoutMs);
|
|
68
|
+
await session.execute('ui.press', { text: 'Confirm', timeout_ms: timeoutMs, settle: false });
|
|
69
|
+
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
let observed = null;
|
|
72
|
+
let destinationAmount = null;
|
|
73
|
+
while (Date.now() <= deadline) {
|
|
74
|
+
const snapshot = await session.snapshot();
|
|
75
|
+
observed = nativeLabel(nativeNode(snapshot, 'edit-slippage-button')) || null;
|
|
76
|
+
destinationAmount = nativeLabel(nativeNode(snapshot, 'dest-token-area-input')) || null;
|
|
77
|
+
if (Number.parseFloat(observed) === percent && Number(destinationAmount) > 0) break;
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
79
|
+
}
|
|
80
|
+
if (Number.parseFloat(observed) !== percent) {
|
|
81
|
+
throw new Error(`Swap quote did not display Slippage: ${percent}%; observed ${JSON.stringify(observed)}.`);
|
|
82
|
+
}
|
|
83
|
+
if (!(Number(destinationAmount) > 0)) {
|
|
84
|
+
throw new Error('Swap quote did not finish refreshing after custom slippage was applied.');
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
action: input.action,
|
|
88
|
+
percent,
|
|
89
|
+
observed,
|
|
90
|
+
destinationAmount,
|
|
91
|
+
proofPath: 'native-accessibility-custom-slippage',
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
await closeNativeSession(session);
|
|
95
|
+
}
|
|
96
|
+
});
|