@deeeed/metamask-harness 0.38.0 → 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 +18 -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/open_balance_action.mjs +1 -6
- package/library/actions/extension/perps/search_markets.mjs +32 -0
- package/library/actions/extension/perps/select_market_filter.mjs +3 -2
- 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/extension/swap_bridge/submit_transaction.mjs +11 -11
- 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/scripts/validate-release-report.mjs +28 -14
- /package/library/recipes/{assets → extension/assets}/release-custom-network-token-persistence.recipe.json +0 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { nativeLabel, nativeNode } from '../platform/native-session.mjs';
|
|
2
|
+
|
|
3
|
+
const IDS = {
|
|
4
|
+
add: 'networks-settings-add-custom-network-button',
|
|
5
|
+
details: 'network-details-view-container',
|
|
6
|
+
hamburger: 'navbar-hamburger-menu-button',
|
|
7
|
+
menu: 'networks-menu-item',
|
|
8
|
+
manager: 'networks-settings-container',
|
|
9
|
+
search: 'networks-settings-search-input',
|
|
10
|
+
wallet: ['wallet-safe-area', 'homepage-container'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function networkItemId(chainId) {
|
|
14
|
+
return `networks-settings-network-item-0x${chainId.toString(16)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function visibleNode(snapshot, identifier) {
|
|
18
|
+
return snapshot.nodes.find((node) =>
|
|
19
|
+
node.identifier === identifier &&
|
|
20
|
+
node.visibleToUser !== false &&
|
|
21
|
+
node.hittable !== false
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function waitForSnapshot(session, timeoutMs, predicate, message) {
|
|
26
|
+
const deadline = Date.now() + timeoutMs;
|
|
27
|
+
while (Date.now() <= deadline) {
|
|
28
|
+
const snapshot = await session.snapshot();
|
|
29
|
+
const value = predicate(snapshot);
|
|
30
|
+
if (value) return { snapshot, value };
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
32
|
+
}
|
|
33
|
+
throw new Error(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function systemBack(session) {
|
|
37
|
+
await session.client.command.back({
|
|
38
|
+
...session.selection,
|
|
39
|
+
mode: 'system',
|
|
40
|
+
responseLevel: 'digest',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function openNetworkManager(session, timeoutMs) {
|
|
45
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
46
|
+
const snapshot = await session.snapshot();
|
|
47
|
+
if (visibleNode(snapshot, IDS.manager)) return snapshot;
|
|
48
|
+
if (visibleNode(snapshot, IDS.details)) {
|
|
49
|
+
await systemBack(session);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (visibleNode(snapshot, IDS.menu)) {
|
|
53
|
+
await session.execute('ui.press', { test_id: IDS.menu, timeout_ms: timeoutMs, settle: false });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (IDS.wallet.some((identifier) => nativeNode(snapshot, identifier))) {
|
|
57
|
+
await session.execute('ui.press', { test_id: IDS.hamburger, timeout_ms: timeoutMs, settle: false });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
await systemBack(session);
|
|
61
|
+
}
|
|
62
|
+
return (await waitForSnapshot(
|
|
63
|
+
session,
|
|
64
|
+
timeoutMs,
|
|
65
|
+
(snapshot) => visibleNode(snapshot, IDS.manager),
|
|
66
|
+
'The Mobile Networks settings screen did not open.',
|
|
67
|
+
)).snapshot;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function findNetwork(session, network, chainId, timeoutMs) {
|
|
71
|
+
const itemId = networkItemId(chainId);
|
|
72
|
+
let snapshot = await session.snapshot();
|
|
73
|
+
let item = visibleNode(snapshot, itemId);
|
|
74
|
+
if (!item) {
|
|
75
|
+
await session.execute('ui.set_input', {
|
|
76
|
+
test_id: IDS.search,
|
|
77
|
+
value: network,
|
|
78
|
+
timeout_ms: timeoutMs,
|
|
79
|
+
settle: false,
|
|
80
|
+
});
|
|
81
|
+
snapshot = (await waitForSnapshot(
|
|
82
|
+
session,
|
|
83
|
+
timeoutMs,
|
|
84
|
+
(current) => visibleNode(current, itemId) ?? current.nodes.find((node) =>
|
|
85
|
+
nativeLabel(node).toLowerCase() === 'no networks found'
|
|
86
|
+
),
|
|
87
|
+
`Mobile Networks search did not settle for ${network}.`,
|
|
88
|
+
)).snapshot;
|
|
89
|
+
item = visibleNode(snapshot, itemId);
|
|
90
|
+
}
|
|
91
|
+
if (!item) return null;
|
|
92
|
+
const label = nativeLabel(item);
|
|
93
|
+
if (label && !label.toLowerCase().includes(network.toLowerCase())) {
|
|
94
|
+
throw new Error(`${itemId} did not identify ${network}; observed ${JSON.stringify(label)}.`);
|
|
95
|
+
}
|
|
96
|
+
return { id: itemId, label: label || network };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function clearNetworkSearch(session, timeoutMs) {
|
|
100
|
+
const snapshot = await session.snapshot();
|
|
101
|
+
const search = nativeNode(snapshot, IDS.search);
|
|
102
|
+
const searchValue = nativeLabel(search);
|
|
103
|
+
if (!search || !searchValue || searchValue.toLowerCase() === 'search networks') return;
|
|
104
|
+
const clear = snapshot.nodes.find((node) =>
|
|
105
|
+
node.parentIndex === search.parentIndex &&
|
|
106
|
+
node.index > search.index &&
|
|
107
|
+
node.hittable === true &&
|
|
108
|
+
node.ref
|
|
109
|
+
);
|
|
110
|
+
if (!clear?.ref) {
|
|
111
|
+
throw new Error('Mobile Networks search did not expose a semantic clear control.');
|
|
112
|
+
}
|
|
113
|
+
await session.client.interactions.press({
|
|
114
|
+
...session.selection,
|
|
115
|
+
ref: `@${clear.ref}`,
|
|
116
|
+
settle: true,
|
|
117
|
+
verify: true,
|
|
118
|
+
timeoutMs,
|
|
119
|
+
responseLevel: 'digest',
|
|
120
|
+
});
|
|
121
|
+
await waitForSnapshot(
|
|
122
|
+
session,
|
|
123
|
+
timeoutMs,
|
|
124
|
+
(current) => {
|
|
125
|
+
const value = nativeLabel(nativeNode(current, IDS.search));
|
|
126
|
+
return !value || value.toLowerCase() === 'search networks' ? true : null;
|
|
127
|
+
},
|
|
128
|
+
'Mobile Networks search did not clear.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { IDS };
|
|
@@ -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)) {
|