@deeeed/metamask-harness 0.37.0 → 0.38.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 +27 -0
- package/adapters/extension/artifact-runtime-state.cjs +13 -3
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +2 -0
- package/adapters/extension/live.sh +1 -1
- package/adapters/extension/readiness.mjs +1 -1
- package/adapters/manifest.json +8 -0
- package/dist/adapters/extension/runtime-decision.js +4 -1
- package/dist/adapters/extension/runtime.js +42 -3
- package/dist/adapters/mobile/provision.js +38 -3
- package/dist/adapters.js +68 -3
- package/dist/app-lifecycle.js +4 -3
- package/dist/commands/call.js +4 -1
- package/dist/commands/launch/extension.js +21 -5
- package/dist/commands/launch/index.js +3 -0
- package/dist/commands/run-engine.js +40 -29
- package/dist/commands/run.js +1 -1
- package/dist/recipe-security.js +40 -0
- package/dist/runner.js +3 -0
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
- package/library/actions/extension/assets/finish_send.mjs +128 -0
- package/library/actions/extension/assets/import_custom_token.mjs +227 -0
- package/library/actions/extension/assets/prepare_send.mjs +53 -0
- package/library/actions/extension/assets/read_details.mjs +57 -0
- package/library/actions/extension/assets/read_visible_state.mjs +50 -0
- package/library/actions/extension/assets/review_send.mjs +23 -0
- package/library/actions/extension/assets/select_network_scope.mjs +30 -0
- package/library/actions/extension/assets/send.mjs +104 -0
- package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
- package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
- package/library/actions/extension/assets/verify_sorting.mjs +89 -0
- package/library/actions/extension/networks/add_chainlist.mjs +94 -0
- package/library/actions/extension/networks/add_custom.mjs +123 -0
- package/library/actions/extension/networks/custom_network.mjs +103 -0
- package/library/actions/extension/networks/read_visible_state.mjs +50 -0
- package/library/actions/extension/networks/remove_custom.mjs +68 -0
- package/library/actions/extension/perps/close_orders.mjs +1 -0
- package/library/actions/extension/perps/close_visible_position.mjs +133 -0
- package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
- package/library/actions/extension/perps/edit_margin.mjs +82 -0
- package/library/actions/extension/perps/open_balance_action.mjs +58 -0
- package/library/actions/extension/perps/open_position_action.mjs +80 -0
- package/library/actions/extension/perps/perps.mjs +194 -2
- package/library/actions/extension/perps/place_order.mjs +5 -2
- package/library/actions/extension/perps/read_eligibility.mjs +34 -0
- package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
- package/library/actions/extension/perps/read_visible_state.mjs +98 -0
- package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
- package/library/actions/extension/perps/select_market_filter.mjs +71 -0
- package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
- package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
- package/library/actions/extension/platform/cdp.mjs +104 -1
- package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
- package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
- package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
- package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
- package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
- package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
- package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
- package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
- package/library/actions/extension/ui/navigate.mjs +264 -8
- package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
- package/library/actions/extension/wallet/lock.mjs +35 -0
- package/library/actions/extension/wallet/select_account.mjs +71 -7
- package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
- package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
- package/library/actions/mobile/platform/native-session-name.mjs +5 -0
- package/library/actions/mobile/platform/observe-ui.mjs +5 -3
- package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
- package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
- package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
- package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
- package/library/actions/mobile/ui/native-navigation.mjs +45 -5
- package/library/actions/mobile/ui/navigate.mjs +3 -1
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
- package/library/actions/mobile/wallet/native-ui.mjs +21 -2
- package/library/actions/shared/assets/visible-state.mjs +176 -0
- package/library/actions/shared/networks/visible-state.mjs +70 -0
- package/library/actions/shared/perps/visible-state.mjs +354 -0
- package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
- package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
- package/library/manifests/extension.action-manifest.json +1255 -189
- package/library/manifests/mobile.action-manifest.json +219 -1
- package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
- package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
- package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
- package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
- package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
- package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
- package/library/recipes/assets/release-token-details.recipe.json +48 -0
- package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
- package/library/recipes/assets/release-token-list.recipe.json +75 -0
- package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
- package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
- package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
- package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
- package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
- package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
- package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
- package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
- package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
- package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
- package/library/recipes/perps/release-market-details.recipe.json +83 -0
- package/library/recipes/perps/release-market-search.recipe.json +79 -0
- package/library/recipes/perps/release-watchlist.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
- package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
- package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
- package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
- package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
- package/package.json +6 -5
- package/scripts/validate-release-report.mjs +319 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
|
|
3
|
+
async function pause(page) {
|
|
4
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 200))');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function visible(page, selector) {
|
|
8
|
+
return page.evaluate(`(() => {
|
|
9
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
10
|
+
if (!element) return false;
|
|
11
|
+
const style = getComputedStyle(element);
|
|
12
|
+
const rect = element.getBoundingClientRect();
|
|
13
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
14
|
+
})()`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function clickUntil(page, trigger, result, deadline, message) {
|
|
18
|
+
while (Date.now() < deadline) {
|
|
19
|
+
if (await visible(page, result)) return;
|
|
20
|
+
if (await visible(page, trigger)) {
|
|
21
|
+
try {
|
|
22
|
+
await page.click(trigger);
|
|
23
|
+
} catch {
|
|
24
|
+
await pause(page);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await pause(page);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
33
|
+
const percent = Number(input.node?.percent);
|
|
34
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
35
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent > 50) {
|
|
36
|
+
throw new Error('metamask.swap_bridge.set_slippage requires percent greater than 0 and at most 50.');
|
|
37
|
+
}
|
|
38
|
+
const deadline = Date.now() + timeoutMs;
|
|
39
|
+
const settings = dataTestId('bridge__header-settings-button');
|
|
40
|
+
const edit = dataTestId('slippage-edit-button');
|
|
41
|
+
const custom = dataTestId('bridge__tx-settings-modal-custom-button');
|
|
42
|
+
const inputSelector = dataTestId('bridge__tx-settings-modal-custom-input');
|
|
43
|
+
const submit = dataTestId('bridge__tx-settings-modal-submit-button');
|
|
44
|
+
const opener = (await visible(page, edit)) ? edit : settings;
|
|
45
|
+
await clickUntil(page, opener, custom, deadline, 'Swap transaction settings did not open.');
|
|
46
|
+
await clickUntil(page, custom, inputSelector, deadline, 'Custom slippage input did not open.');
|
|
47
|
+
await page.setInput(inputSelector, String(percent));
|
|
48
|
+
for (const type of ['keyDown', 'keyUp']) {
|
|
49
|
+
await page.session.call('Input.dispatchKeyEvent', {
|
|
50
|
+
type,
|
|
51
|
+
key: 'Tab',
|
|
52
|
+
code: 'Tab',
|
|
53
|
+
windowsVirtualKeyCode: 9,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
const enabled = await page.evaluate(`document.querySelector(${JSON.stringify(submit)})?.disabled === false`);
|
|
58
|
+
if (enabled) break;
|
|
59
|
+
await pause(page);
|
|
60
|
+
}
|
|
61
|
+
if (!(await page.evaluate(`document.querySelector(${JSON.stringify(submit)})?.disabled === false`))) {
|
|
62
|
+
throw new Error('Custom slippage did not enable Submit.');
|
|
63
|
+
}
|
|
64
|
+
await page.click(submit);
|
|
65
|
+
let observed = null;
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
observed = await page.evaluate(`(() => {
|
|
68
|
+
const button = document.querySelector('[data-testid="slippage-edit-button"]');
|
|
69
|
+
return String(button?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim();
|
|
70
|
+
})()`);
|
|
71
|
+
if (observed.includes(`${percent}%`)) break;
|
|
72
|
+
await pause(page);
|
|
73
|
+
}
|
|
74
|
+
if (!observed?.includes(`${percent}%`)) {
|
|
75
|
+
throw new Error(`Swap quote did not display Slippage: ${percent}%.`);
|
|
76
|
+
}
|
|
77
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 500))');
|
|
78
|
+
let stableSince = null;
|
|
79
|
+
let quote = null;
|
|
80
|
+
let lastRefreshAt = 0;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
quote = await page.evaluate(`(() => {
|
|
83
|
+
const destination = document.querySelector('[data-testid="to-amount"]');
|
|
84
|
+
const input = destination?.matches('input, textarea') ? destination : destination?.querySelector('input, textarea');
|
|
85
|
+
const cta = document.querySelector('[data-testid="bridge-cta-button"]');
|
|
86
|
+
return {
|
|
87
|
+
loading: Boolean(document.querySelector('[data-testid="multichain-bridge-quote-card-loading"]')),
|
|
88
|
+
destinationAmount: String(input?.value ?? '').trim(),
|
|
89
|
+
cta: String(cta?.innerText || '').replace(/\\s+/gu, ' ').trim(),
|
|
90
|
+
ctaEnabled: Boolean(cta && !cta.disabled),
|
|
91
|
+
};
|
|
92
|
+
})()`);
|
|
93
|
+
if (/^(?:Get new quote|Refresh quote)$/iu.test(quote.cta) && quote.ctaEnabled && Date.now() - lastRefreshAt >= 1000) {
|
|
94
|
+
await page.click(dataTestId('bridge-cta-button'));
|
|
95
|
+
lastRefreshAt = Date.now();
|
|
96
|
+
stableSince = null;
|
|
97
|
+
await pause(page);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ready = !quote.loading && Number(quote.destinationAmount) > 0 && quote.ctaEnabled
|
|
101
|
+
&& /^(?:Swap|Bridge)$/iu.test(quote.cta);
|
|
102
|
+
if (ready) {
|
|
103
|
+
stableSince ??= Date.now();
|
|
104
|
+
if (Date.now() - stableSince >= 1000) break;
|
|
105
|
+
} else {
|
|
106
|
+
stableSince = null;
|
|
107
|
+
}
|
|
108
|
+
await pause(page);
|
|
109
|
+
}
|
|
110
|
+
if (stableSince === null || quote?.loading || !(Number(quote?.destinationAmount) > 0) || !quote?.ctaEnabled
|
|
111
|
+
|| !/^(?:Swap|Bridge)$/iu.test(String(quote?.cta))) {
|
|
112
|
+
throw new Error('Swap quote did not finish refreshing after custom slippage was applied.');
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
action: input.action,
|
|
116
|
+
percent,
|
|
117
|
+
observed,
|
|
118
|
+
quote,
|
|
119
|
+
proofPath: 'trusted-pointer-keyboard-visible-ui',
|
|
120
|
+
};
|
|
121
|
+
}));
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { bodyState, tokenRow, transactionState, visible, waitFor } from '../assets/send.mjs';
|
|
3
|
+
|
|
4
|
+
const selectors = {
|
|
5
|
+
cta: dataTestId('bridge-cta-button'),
|
|
6
|
+
warningProceed: `${dataTestId('bridge-alert-modal-proceed-button')}:not([disabled])`,
|
|
7
|
+
confirm: dataTestId('confirm-sign-and-send-transaction-confirm-snap-footer-button'),
|
|
8
|
+
close: 'button[aria-label="Close"]',
|
|
9
|
+
activity: dataTestId('bottom-nav-activity'),
|
|
10
|
+
home: dataTestId('bottom-nav-home'),
|
|
11
|
+
swap: dataTestId('bottom-nav-swaps'),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
async function pause(page, timeoutMs = 200) {
|
|
15
|
+
await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${JSON.stringify(timeoutMs)}))`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function activatePoint(page, point) {
|
|
19
|
+
await page.session.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
|
|
20
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
21
|
+
type: 'mousePressed',
|
|
22
|
+
x: point.x,
|
|
23
|
+
y: point.y,
|
|
24
|
+
button: 'left',
|
|
25
|
+
clickCount: 1,
|
|
26
|
+
});
|
|
27
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
28
|
+
type: 'mouseReleased',
|
|
29
|
+
x: point.x,
|
|
30
|
+
y: point.y,
|
|
31
|
+
button: 'left',
|
|
32
|
+
clickCount: 1,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function activateSelector(page, selector) {
|
|
37
|
+
const point = await page.evaluate(`(() => {
|
|
38
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
39
|
+
if (!element) return null;
|
|
40
|
+
const style = getComputedStyle(element);
|
|
41
|
+
const rect = element.getBoundingClientRect();
|
|
42
|
+
if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
|
|
43
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
44
|
+
})()`);
|
|
45
|
+
if (!point) return false;
|
|
46
|
+
await activatePoint(page, point);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isActivityRoute(route) {
|
|
51
|
+
return route.includes('/activity') || /[?&]tab=activity(?:&|$)/u.test(route);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function transactionIdentifier(value) {
|
|
55
|
+
const identifier = String(value);
|
|
56
|
+
return /^0x/iu.test(identifier) ? identifier.toLowerCase() : identifier;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function openActivity(page, timeoutMs) {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
let lastActivationAt = 0;
|
|
62
|
+
let lastResetAt = 0;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
const state = await bodyState(page);
|
|
65
|
+
if (isActivityRoute(state.route) && await visible(page, dataTestId('activity-list-item'))) return state;
|
|
66
|
+
if (isActivityRoute(state.route) && await visible(page, selectors.swap) && Date.now() - lastResetAt >= 1000) {
|
|
67
|
+
await activateSelector(page, selectors.swap);
|
|
68
|
+
lastResetAt = Date.now();
|
|
69
|
+
await pause(page);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (await visible(page, selectors.activity) && Date.now() - lastActivationAt >= 1000) {
|
|
73
|
+
await activateSelector(page, selectors.activity);
|
|
74
|
+
lastActivationAt = Date.now();
|
|
75
|
+
}
|
|
76
|
+
await pause(page);
|
|
77
|
+
}
|
|
78
|
+
throw new Error('Activity did not open after swap.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function quoteState(page) {
|
|
82
|
+
return page.evaluate(`(() => {
|
|
83
|
+
const value = (testId) => {
|
|
84
|
+
const root = document.querySelector('[data-testid="' + testId + '"]');
|
|
85
|
+
const input = root?.matches('input, textarea') ? root : root?.querySelector('input, textarea');
|
|
86
|
+
return String(input?.value ?? '').trim();
|
|
87
|
+
};
|
|
88
|
+
const label = (testId) => String(document.querySelector('[data-testid="' + testId + '"]')?.innerText || '').replace(/\\s+/gu, ' ').trim();
|
|
89
|
+
const cta = document.querySelector('[data-testid="bridge-cta-button"]');
|
|
90
|
+
return {
|
|
91
|
+
route: location.hash,
|
|
92
|
+
sourceSymbol: label('bridge-source-button'),
|
|
93
|
+
destinationSymbol: label('bridge-destination-button'),
|
|
94
|
+
sourceAmount: value('from-amount'),
|
|
95
|
+
destinationAmount: value('to-amount'),
|
|
96
|
+
cta: cta ? String(cta.innerText || '').replace(/\\s+/gu, ' ').trim() : null,
|
|
97
|
+
ctaEnabled: Boolean(cta && !cta.disabled),
|
|
98
|
+
loading: Boolean(document.querySelector('[data-testid="multichain-bridge-quote-card-loading"]')),
|
|
99
|
+
warning: String(document.body?.innerText || '').match(/(?:might be reverted|may fail|likely to fail|insufficient funds|trade route isn't available)[^.]{0,300}/iu)?.[0] || null,
|
|
100
|
+
};
|
|
101
|
+
})()`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function submittableQuote(page, timeoutMs) {
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
const quote = await quoteState(page);
|
|
108
|
+
if (/^(?:Get new quote|Refresh quote)$/iu.test(String(quote.cta)) && quote.ctaEnabled) {
|
|
109
|
+
await page.click(selectors.cta);
|
|
110
|
+
await pause(page, 500);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (/^(?:Swap|Bridge)$/iu.test(String(quote.cta)) && quote.ctaEnabled && !quote.loading) return quote;
|
|
114
|
+
await pause(page);
|
|
115
|
+
}
|
|
116
|
+
throw new Error('Swap did not expose a fresh submittable quote.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function activityRow(page, sourceSymbol, destinationSymbol, occurrence = 0) {
|
|
120
|
+
return page.evaluate(`(() => {
|
|
121
|
+
const source = ${JSON.stringify(sourceSymbol)};
|
|
122
|
+
const destination = ${JSON.stringify(destinationSymbol)};
|
|
123
|
+
const occurrence = ${JSON.stringify(occurrence)};
|
|
124
|
+
let matched = 0;
|
|
125
|
+
for (const row of document.querySelectorAll('[data-testid="activity-list-item"]')) {
|
|
126
|
+
const label = String(row.innerText || row.textContent || '').replace(/\\s+/gu, ' ').trim();
|
|
127
|
+
const upper = label.toUpperCase();
|
|
128
|
+
const pair = upper.includes(source + ' → ' + destination)
|
|
129
|
+
|| upper.includes(source + ' -> ' + destination);
|
|
130
|
+
const bridgeSource = upper.includes('BRIDGED ' + source);
|
|
131
|
+
if (!pair && !bridgeSource) continue;
|
|
132
|
+
const style = getComputedStyle(row);
|
|
133
|
+
const rect = row.getBoundingClientRect();
|
|
134
|
+
if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
|
|
135
|
+
if (matched < occurrence) {
|
|
136
|
+
matched += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
label: label.slice(0, 1000),
|
|
141
|
+
status: String(row.getAttribute('data-tx-status') || ''),
|
|
142
|
+
x: rect.left + rect.width / 2,
|
|
143
|
+
y: rect.top + rect.height / 2,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
})()`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function activateActivityRow(page, row) {
|
|
151
|
+
await activatePoint(page, row);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
155
|
+
const sourceSymbol = String(input.node?.source_symbol || '').trim().toUpperCase();
|
|
156
|
+
const destinationSymbol = String(input.node?.destination_symbol || '').trim().toUpperCase();
|
|
157
|
+
const requestedAmount = String(input.node?.amount || '').trim();
|
|
158
|
+
const resumeSubmitted = input.node?.resume_submitted === true;
|
|
159
|
+
const resumedHash = String(input.node?.transaction_hash || '').trim();
|
|
160
|
+
const activityOccurrence = Number(input.node?.activity_occurrence ?? 0);
|
|
161
|
+
const requireDestinationBalance = input.node?.require_destination_balance !== false;
|
|
162
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 300_000);
|
|
163
|
+
if (!sourceSymbol || !destinationSymbol || !requestedAmount || !Number.isInteger(activityOccurrence) || activityOccurrence < 0) {
|
|
164
|
+
throw new Error('metamask.swap_bridge.submit_transaction requires source_symbol, destination_symbol, and amount.');
|
|
165
|
+
}
|
|
166
|
+
if (!resumeSubmitted && input.node?.confirm_live !== true) {
|
|
167
|
+
throw new Error('metamask.swap_bridge.submit_transaction requires confirm_live=true.');
|
|
168
|
+
}
|
|
169
|
+
if (resumeSubmitted && !resumedHash) {
|
|
170
|
+
throw new Error('metamask.swap_bridge.submit_transaction resume_submitted=true requires transaction_hash.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let submitted = resumeSubmitted;
|
|
174
|
+
let confirmed = resumeSubmitted;
|
|
175
|
+
let amount = requestedAmount;
|
|
176
|
+
let beforeTransactionIdentifiers = new Set();
|
|
177
|
+
if (!resumeSubmitted) {
|
|
178
|
+
let quote = await submittableQuote(page, Math.min(timeoutMs, 60_000));
|
|
179
|
+
if (requestedAmount === 'visible') amount = quote.sourceAmount;
|
|
180
|
+
if (!quote.sourceSymbol.toUpperCase().includes(sourceSymbol)
|
|
181
|
+
|| !quote.destinationSymbol.toUpperCase().includes(destinationSymbol)
|
|
182
|
+
|| quote.sourceAmount !== amount
|
|
183
|
+
|| !(Number(quote.destinationAmount) > 0)
|
|
184
|
+
|| !quote.ctaEnabled
|
|
185
|
+
|| quote.loading
|
|
186
|
+
|| quote.warning) {
|
|
187
|
+
throw new Error(`Swap quote is not safe to submit: ${JSON.stringify(quote)}.`);
|
|
188
|
+
}
|
|
189
|
+
await pause(page, 2000);
|
|
190
|
+
quote = await submittableQuote(page, Math.min(timeoutMs, 60_000));
|
|
191
|
+
if (!quote.sourceSymbol.toUpperCase().includes(sourceSymbol)
|
|
192
|
+
|| !quote.destinationSymbol.toUpperCase().includes(destinationSymbol)
|
|
193
|
+
|| quote.sourceAmount !== amount
|
|
194
|
+
|| !(Number(quote.destinationAmount) > 0)
|
|
195
|
+
|| !quote.ctaEnabled
|
|
196
|
+
|| quote.loading
|
|
197
|
+
|| quote.warning) {
|
|
198
|
+
throw new Error(`Swap quote changed before submission: ${JSON.stringify(quote)}.`);
|
|
199
|
+
}
|
|
200
|
+
const beforeTransactions = await transactionState(page);
|
|
201
|
+
if (!beforeTransactions.available) {
|
|
202
|
+
throw new Error('Transaction state is unavailable; refusing to submit without proof binding.');
|
|
203
|
+
}
|
|
204
|
+
beforeTransactionIdentifiers = new Set(beforeTransactions.transactions.flatMap((transaction) => [
|
|
205
|
+
transaction.id,
|
|
206
|
+
transaction.hash,
|
|
207
|
+
].filter(Boolean).map(transactionIdentifier)));
|
|
208
|
+
await page.click(selectors.cta);
|
|
209
|
+
const submissionDeadline = Date.now() + Math.min(timeoutMs, 120_000);
|
|
210
|
+
while (Date.now() < submissionDeadline) {
|
|
211
|
+
if (await visible(page, selectors.warningProceed)) {
|
|
212
|
+
await page.click(selectors.warningProceed);
|
|
213
|
+
await pause(page);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (await visible(page, selectors.confirm)) {
|
|
217
|
+
await page.click(selectors.confirm);
|
|
218
|
+
confirmed = true;
|
|
219
|
+
submitted = true;
|
|
220
|
+
await pause(page);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const state = await bodyState(page);
|
|
224
|
+
if (state.route !== '#/cross-chain/swaps/prepare-bridge-page'
|
|
225
|
+
&& !state.route.includes('/confirmation')) {
|
|
226
|
+
submitted = true;
|
|
227
|
+
}
|
|
228
|
+
if (submitted && (state.route === '' || state.route === '#/' || isActivityRoute(state.route) || /Processing|Transaction complete|submitted/iu.test(state.text))) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
await pause(page);
|
|
232
|
+
}
|
|
233
|
+
if (!submitted) {
|
|
234
|
+
throw new Error('Swap submission did not reach a signed or submitted state.');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let activity = null;
|
|
239
|
+
let details = await bodyState(page);
|
|
240
|
+
const resumedFromDetails = details.route.includes('/tx/')
|
|
241
|
+
&& /\bConfirmed\b/u.test(details.text)
|
|
242
|
+
&& /\bYou sent\b/u.test(details.text)
|
|
243
|
+
&& /\bYou received\b/u.test(details.text)
|
|
244
|
+
&& details.text.toUpperCase().includes(sourceSymbol)
|
|
245
|
+
&& details.text.toUpperCase().includes(destinationSymbol);
|
|
246
|
+
if (!resumedFromDetails) {
|
|
247
|
+
await waitFor(page, async () => {
|
|
248
|
+
const state = await bodyState(page);
|
|
249
|
+
if (isActivityRoute(state.route) || await visible(page, selectors.activity)) return state;
|
|
250
|
+
if (await visible(page, selectors.close)) await page.click(selectors.close);
|
|
251
|
+
return null;
|
|
252
|
+
}, 'Swap did not expose Activity after submission.', timeoutMs);
|
|
253
|
+
await openActivity(page, timeoutMs);
|
|
254
|
+
|
|
255
|
+
let lastActivityRefresh = 0;
|
|
256
|
+
const activityTarget = await waitFor(page, async () => {
|
|
257
|
+
const row = await activityRow(page, sourceSymbol, destinationSymbol, activityOccurrence);
|
|
258
|
+
if (row?.status === 'failed') throw new Error(`Swap Activity failed: ${row.label}.`);
|
|
259
|
+
if (row?.status === 'confirmed') return row;
|
|
260
|
+
if (Date.now() - lastActivityRefresh >= 5000 && await visible(page, selectors.home)) {
|
|
261
|
+
await page.click(selectors.home);
|
|
262
|
+
await pause(page, 500);
|
|
263
|
+
await openActivity(page, Math.min(10_000, Math.max(1, timeoutMs)));
|
|
264
|
+
lastActivityRefresh = Date.now();
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}, `Swap ${sourceSymbol} to ${destinationSymbol} did not reach Confirmed in Activity.`, timeoutMs);
|
|
268
|
+
activity = { label: activityTarget.label, status: activityTarget.status };
|
|
269
|
+
confirmed = true;
|
|
270
|
+
let lastDetailsActivationAt = 0;
|
|
271
|
+
details = await waitFor(page, async () => {
|
|
272
|
+
const current = await bodyState(page);
|
|
273
|
+
if (current.route.includes('/tx/') && /\bConfirmed\b/u.test(current.text)) return current;
|
|
274
|
+
if (Date.now() - lastDetailsActivationAt >= 1000) {
|
|
275
|
+
await activateActivityRow(page, activityTarget);
|
|
276
|
+
lastDetailsActivationAt = Date.now();
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}, 'Confirmed swap transaction details did not open.', timeoutMs);
|
|
280
|
+
}
|
|
281
|
+
const explorerUrl = await page.evaluate(`document.querySelector('[data-testid="transaction-details-block-explorer"]')?.getAttribute('data-explorer-url') || null`);
|
|
282
|
+
const routeIdentifier = details.route.match(/\/([^/?#]+)(?:\?|$)/u)?.[1] ?? null;
|
|
283
|
+
const transactionHash = details.route.match(/\/(0x[0-9a-f]{64})(?:\?|$)/iu)?.[1]
|
|
284
|
+
?? details.text.match(/\b0x[0-9a-f]{64}\b/iu)?.[0]
|
|
285
|
+
?? String(explorerUrl || '').match(/\/(0x[0-9a-f]{64})(?:[/?#]|$)/iu)?.[1]
|
|
286
|
+
?? (/^[1-9A-HJ-NP-Za-km-z]{64,100}$/u.test(String(routeIdentifier)) ? routeIdentifier : null)
|
|
287
|
+
?? String(explorerUrl || '').match(/\/(?:tx|transaction)\/([1-9A-HJ-NP-Za-km-z]{64,100})(?:[/?#]|$)/u)?.[1]
|
|
288
|
+
?? null;
|
|
289
|
+
if (!transactionHash) throw new Error('Confirmed swap details did not expose a full transaction hash.');
|
|
290
|
+
if (resumeSubmitted && transactionIdentifier(transactionHash) !== transactionIdentifier(resumedHash)) {
|
|
291
|
+
throw new Error(`Confirmed swap details opened ${transactionHash}, expected ${resumedHash}.`);
|
|
292
|
+
}
|
|
293
|
+
if (!resumeSubmitted) {
|
|
294
|
+
const confirmedIdentifier = transactionIdentifier(transactionHash);
|
|
295
|
+
if (beforeTransactionIdentifiers.has(confirmedIdentifier)) {
|
|
296
|
+
throw new Error(`Confirmed swap details opened pre-existing transaction ${transactionHash}.`);
|
|
297
|
+
}
|
|
298
|
+
const afterTransactions = await transactionState(page);
|
|
299
|
+
const submittedTransaction = afterTransactions.transactions.find((transaction) =>
|
|
300
|
+
transactionIdentifier(transaction.hash) === confirmedIdentifier &&
|
|
301
|
+
!beforeTransactionIdentifiers.has(transactionIdentifier(transaction.id)));
|
|
302
|
+
if (!submittedTransaction) {
|
|
303
|
+
throw new Error(`Confirmed swap ${transactionHash} is not bound to a newly created transaction record.`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const detailText = details.text.toUpperCase();
|
|
307
|
+
const sentSource = new RegExp('YOU SENT[\\s\\S]{0,120}\\b' + sourceSymbol + '\\b', 'u');
|
|
308
|
+
const receivedDestination = new RegExp('YOU RECEIVED[\\s\\S]{0,120}\\b' + destinationSymbol + '\\b', 'u');
|
|
309
|
+
if (!sentSource.test(detailText) || !receivedDestination.test(detailText)) {
|
|
310
|
+
throw new Error(`Confirmed swap details do not prove ${sourceSymbol} to ${destinationSymbol}.`);
|
|
311
|
+
}
|
|
312
|
+
const back = dataTestId('transaction-details-back-button');
|
|
313
|
+
let lastBackActivationAt = 0;
|
|
314
|
+
await waitFor(page, async () => {
|
|
315
|
+
const current = await bodyState(page);
|
|
316
|
+
if (isActivityRoute(current.route)) return current;
|
|
317
|
+
if (await visible(page, back) && Date.now() - lastBackActivationAt >= 1000) {
|
|
318
|
+
await page.click(back);
|
|
319
|
+
lastBackActivationAt = Date.now();
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}, 'Swap details did not return to Activity.', timeoutMs);
|
|
323
|
+
await waitFor(page, async () => await visible(page, selectors.home), 'Home navigation did not return after swap details.', timeoutMs);
|
|
324
|
+
let lastHomeActivationAt = 0;
|
|
325
|
+
await waitFor(page, async () => {
|
|
326
|
+
const current = await bodyState(page);
|
|
327
|
+
if (current.route === '' || current.route === '#/' || current.route.startsWith('#/?')) return current;
|
|
328
|
+
if (await visible(page, selectors.home) && Date.now() - lastHomeActivationAt >= 1000) {
|
|
329
|
+
await page.click(selectors.home);
|
|
330
|
+
lastHomeActivationAt = Date.now();
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}, 'Home did not open after the confirmed swap.', timeoutMs);
|
|
334
|
+
const destinationAfter = requireDestinationBalance
|
|
335
|
+
? await waitFor(
|
|
336
|
+
page,
|
|
337
|
+
async () => {
|
|
338
|
+
const row = await tokenRow(page, destinationSymbol);
|
|
339
|
+
const numeric = Number(String(row?.amount || '').replace(/[^0-9.eE+-]/gu, ''));
|
|
340
|
+
return row && Number.isFinite(numeric) && numeric > 0 ? row : null;
|
|
341
|
+
},
|
|
342
|
+
`${destinationSymbol} did not reach a positive Home balance after the transaction.`,
|
|
343
|
+
timeoutMs,
|
|
344
|
+
)
|
|
345
|
+
: null;
|
|
346
|
+
return {
|
|
347
|
+
action: input.action,
|
|
348
|
+
sourceSymbol,
|
|
349
|
+
destinationSymbol,
|
|
350
|
+
amount,
|
|
351
|
+
submitted,
|
|
352
|
+
confirmed,
|
|
353
|
+
activity,
|
|
354
|
+
details,
|
|
355
|
+
destinationAfter,
|
|
356
|
+
explorerUrl,
|
|
357
|
+
transactionHash,
|
|
358
|
+
resumed: resumeSubmitted,
|
|
359
|
+
activityOccurrence,
|
|
360
|
+
destinationBalanceRequired: requireDestinationBalance,
|
|
361
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
362
|
+
};
|
|
363
|
+
}));
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
|
|
3
|
+
function nonEmpty(value, name) {
|
|
4
|
+
if (value == null) return undefined;
|
|
5
|
+
const result = String(value).trim();
|
|
6
|
+
if (!result) throw new Error(`ui.activate_and_observe ${name} must not be empty.`);
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function boundedInteger(value, fallback, minimum, maximum, name) {
|
|
11
|
+
const result = value == null ? fallback : Number(value);
|
|
12
|
+
if (!Number.isInteger(result) || result < minimum || result > maximum) {
|
|
13
|
+
throw new Error(`ui.activate_and_observe ${name} must be an integer from ${minimum} to ${maximum}.`);
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stringList(value, name) {
|
|
19
|
+
if (value == null) return [];
|
|
20
|
+
if (!Array.isArray(value)) throw new Error(`ui.activate_and_observe ${name} must be an array.`);
|
|
21
|
+
return [...new Set(value.map((entry) => String(entry).trim()).filter(Boolean))];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
25
|
+
const testId = nonEmpty(input.node?.test_id ?? input.node?.testID, 'test_id');
|
|
26
|
+
const selector = nonEmpty(input.node?.selector, 'selector');
|
|
27
|
+
if (Number(Boolean(testId)) + Number(Boolean(selector)) !== 1) {
|
|
28
|
+
throw new Error('ui.activate_and_observe requires exactly one of test_id or selector.');
|
|
29
|
+
}
|
|
30
|
+
const target = selector ?? dataTestId(testId);
|
|
31
|
+
const timeoutMs = boundedInteger(input.node?.timeout_ms, 10_000, 1, 60_000, 'timeout_ms');
|
|
32
|
+
const settleMs = boundedInteger(input.node?.settle_ms, 250, 0, 10_000, 'settle_ms');
|
|
33
|
+
const maxItems = boundedInteger(input.node?.max_items, 100, 1, 200, 'max_items');
|
|
34
|
+
const requireTestIds = stringList(input.node?.require_test_ids, 'require_test_ids');
|
|
35
|
+
const requireText = stringList(input.node?.require_text, 'require_text');
|
|
36
|
+
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
let targetVisible = false;
|
|
39
|
+
while (Date.now() <= deadline) {
|
|
40
|
+
targetVisible = await page.evaluate(`(() => {
|
|
41
|
+
const element = document.querySelector(${JSON.stringify(target)});
|
|
42
|
+
if (!element) return false;
|
|
43
|
+
const style = getComputedStyle(element);
|
|
44
|
+
const rect = element.getBoundingClientRect();
|
|
45
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
46
|
+
})()`);
|
|
47
|
+
if (targetVisible) break;
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
49
|
+
}
|
|
50
|
+
if (!targetVisible) throw new Error(`ui.activate_and_observe target did not become visible: ${target}`);
|
|
51
|
+
const interaction = await page.click(target);
|
|
52
|
+
if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
53
|
+
const observed = await page.evaluate(`(() => {
|
|
54
|
+
const maximum = ${JSON.stringify(maxItems)};
|
|
55
|
+
const controls = [];
|
|
56
|
+
for (const element of document.querySelectorAll('[data-testid]')) {
|
|
57
|
+
const testId = element.getAttribute('data-testid');
|
|
58
|
+
if (!testId) continue;
|
|
59
|
+
const style = getComputedStyle(element);
|
|
60
|
+
const rect = element.getBoundingClientRect();
|
|
61
|
+
if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) continue;
|
|
62
|
+
controls.push({
|
|
63
|
+
testId,
|
|
64
|
+
label: String(element.getAttribute('aria-label') || element.innerText || '')
|
|
65
|
+
.replace(/\\s+/gu, ' ')
|
|
66
|
+
.trim()
|
|
67
|
+
.slice(0, 500),
|
|
68
|
+
});
|
|
69
|
+
if (controls.length >= maximum) break;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
route: location.hash,
|
|
73
|
+
text: String(document.body?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 20_000),
|
|
74
|
+
controls,
|
|
75
|
+
};
|
|
76
|
+
})()`);
|
|
77
|
+
const observedTestIds = observed.controls.map((entry) => entry.testId);
|
|
78
|
+
const observedText = [observed.text, ...observed.controls.map((entry) => entry.label)].join(' ');
|
|
79
|
+
const missingTestIds = requireTestIds.filter((entry) => !observedTestIds.includes(entry));
|
|
80
|
+
const missingText = requireText.filter((entry) => !observedText.includes(entry));
|
|
81
|
+
if (missingTestIds.length > 0 || missingText.length > 0) {
|
|
82
|
+
throw new Error([
|
|
83
|
+
missingTestIds.length > 0 ? `missing test IDs: ${missingTestIds.join(', ')}` : '',
|
|
84
|
+
missingText.length > 0 ? `missing text: ${missingText.join(', ')}` : '',
|
|
85
|
+
].filter(Boolean).join('; '));
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
action: input.action,
|
|
89
|
+
interaction,
|
|
90
|
+
route: observed.route,
|
|
91
|
+
controls: observed.controls,
|
|
92
|
+
observedTestIds,
|
|
93
|
+
matchedText: requireText,
|
|
94
|
+
proofPath: 'trusted-pointer-visible-ui',
|
|
95
|
+
};
|
|
96
|
+
}));
|