@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
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
if (
|
|
6
|
-
throw new Error(
|
|
3
|
+
function expectedList(value, name) {
|
|
4
|
+
if (value == null) return undefined;
|
|
5
|
+
if (!Array.isArray(value)) {
|
|
6
|
+
throw new Error(`metamask.perps.read_funds_confirmation ${name} must be an array.`);
|
|
7
7
|
}
|
|
8
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function matchesExpectedObservation(observed, node, expectedControls, requiredText) {
|
|
12
|
+
return (
|
|
13
|
+
(typeof node.expected_submit_enabled !== 'boolean' || observed.submitEnabled === node.expected_submit_enabled) &&
|
|
14
|
+
(node.expected_submit_label == null || observed.submitLabel === String(node.expected_submit_label)) &&
|
|
15
|
+
(node.expected_amount == null || observed.amount === String(node.expected_amount)) &&
|
|
16
|
+
(node.expected_amount_symbol == null || observed.amountSymbol === String(node.expected_amount_symbol)) &&
|
|
17
|
+
(typeof node.expected_pay_token_amount_visible !== 'boolean' || observed.payTokenAmountVisible === node.expected_pay_token_amount_visible) &&
|
|
18
|
+
(node.expected_pay_token == null || observed.payToken === String(node.expected_pay_token)) &&
|
|
19
|
+
(node.expected_receive_token == null || observed.receiveToken === String(node.expected_receive_token)) &&
|
|
20
|
+
(node.expected_visible_network == null || observed.visibleNetwork === String(node.expected_visible_network)) &&
|
|
21
|
+
(!expectedControls || (
|
|
22
|
+
expectedControls.length === observed.percentageControls.length &&
|
|
23
|
+
expectedControls.every((control) => observed.percentageControls.includes(control))
|
|
24
|
+
)) &&
|
|
25
|
+
requiredText.every((text) => observed.bodyText.includes(text))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readFundsConfirmation(page, node, mode, timeoutMs = 30_000) {
|
|
8
30
|
const expectedTitle = mode === 'deposit' ? 'Deposit funds to Perps' : 'Withdraw';
|
|
9
|
-
const
|
|
31
|
+
const expectedControls = expectedList(node.expected_percentage_controls, 'expected_percentage_controls');
|
|
32
|
+
const requiredText = expectedList(node.require_visible_text, 'require_visible_text') ?? [];
|
|
33
|
+
const deadline = Date.now() + timeoutMs;
|
|
10
34
|
let observed;
|
|
11
35
|
while (Date.now() <= deadline) {
|
|
12
36
|
observed = await page.evaluate(`(() => {
|
|
@@ -14,17 +38,50 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
14
38
|
.replace(/\\s+/gu, ' ')
|
|
15
39
|
.trim()
|
|
16
40
|
.slice(0, 500);
|
|
41
|
+
const readSymbol = () => {
|
|
42
|
+
const element = document.querySelector('[data-testid="pay-with-symbol"]');
|
|
43
|
+
const textNode = [...(element?.childNodes || [])]
|
|
44
|
+
.find((child) => child.nodeType === 3 && String(child.textContent || '').trim());
|
|
45
|
+
return String(textNode?.textContent || element?.innerText || '').replace(/\\s+/gu, ' ').trim();
|
|
46
|
+
};
|
|
17
47
|
const amount = document.querySelector('[data-testid="custom-amount-input"]');
|
|
18
48
|
const submit = document.querySelector('[data-testid="confirm-footer-button"]');
|
|
49
|
+
const headerBack = document.querySelector('[data-testid="wallet-initiated-header-back-button"]');
|
|
50
|
+
const payWithPill = document.querySelector('[data-testid="pay-with-pill"]');
|
|
51
|
+
const visible = (element) => {
|
|
52
|
+
if (!element) return false;
|
|
53
|
+
const style = getComputedStyle(element);
|
|
54
|
+
const rect = element.getBoundingClientRect();
|
|
55
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
56
|
+
};
|
|
57
|
+
const bodyText = String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 10_000);
|
|
58
|
+
const iconNames = [...(payWithPill?.querySelectorAll('img[alt$=" logo"]') || [])]
|
|
59
|
+
.filter(visible)
|
|
60
|
+
.map((element) => String(element.getAttribute('alt') || '').replace(/ logo$/u, '').trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const fundsRow = read('pay-with-row');
|
|
63
|
+
const fundsToken = readSymbol();
|
|
19
64
|
return {
|
|
20
65
|
route: String(location.hash),
|
|
21
|
-
title: String(
|
|
66
|
+
title: String(headerBack?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() ||
|
|
67
|
+
String(document.body?.innerText || '').trim().split(/\\n+/u)[0] || '',
|
|
68
|
+
bodyText,
|
|
22
69
|
amount: amount && 'value' in amount ? String(amount.value) : null,
|
|
70
|
+
amountSymbol: read('custom-amount-symbol'),
|
|
23
71
|
availableBalance: read('perps-withdraw-balance-value'),
|
|
24
72
|
transactionFee: read('transaction-fee-value'),
|
|
25
73
|
estimatedTime: read('bridge-time-value'),
|
|
26
74
|
receive: read('receive-value'),
|
|
27
|
-
payToken:
|
|
75
|
+
payToken: /^Pay with(?:\\s|$)/u.test(fundsRow) ? fundsToken : '',
|
|
76
|
+
receiveToken: /^Receive(?:\\s|$)/u.test(fundsRow) ? fundsToken : '',
|
|
77
|
+
visibleNetwork: read('pay-with-network'),
|
|
78
|
+
selectedIconNames: iconNames,
|
|
79
|
+
payTokenAmountVisible: visible(document.querySelector('[data-testid="pay-token-amount"]')),
|
|
80
|
+
percentageControls: [...document.querySelectorAll('[data-testid^="percentage-button-"]')]
|
|
81
|
+
.filter(visible)
|
|
82
|
+
.map((element) => String(element.getAttribute('data-testid') || ''))
|
|
83
|
+
.slice(0, 10),
|
|
84
|
+
loadingVisible: [...document.querySelectorAll('[data-testid$="-skeleton"]')].some(visible),
|
|
28
85
|
submitLabel: String(submit?.innerText || '').trim(),
|
|
29
86
|
submitEnabled: Boolean(submit && !submit.disabled && submit.getAttribute('aria-disabled') !== 'true'),
|
|
30
87
|
layout: {
|
|
@@ -45,25 +102,81 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
45
102
|
},
|
|
46
103
|
};
|
|
47
104
|
})()`);
|
|
48
|
-
|
|
105
|
+
const previewReady = node.require_preview_fields !== true || (
|
|
106
|
+
observed.transactionFee && observed.estimatedTime && observed.receive &&
|
|
107
|
+
(observed.payToken || observed.receiveToken) && !observed.loadingVisible
|
|
108
|
+
);
|
|
109
|
+
if (
|
|
110
|
+
observed.title === expectedTitle &&
|
|
111
|
+
observed.amount !== null &&
|
|
112
|
+
previewReady &&
|
|
113
|
+
matchesExpectedObservation(observed, node, expectedControls, requiredText)
|
|
114
|
+
) break;
|
|
49
115
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
50
116
|
}
|
|
51
117
|
if (observed?.title !== expectedTitle || observed?.amount === null) {
|
|
52
118
|
throw new Error(`Perps ${mode} confirmation did not finish loading.`);
|
|
53
119
|
}
|
|
54
|
-
if (
|
|
120
|
+
if (node.require_no_horizontal_overflow === true && (
|
|
55
121
|
observed.layout.horizontalOverflow || observed.layout.clippedTestIds.length > 0
|
|
56
122
|
)) {
|
|
57
123
|
throw new Error(`Visible Perps funds layout overflows horizontally: ${JSON.stringify(observed.layout)}.`);
|
|
58
124
|
}
|
|
59
|
-
if (typeof
|
|
60
|
-
&& observed.submitEnabled !==
|
|
61
|
-
throw new Error(`Perps ${mode} submit state mismatch: expected ${
|
|
125
|
+
if (typeof node.expected_submit_enabled === 'boolean'
|
|
126
|
+
&& observed.submitEnabled !== node.expected_submit_enabled) {
|
|
127
|
+
throw new Error(`Perps ${mode} submit state mismatch: expected ${node.expected_submit_enabled}, observed ${observed.submitEnabled}.`);
|
|
128
|
+
}
|
|
129
|
+
if (node.expected_amount != null && observed.amount !== String(node.expected_amount)) {
|
|
130
|
+
throw new Error(`Perps ${mode} amount mismatch: expected ${JSON.stringify(String(node.expected_amount))}, observed ${JSON.stringify(observed.amount)}.`);
|
|
131
|
+
}
|
|
132
|
+
if (node.expected_amount_symbol != null
|
|
133
|
+
&& observed.amountSymbol !== String(node.expected_amount_symbol)) {
|
|
134
|
+
throw new Error(`Perps ${mode} amount symbol mismatch: expected ${JSON.stringify(String(node.expected_amount_symbol))}, observed ${JSON.stringify(observed.amountSymbol)}.`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof node.expected_pay_token_amount_visible === 'boolean'
|
|
137
|
+
&& observed.payTokenAmountVisible !== node.expected_pay_token_amount_visible) {
|
|
138
|
+
throw new Error(`Perps ${mode} token amount visibility mismatch: expected ${node.expected_pay_token_amount_visible}, observed ${observed.payTokenAmountVisible}.`);
|
|
139
|
+
}
|
|
140
|
+
if (node.expected_pay_token != null
|
|
141
|
+
&& observed.payToken !== String(node.expected_pay_token)) {
|
|
142
|
+
throw new Error(`Perps ${mode} pay token mismatch: expected ${JSON.stringify(String(node.expected_pay_token))}, observed ${JSON.stringify(observed.payToken)}.`);
|
|
143
|
+
}
|
|
144
|
+
if (node.expected_receive_token != null
|
|
145
|
+
&& observed.receiveToken !== String(node.expected_receive_token)) {
|
|
146
|
+
throw new Error(`Perps ${mode} receive token mismatch: expected ${JSON.stringify(String(node.expected_receive_token))}, observed ${JSON.stringify(observed.receiveToken)}.`);
|
|
147
|
+
}
|
|
148
|
+
if (node.expected_visible_network != null
|
|
149
|
+
&& observed.visibleNetwork !== String(node.expected_visible_network)) {
|
|
150
|
+
throw new Error(`Perps ${mode} visible network mismatch: expected ${JSON.stringify(String(node.expected_visible_network))}, observed ${JSON.stringify(observed.visibleNetwork)}.`);
|
|
151
|
+
}
|
|
152
|
+
if (node.expected_submit_label != null
|
|
153
|
+
&& observed.submitLabel !== String(node.expected_submit_label)) {
|
|
154
|
+
throw new Error(`Perps ${mode} submit label mismatch: expected ${JSON.stringify(String(node.expected_submit_label))}, observed ${JSON.stringify(observed.submitLabel)}.`);
|
|
155
|
+
}
|
|
156
|
+
if (expectedControls && (
|
|
157
|
+
expectedControls.length !== observed.percentageControls.length ||
|
|
158
|
+
expectedControls.some((control) => !observed.percentageControls.includes(control))
|
|
159
|
+
)) {
|
|
160
|
+
throw new Error(`Perps ${mode} percentage controls mismatch: expected ${JSON.stringify(expectedControls)}, observed ${JSON.stringify(observed.percentageControls)}.`);
|
|
161
|
+
}
|
|
162
|
+
const missingText = requiredText.filter((text) => !observed.bodyText.includes(text));
|
|
163
|
+
if (missingText.length > 0) {
|
|
164
|
+
throw new Error(`Perps ${mode} missing visible text: ${missingText.join(', ')}.`);
|
|
62
165
|
}
|
|
63
166
|
return {
|
|
64
|
-
action: input.action,
|
|
65
167
|
mode,
|
|
66
168
|
...observed,
|
|
67
169
|
proofPath: 'visible-dom-accessibility',
|
|
68
170
|
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
174
|
+
const mode = String(input.node?.mode ?? '').trim().toLowerCase();
|
|
175
|
+
if (mode !== 'deposit' && mode !== 'withdraw') {
|
|
176
|
+
throw new Error('metamask.perps.read_funds_confirmation mode must be deposit or withdraw.');
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
action: input.action,
|
|
180
|
+
...await readFundsConfirmation(page, input.node, mode),
|
|
181
|
+
};
|
|
69
182
|
}));
|
|
@@ -67,6 +67,35 @@ export function resolveSnapshotOptions(input, now = Date.now()) {
|
|
|
67
67
|
throw new Error('metamask.perps.read_snapshot history range must not exceed 365 days.');
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const sampling = node.sampling && typeof node.sampling === 'object' && !Array.isArray(node.sampling)
|
|
71
|
+
? node.sampling
|
|
72
|
+
: {};
|
|
73
|
+
const expect = node.expect && typeof node.expect === 'object' && !Array.isArray(node.expect)
|
|
74
|
+
? node.expect
|
|
75
|
+
: {};
|
|
76
|
+
const sampleCount = boundedInteger(sampling.count, 1, 1, 10, 'sampling.count');
|
|
77
|
+
const minimumChangedMarketCount = boundedInteger(
|
|
78
|
+
expect.minimum_changed_market_count,
|
|
79
|
+
0,
|
|
80
|
+
0,
|
|
81
|
+
100,
|
|
82
|
+
'expect.minimum_changed_market_count',
|
|
83
|
+
);
|
|
84
|
+
const minimumHistoryCount = boundedInteger(
|
|
85
|
+
expect.minimum_history_count,
|
|
86
|
+
0,
|
|
87
|
+
0,
|
|
88
|
+
500,
|
|
89
|
+
'expect.minimum_history_count',
|
|
90
|
+
);
|
|
91
|
+
const requireActivityHistoryAgreement = expect.require_activity_history_agreement === true;
|
|
92
|
+
if (minimumChangedMarketCount > 0 && (!sections.includes('markets') || sampleCount < 2)) {
|
|
93
|
+
throw new Error('metamask.perps.read_snapshot market change expectations require markets and at least two samples.');
|
|
94
|
+
}
|
|
95
|
+
if ((minimumHistoryCount > 0 || requireActivityHistoryAgreement) && !sections.includes('history')) {
|
|
96
|
+
throw new Error('metamask.perps.read_snapshot history expectations require the history section.');
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
return {
|
|
71
100
|
include: sections,
|
|
72
101
|
markets: requestedMarkets(node),
|
|
@@ -80,6 +109,15 @@ export function resolveSnapshotOptions(input, now = Date.now()) {
|
|
|
80
109
|
: String(history.account_id),
|
|
81
110
|
limit: boundedInteger(history.limit, 100, 1, 500, 'history.limit'),
|
|
82
111
|
},
|
|
112
|
+
sampling: {
|
|
113
|
+
count: sampleCount,
|
|
114
|
+
intervalMs: boundedInteger(sampling.interval_ms, 1_000, 100, 30_000, 'sampling.interval_ms'),
|
|
115
|
+
},
|
|
116
|
+
expect: {
|
|
117
|
+
minimumChangedMarketCount,
|
|
118
|
+
minimumHistoryCount,
|
|
119
|
+
requireActivityHistoryAgreement,
|
|
120
|
+
},
|
|
83
121
|
};
|
|
84
122
|
}
|
|
85
123
|
|
|
@@ -174,6 +212,7 @@ export function snapshotRequestExpression(options) {
|
|
|
174
212
|
stopLossPrice: field(item, ['stopLossPrice']),
|
|
175
213
|
};
|
|
176
214
|
return {
|
|
215
|
+
activityId: field(item, ['id', 'depositId', 'withdrawalId']),
|
|
177
216
|
type: field(item, ['type', 'activityType']),
|
|
178
217
|
status: field(item, ['status']),
|
|
179
218
|
asset: field(item, ['asset', 'token', 'currency']),
|
|
@@ -339,6 +378,7 @@ function normalizeOrder(item) {
|
|
|
339
378
|
|
|
340
379
|
function normalizeHistoryItem(item) {
|
|
341
380
|
return {
|
|
381
|
+
activityId: field(item, ['activityId', 'id', 'depositId', 'withdrawalId']),
|
|
342
382
|
type: field(item, ['type', 'activityType']),
|
|
343
383
|
status: field(item, ['status']),
|
|
344
384
|
asset: field(item, ['asset', 'token', 'currency']),
|
|
@@ -438,10 +478,84 @@ export function normalizeSnapshotResult(raw, options) {
|
|
|
438
478
|
return sections;
|
|
439
479
|
}
|
|
440
480
|
|
|
441
|
-
export
|
|
481
|
+
export function changedMarketFacts(first, last) {
|
|
482
|
+
const initial = new Map((first?.markets?.items ?? []).map((item) => [item.market, item]));
|
|
483
|
+
const fields = ['price', 'markPrice', 'oraclePrice', 'change24hPercent', 'fundingRate'];
|
|
484
|
+
return (last?.markets?.items ?? []).flatMap((item) => {
|
|
485
|
+
const before = initial.get(item.market);
|
|
486
|
+
const changedFields = before
|
|
487
|
+
? fields.filter((fieldName) => before[fieldName] !== item[fieldName])
|
|
488
|
+
: [];
|
|
489
|
+
return changedFields.length > 0 ? [{ market: item.market, changedFields }] : [];
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function visibleActivityCards(page) {
|
|
494
|
+
return page.evaluate(`[
|
|
495
|
+
...document.querySelectorAll('[data-testid^="transaction-card-"]')
|
|
496
|
+
].slice(0, 200).map((element) => {
|
|
497
|
+
const style = getComputedStyle(element);
|
|
498
|
+
const rect = element.getBoundingClientRect();
|
|
499
|
+
return {
|
|
500
|
+
testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
|
|
501
|
+
label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
|
|
502
|
+
visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0,
|
|
503
|
+
};
|
|
504
|
+
})`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function activityHistoryAgreement(cards, historyItems) {
|
|
508
|
+
for (const item of historyItems) {
|
|
509
|
+
const activityId = String(item.activityId ?? '').trim();
|
|
510
|
+
if (!activityId) continue;
|
|
511
|
+
const type = String(item.type ?? '').trim().toLowerCase();
|
|
512
|
+
const prefix = type === 'withdraw' ? 'withdrawal' : type;
|
|
513
|
+
const expectedTestId = `transaction-card-${prefix}-${activityId}`;
|
|
514
|
+
const card = cards.find((entry) => entry.visible === true && entry.testId === expectedTestId);
|
|
515
|
+
if (card) {
|
|
516
|
+
return {
|
|
517
|
+
activityTestId: card.testId,
|
|
518
|
+
historyActivityId: activityId,
|
|
519
|
+
historyType: item.type,
|
|
520
|
+
historyStatus: item.status,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function readSnapshot(input, testOptions = {}) {
|
|
442
528
|
const options = resolveSnapshotOptions(input);
|
|
443
529
|
return withExtensionPage(input, async (page) => {
|
|
444
|
-
const
|
|
530
|
+
const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
531
|
+
const samples = [];
|
|
532
|
+
for (let index = 0; index < options.sampling.count; index += 1) {
|
|
533
|
+
const raw = await page.evaluate(snapshotRequestExpression(options));
|
|
534
|
+
samples.push(normalizeSnapshotResult(raw, options));
|
|
535
|
+
if (index + 1 < options.sampling.count) await pause(options.sampling.intervalMs);
|
|
536
|
+
}
|
|
537
|
+
const sections = samples.at(-1);
|
|
538
|
+
const changedMarkets = changedMarketFacts(samples[0], sections);
|
|
539
|
+
if (changedMarkets.length < options.expect.minimumChangedMarketCount) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`Expected at least ${options.expect.minimumChangedMarketCount} changing Perps markets, but observed ${changedMarkets.length}.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const historyCount = sections.history?.returnedCount ?? 0;
|
|
545
|
+
if (historyCount < options.expect.minimumHistoryCount) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`Expected at least ${options.expect.minimumHistoryCount} Perps history items, but observed ${historyCount}.`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const visibleCards = options.expect.requireActivityHistoryAgreement
|
|
551
|
+
? await visibleActivityCards(page)
|
|
552
|
+
: [];
|
|
553
|
+
const agreement = options.expect.requireActivityHistoryAgreement
|
|
554
|
+
? activityHistoryAgreement(visibleCards, sections.history?.items ?? [])
|
|
555
|
+
: null;
|
|
556
|
+
if (options.expect.requireActivityHistoryAgreement && !agreement) {
|
|
557
|
+
throw new Error('Visible Perps activity did not agree with any normalized history identity.');
|
|
558
|
+
}
|
|
445
559
|
return {
|
|
446
560
|
action: input.action,
|
|
447
561
|
source: 'extension-stateHooks-background',
|
|
@@ -454,7 +568,14 @@ export async function readSnapshot(input) {
|
|
|
454
568
|
accountScoped: options.history.accountId !== undefined,
|
|
455
569
|
}
|
|
456
570
|
: null,
|
|
457
|
-
sections
|
|
571
|
+
sections,
|
|
572
|
+
sampling: {
|
|
573
|
+
count: options.sampling.count,
|
|
574
|
+
intervalMs: options.sampling.intervalMs,
|
|
575
|
+
changedMarketCount: changedMarkets.length,
|
|
576
|
+
changedMarkets,
|
|
577
|
+
},
|
|
578
|
+
activityHistoryAgreement: agreement,
|
|
458
579
|
redacted: true,
|
|
459
580
|
proofPath: 'background-perps-snapshot',
|
|
460
581
|
};
|
|
@@ -4,15 +4,59 @@ import {
|
|
|
4
4
|
resolveVisibleStateOptions,
|
|
5
5
|
} from '../../shared/perps/visible-state.mjs';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
function pointInside(point, left, top, right, bottom) {
|
|
8
|
+
return point && point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function classifyActionableControlLayout(controls) {
|
|
12
|
+
const bounded = Array.isArray(controls) ? controls.slice(0, 200) : [];
|
|
13
|
+
return {
|
|
14
|
+
obscuredControlTestIds: bounded
|
|
15
|
+
.filter((control) => !control.hitPoint)
|
|
16
|
+
.map((control) => control.testId)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.slice(0, 100),
|
|
19
|
+
overlappingControlPairs: bounded.flatMap((left, index) =>
|
|
20
|
+
bounded.slice(index + 1).flatMap((right) => {
|
|
21
|
+
if (!left.hitPoint || !right.hitPoint) return [];
|
|
22
|
+
const overlapLeft = Math.max(left.rect.left, right.rect.left);
|
|
23
|
+
const overlapTop = Math.max(left.rect.top, right.rect.top);
|
|
24
|
+
const overlapRight = Math.min(left.rect.right, right.rect.right);
|
|
25
|
+
const overlapBottom = Math.min(left.rect.bottom, right.rect.bottom);
|
|
26
|
+
if (overlapRight - overlapLeft <= 1 || overlapBottom - overlapTop <= 1) return [];
|
|
27
|
+
return pointInside(left.hitPoint, overlapLeft, overlapTop, overlapRight, overlapBottom) ||
|
|
28
|
+
pointInside(right.hitPoint, overlapLeft, overlapTop, overlapRight, overlapBottom)
|
|
29
|
+
? [[left.testId, right.testId]]
|
|
30
|
+
: [];
|
|
31
|
+
}))
|
|
32
|
+
.slice(0, 100),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readVisiblePerpsState(page, node = {}) {
|
|
37
|
+
const options = resolveVisibleStateOptions(node);
|
|
9
38
|
const raw = await page.evaluate(`(() => {
|
|
10
39
|
const includeOffscreen = ${JSON.stringify(options.includeOffscreen)};
|
|
11
40
|
const maximum = ${JSON.stringify(options.maxItems)};
|
|
12
|
-
const relevant = /^(?:perps-|explore-markets-|market-list-view|market-row-|filter-select-button|sort-dropdown-button|search-input|position-card-|order-card-|transaction-card-|bottom-nav-
|
|
41
|
+
const relevant = /^(?:perps-|explore-markets-|market-list-view|market-row-|filter-select-button|sort-dropdown-button|search-input|position-card-|order-card-|transaction-card-|bottom-nav-|back-button|direction-|order-type-|amount-|balance-percent-|leverage-|auto-close|submit-order-button|limit-price-|tp-|sl-|custom-amount-|pay-with-|confirm-footer-button|wallet-initiated-header-back-button|transaction-fee-|bridge-(?:fee|time)-|receive-(?:row|value))/u;
|
|
13
42
|
const items = [];
|
|
14
43
|
const offscreenItems = [];
|
|
15
44
|
const clippedTestIds = [];
|
|
45
|
+
const visibleControls = [];
|
|
46
|
+
const safeHitPoint = (element, rect) => {
|
|
47
|
+
const left = Math.max(0, rect.left);
|
|
48
|
+
const top = Math.max(0, rect.top);
|
|
49
|
+
const right = Math.min(innerWidth, rect.right);
|
|
50
|
+
const bottom = Math.min(innerHeight, rect.bottom);
|
|
51
|
+
if (right - left <= 1 || bottom - top <= 1) return null;
|
|
52
|
+
for (const [xRatio, yRatio] of [[0.5, 0.5], [0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]) {
|
|
53
|
+
const x = left + (right - left) * xRatio;
|
|
54
|
+
const y = top + (bottom - top) * yRatio;
|
|
55
|
+
const hit = document.elementFromPoint(x, y);
|
|
56
|
+
if (hit && (hit === element || element.contains(hit))) return { x, y };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
16
60
|
let matched = 0;
|
|
17
61
|
for (const element of document.querySelectorAll('[data-testid]')) {
|
|
18
62
|
const testId = element.getAttribute('data-testid');
|
|
@@ -26,6 +70,13 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
26
70
|
if (rect.bottom > 0 && rect.top < innerHeight && (rect.left < -1 || rect.right > innerWidth + 1)) {
|
|
27
71
|
clippedTestIds.push(testId);
|
|
28
72
|
}
|
|
73
|
+
if (visible && element.matches('button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="slider"]')) {
|
|
74
|
+
visibleControls.push({
|
|
75
|
+
testId,
|
|
76
|
+
hitPoint: safeHitPoint(element, rect),
|
|
77
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
29
80
|
if (!visible && !includeOffscreen) continue;
|
|
30
81
|
if (items.length + offscreenItems.length >= maximum) break;
|
|
31
82
|
const label = String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '')
|
|
@@ -87,12 +138,19 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
87
138
|
contentWidth: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
|
|
88
139
|
horizontalOverflow: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0) > innerWidth + 1,
|
|
89
140
|
clippedTestIds: [...new Set(clippedTestIds)].slice(0, 100),
|
|
141
|
+
actionableControls: visibleControls,
|
|
90
142
|
},
|
|
91
143
|
};
|
|
92
144
|
})()`);
|
|
145
|
+
Object.assign(raw.layout, classifyActionableControlLayout(raw.layout.actionableControls));
|
|
146
|
+
delete raw.layout.actionableControls;
|
|
93
147
|
return {
|
|
94
|
-
action: input.action,
|
|
95
148
|
...normalizeVisiblePerpsState(raw, options, 'extension'),
|
|
96
149
|
proofPath: 'visible-dom-accessibility',
|
|
97
150
|
};
|
|
98
|
-
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => ({
|
|
154
|
+
action: input.action,
|
|
155
|
+
...await readVisiblePerpsState(page, input.node),
|
|
156
|
+
})));
|
|
@@ -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
|
+
}));
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
|
|
1
3
|
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
4
|
|
|
3
5
|
const FILTERS = new Map([
|
|
@@ -7,7 +9,17 @@ const FILTERS = new Map([
|
|
|
7
9
|
['deposits', 'deposit'],
|
|
8
10
|
]);
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
function activityRoute(hash) {
|
|
13
|
+
const separator = hash.indexOf('?');
|
|
14
|
+
const pathname = separator === -1 ? hash : hash.slice(0, separator);
|
|
15
|
+
const query = separator === -1 ? '' : hash.slice(separator + 1);
|
|
16
|
+
return {
|
|
17
|
+
pathname,
|
|
18
|
+
queryFilter: new URLSearchParams(query).get('filter'),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function selectActivityFilter(page, input, options = {}) {
|
|
11
23
|
const filter = String(input.node?.filter ?? '').trim().toLowerCase();
|
|
12
24
|
const option = FILTERS.get(filter);
|
|
13
25
|
if (!option) {
|
|
@@ -15,30 +27,81 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
15
27
|
}
|
|
16
28
|
await page.click(dataTestId('perps-activity-filter-button'));
|
|
17
29
|
await page.click(dataTestId(`perps-activity-filter-option-${option}`));
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
31
|
+
const pollIntervalMs = options.pollIntervalMs ?? 100;
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
let observed;
|
|
34
|
+
let route;
|
|
35
|
+
while (true) {
|
|
36
|
+
observed = await page.evaluate(`(() => {
|
|
37
|
+
const button = document.querySelector('[data-testid="perps-activity-filter-button"]');
|
|
38
|
+
const style = button ? getComputedStyle(button) : null;
|
|
39
|
+
const rect = button?.getBoundingClientRect();
|
|
40
|
+
const visible = (element) => {
|
|
41
|
+
if (!element) return false;
|
|
42
|
+
const elementStyle = getComputedStyle(element);
|
|
43
|
+
const elementRect = element.getBoundingClientRect();
|
|
44
|
+
return elementStyle.display !== 'none' && elementStyle.visibility !== 'hidden' && elementRect.width > 0 && elementRect.height > 0;
|
|
45
|
+
};
|
|
46
|
+
const cards = [...document.querySelectorAll('[data-testid^="transaction-card-"]')]
|
|
47
|
+
.slice(0, 200)
|
|
48
|
+
.flatMap((element) => {
|
|
49
|
+
const cardStyle = getComputedStyle(element);
|
|
50
|
+
const cardRect = element.getBoundingClientRect();
|
|
51
|
+
if (cardStyle.display === 'none' || cardStyle.visibility === 'hidden' || cardRect.width <= 0 || cardRect.height <= 0) return [];
|
|
52
|
+
return [{
|
|
53
|
+
testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
|
|
54
|
+
label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
|
|
55
|
+
}];
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
hash: String(location.hash),
|
|
59
|
+
selected: String(button?.innerText || '').trim(),
|
|
60
|
+
selectedVisible: Boolean(button && style?.display !== 'none' && style?.visibility !== 'hidden' && rect.width > 0 && rect.height > 0),
|
|
61
|
+
cards,
|
|
62
|
+
entries: cards.map((card) => card.label),
|
|
63
|
+
loadedBoundary: visible(document.querySelector('[data-testid="perps-activity-page-skeleton"]'))
|
|
64
|
+
? null
|
|
65
|
+
: visible(document.querySelector('[data-testid="perps-activity-page"]'))
|
|
66
|
+
? cards.length > 0 ? 'visible-cards' : 'loaded-empty'
|
|
67
|
+
: null,
|
|
68
|
+
empty: String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 2_000),
|
|
69
|
+
};
|
|
70
|
+
})()`);
|
|
71
|
+
route = activityRoute(observed.hash);
|
|
72
|
+
if (
|
|
73
|
+
route.pathname === '#/perps/activity' &&
|
|
74
|
+
route.queryFilter === option &&
|
|
75
|
+
observed.selectedVisible &&
|
|
76
|
+
observed.selected.toLowerCase() === filter &&
|
|
77
|
+
observed.loadedBoundary !== null
|
|
78
|
+
) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (Date.now() >= deadline) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Perps activity filter ${filter} did not settle within ${timeoutMs}ms: ` +
|
|
84
|
+
`pathname=${JSON.stringify(route.pathname)}, filter=${JSON.stringify(route.queryFilter)}, ` +
|
|
85
|
+
`label=${JSON.stringify(observed.selected)}, visible=${observed.selectedVisible === true}.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${JSON.stringify(pollIntervalMs)}))`);
|
|
34
89
|
}
|
|
35
90
|
return {
|
|
36
91
|
action: input.action,
|
|
37
92
|
filter,
|
|
93
|
+
pathname: route.pathname,
|
|
94
|
+
queryFilter: route.queryFilter,
|
|
38
95
|
selected: observed.selected,
|
|
39
96
|
count: observed.entries.length,
|
|
97
|
+
cards: observed.cards,
|
|
98
|
+
loadedBoundary: observed.loadedBoundary,
|
|
40
99
|
entries: observed.entries,
|
|
41
100
|
empty: observed.entries.length === 0 ? observed.empty : undefined,
|
|
42
101
|
proofPath: 'trusted-pointer-visible-ui',
|
|
43
102
|
};
|
|
44
|
-
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
106
|
+
runAdapter((input) => withExtensionPage(input, (page) => selectActivityFilter(page, input)));
|
|
107
|
+
}
|