@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
|
@@ -59,12 +59,13 @@ function cdpPort(input) {
|
|
|
59
59
|
return port;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export function extensionTarget(targets, extensionId = null) {
|
|
62
|
+
export function extensionTarget(targets, extensionId = null, cdpTargetId = null) {
|
|
63
63
|
const candidates = (Array.isArray(targets) ? targets : []).filter((target) => {
|
|
64
64
|
if (target?.type !== 'page') return false;
|
|
65
65
|
if (!String(target?.url ?? '').startsWith('chrome-extension://')) return false;
|
|
66
66
|
if (!String(target.url).includes('/home.html') && !String(target.url).includes('/sidepanel.html')) return false;
|
|
67
67
|
if (extensionId && extensionIdFromTarget(target) !== extensionId) return false;
|
|
68
|
+
if (cdpTargetId && target.id !== cdpTargetId) return false;
|
|
68
69
|
return Boolean(target.webSocketDebuggerUrl);
|
|
69
70
|
});
|
|
70
71
|
const home = candidates.find((target) =>
|
|
@@ -663,15 +664,24 @@ async function openExtensionHomePage(port, extensionId) {
|
|
|
663
664
|
|
|
664
665
|
async function ensureExtensionTarget(input, port) {
|
|
665
666
|
let launch = null;
|
|
667
|
+
const trustedTargetId = input.context?.trustedMutation?.cdpTargetId ?? null;
|
|
668
|
+
if (trustedTargetId !== null && !/^[A-Za-z0-9._:-]{1,128}$/u.test(String(trustedTargetId))) {
|
|
669
|
+
throw new Error('Funded Extension action has no valid trusted CDP target id.');
|
|
670
|
+
}
|
|
666
671
|
try {
|
|
667
672
|
const targets = await jsonGet(`http://127.0.0.1:${port}/json/list`);
|
|
668
673
|
const expectedExtensionId = await extensionIdFromFile(input.context.projectRoot);
|
|
669
674
|
const target =
|
|
670
|
-
(expectedExtensionId
|
|
671
|
-
|
|
675
|
+
(expectedExtensionId
|
|
676
|
+
? extensionTarget(Array.isArray(targets) ? targets : [], expectedExtensionId, trustedTargetId)
|
|
677
|
+
: null) ??
|
|
678
|
+
extensionTarget(Array.isArray(targets) ? targets : [], null, trustedTargetId);
|
|
672
679
|
if (target?.webSocketDebuggerUrl) return { target, launch };
|
|
680
|
+
if (trustedTargetId) {
|
|
681
|
+
throw new Error(`Trusted Extension CDP target ${trustedTargetId} is unavailable on port ${port}.`);
|
|
682
|
+
}
|
|
673
683
|
} catch (error) {
|
|
674
|
-
if (!autolaunchEnabled(input)) throw error;
|
|
684
|
+
if (trustedTargetId || !autolaunchEnabled(input)) throw error;
|
|
675
685
|
}
|
|
676
686
|
if (!autolaunchEnabled(input)) {
|
|
677
687
|
throw new Error(`No extension page target found on CDP port ${port}. Launch the extension runtime first, or set METAMASK_RECIPE_EXTENSION_AUTOLAUNCH=1 to launch an existing dist/chrome.`);
|
|
@@ -707,29 +717,98 @@ export async function writeOutput(input, output) {
|
|
|
707
717
|
await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
|
708
718
|
}
|
|
709
719
|
|
|
720
|
+
export function extensionActionOutput(result, { port, target, launch, browserVersion }) {
|
|
721
|
+
const identity = extensionRuntimeIdentity({ port, target, browserVersion });
|
|
722
|
+
return {
|
|
723
|
+
...result,
|
|
724
|
+
cdpPort: identity.cdpPort,
|
|
725
|
+
targetUrl: target.url,
|
|
726
|
+
runtimeLaunch: launch,
|
|
727
|
+
cdpTargetId: identity.cdpTargetId,
|
|
728
|
+
runtimeSessionId: identity.runtimeSessionId,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function extensionRuntimeIdentity({ port, target, browserVersion }) {
|
|
733
|
+
const cdpTargetId = String(target?.id ?? '');
|
|
734
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(cdpTargetId)) {
|
|
735
|
+
throw new Error('Selected Extension CDP target has no valid stable target id.');
|
|
736
|
+
}
|
|
737
|
+
let runtimeSessionId = '';
|
|
738
|
+
try {
|
|
739
|
+
const browserSocket = new URL(String(browserVersion?.webSocketDebuggerUrl ?? ''));
|
|
740
|
+
if (!['ws:', 'wss:'].includes(browserSocket.protocol)) throw new Error('invalid protocol');
|
|
741
|
+
const match = /^\/devtools\/browser\/([A-Za-z0-9._:-]{1,128})$/u.exec(browserSocket.pathname);
|
|
742
|
+
runtimeSessionId = match?.[1] ?? '';
|
|
743
|
+
} catch {
|
|
744
|
+
runtimeSessionId = '';
|
|
745
|
+
}
|
|
746
|
+
if (!runtimeSessionId) {
|
|
747
|
+
throw new Error('Extension browser CDP endpoint has no valid stable runtime session id.');
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
cdpPort: port,
|
|
751
|
+
cdpTargetId,
|
|
752
|
+
runtimeSessionId,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
710
756
|
export async function withExtensionPage(input, callback) {
|
|
711
757
|
const port = cdpPort(input);
|
|
712
758
|
const { target, launch } = await ensureExtensionTarget(input, port);
|
|
759
|
+
const browserVersion = await jsonGet(`http://127.0.0.1:${port}/json/version`);
|
|
713
760
|
const session = await CdpSession.connect(target.webSocketDebuggerUrl);
|
|
714
761
|
try {
|
|
715
762
|
const extensionId = extensionIdFromTarget(target);
|
|
716
763
|
const origin = `chrome-extension://${extensionId}`;
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
764
|
+
const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
|
|
765
|
+
const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
|
|
766
|
+
const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
|
|
767
|
+
if (recoverVisibleWallet) {
|
|
768
|
+
try {
|
|
769
|
+
await session.call('Runtime.enable', {}, { timeoutMs: 1000 });
|
|
770
|
+
} catch {
|
|
771
|
+
let route = '<unavailable>';
|
|
772
|
+
try {
|
|
773
|
+
await session.call('Page.enable', {}, { timeoutMs: 1000 });
|
|
774
|
+
const history = await session.call('Page.getNavigationHistory', {}, { timeoutMs: 1000 });
|
|
775
|
+
const currentUrl = new URL(String(history.entries?.[history.currentIndex]?.url ?? ''));
|
|
776
|
+
route = currentUrl.hash.split('?')[0]
|
|
777
|
+
.replace(/0x[0-9a-f]{40}/giu, '<redacted-address>')
|
|
778
|
+
.slice(0, 200);
|
|
779
|
+
if (currentUrl.origin !== origin || !['/home.html', '/sidepanel.html'].includes(currentUrl.pathname)) {
|
|
780
|
+
throw new Error('unexpected visible wallet target');
|
|
781
|
+
}
|
|
782
|
+
const navigation = await session.call('Page.navigate', { url: `${origin}${currentUrl.pathname}#/` }, { timeoutMs: 1000 });
|
|
783
|
+
if (navigation.errorText) throw new Error('visible wallet Home reload failed');
|
|
784
|
+
await sleep(100);
|
|
785
|
+
await session.call('Runtime.enable', {}, { timeoutMs: 5000 });
|
|
786
|
+
} catch {
|
|
787
|
+
throw new Error(`Visible wallet Runtime did not recover after the same-target Home reload (route ${JSON.stringify(route)}).`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
await session.call('Page.enable', {}, { timeoutMs: 1000 });
|
|
791
|
+
} else {
|
|
792
|
+
await session.call('Runtime.enable');
|
|
793
|
+
await session.call('Page.enable');
|
|
794
|
+
}
|
|
720
795
|
const result = await callback(page);
|
|
721
|
-
return
|
|
796
|
+
return extensionActionOutput(result, { port, target, launch, browserVersion });
|
|
722
797
|
} finally {
|
|
723
798
|
session.close();
|
|
724
799
|
}
|
|
725
800
|
}
|
|
726
801
|
|
|
727
802
|
export class ExtensionPage extends CdpWebPage {
|
|
728
|
-
constructor(session, origin, target, port) {
|
|
803
|
+
constructor(session, origin, target, port, runtimeIdentity) {
|
|
729
804
|
super(session);
|
|
730
805
|
this.origin = origin;
|
|
731
806
|
this.target = target;
|
|
732
807
|
this.port = port;
|
|
808
|
+
this.runtimeIdentity = Object.freeze({
|
|
809
|
+
...runtimeIdentity,
|
|
810
|
+
extensionOrigin: origin,
|
|
811
|
+
});
|
|
733
812
|
}
|
|
734
813
|
|
|
735
814
|
async click(selector) {
|
|
@@ -27,11 +27,24 @@ async function waitForState(page, enabled, timeoutMs = 15_000) {
|
|
|
27
27
|
throw new Error(`Basic Functionality did not become ${enabled ? 'enabled' : 'disabled'}.`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
async function waitForEnabledControl(page, selector, timeoutMs = 15_000) {
|
|
31
|
+
const deadline = Date.now() + timeoutMs;
|
|
32
|
+
while (Date.now() <= deadline) {
|
|
33
|
+
const enabled = await page.evaluate(`(() => {
|
|
34
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
35
|
+
if (!element) return false;
|
|
36
|
+
const style = getComputedStyle(element);
|
|
37
|
+
const rect = element.getBoundingClientRect();
|
|
38
|
+
return !element.disabled && element.getAttribute('aria-disabled') !== 'true' &&
|
|
39
|
+
style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
40
|
+
})()`);
|
|
41
|
+
if (enabled) return;
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
33
43
|
}
|
|
34
|
-
|
|
44
|
+
throw new Error(`Basic Functionality confirmation did not become enabled: ${selector}.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function setBasicFunctionality(page, requested, timeoutMs = 15_000) {
|
|
35
48
|
await page.navigateHash('#/settings/privacy');
|
|
36
49
|
await page.waitForSelector(TOGGLE_LABEL_SELECTOR);
|
|
37
50
|
const before = await readState(page);
|
|
@@ -42,17 +55,32 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
42
55
|
if (!requested) {
|
|
43
56
|
await page.waitForSelector(dataTestId('basic-configuration-checkbox'));
|
|
44
57
|
await page.click(dataTestId('basic-configuration-checkbox'));
|
|
45
|
-
|
|
58
|
+
const confirm = dataTestId('basic-configuration-modal-toggle-button');
|
|
59
|
+
await waitForEnabledControl(page, confirm, timeoutMs);
|
|
60
|
+
await page.click(confirm);
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
const after = await waitForState(page, requested);
|
|
64
|
+
const after = await waitForState(page, requested, timeoutMs);
|
|
50
65
|
return {
|
|
51
|
-
action: input.action,
|
|
52
66
|
requested,
|
|
53
67
|
changed: before.enabled !== after.enabled,
|
|
54
68
|
before,
|
|
55
69
|
after,
|
|
56
70
|
proofPath: 'trusted-pointer-visible-ui',
|
|
57
71
|
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
75
|
+
if (typeof input.node?.enabled !== 'boolean') {
|
|
76
|
+
throw new Error('metamask.settings.set_basic_functionality requires enabled=true or enabled=false.');
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
action: input.action,
|
|
80
|
+
...await setBasicFunctionality(
|
|
81
|
+
page,
|
|
82
|
+
input.node.enabled,
|
|
83
|
+
Number(input.node?.timeout_ms ?? 15_000),
|
|
84
|
+
),
|
|
85
|
+
};
|
|
58
86
|
}));
|
|
@@ -13,6 +13,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
13
13
|
'bridge-source-button',
|
|
14
14
|
'bridge-destination-button',
|
|
15
15
|
'bridge__header-settings-button',
|
|
16
|
+
'slippage-edit-button',
|
|
16
17
|
'prepare-swap-page-error',
|
|
17
18
|
'bridge-missing-price-banner',
|
|
18
19
|
];
|
|
@@ -39,6 +40,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
39
40
|
route: location.hash,
|
|
40
41
|
title: document.title,
|
|
41
42
|
items,
|
|
43
|
+
slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
|
|
42
44
|
quote: {
|
|
43
45
|
slippage: String(slippageButton?.parentElement?.innerText || '').replace(/\\s+/gu, ' ').trim() || null,
|
|
44
46
|
networkFee: text('[data-testid="network-fees-sponsored"], [data-testid="network-fees-included"], [data-testid="network-fees"]') || null,
|
|
@@ -52,16 +54,10 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
52
54
|
})()`);
|
|
53
55
|
const state = normalizeVisibleSwapBridgeState(raw, options, 'extension');
|
|
54
56
|
const requireQuote = input.node?.require_quote === true;
|
|
55
|
-
const expectedSlippage = input.node?.expected_slippage == null
|
|
56
|
-
? null
|
|
57
|
-
: Number(input.node.expected_slippage);
|
|
58
57
|
if (requireQuote && (!raw.quote.ctaEnabled || !(Number(state.destinationAmount) > 0)
|
|
59
58
|
|| !/^(?:Swap|Bridge)$/iu.test(String(raw.quote.cta)))) {
|
|
60
59
|
throw new Error('Swap quote is not ready for review.');
|
|
61
60
|
}
|
|
62
|
-
if (expectedSlippage !== null && raw.quote.slippage !== `${expectedSlippage}%`) {
|
|
63
|
-
throw new Error(`Swap slippage mismatch: expected ${expectedSlippage}%, observed ${raw.quote.slippage ?? 'none'}.`);
|
|
64
|
-
}
|
|
65
61
|
return {
|
|
66
62
|
action: input.action,
|
|
67
63
|
...state,
|
|
@@ -7,6 +7,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
7
7
|
await page.setInput(source, options.amount);
|
|
8
8
|
const deadline = Date.now() + options.timeoutMs;
|
|
9
9
|
let observed;
|
|
10
|
+
let lastRefreshAt = 0;
|
|
10
11
|
do {
|
|
11
12
|
observed = await page.evaluate(`(() => {
|
|
12
13
|
const value = (testId) => {
|
|
@@ -23,13 +24,21 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
23
24
|
noQuote: document.body.innerText.includes("This trade route isn't available right now"),
|
|
24
25
|
};
|
|
25
26
|
})()`);
|
|
26
|
-
if (!options.waitForQuote || observed.noQuote
|
|
27
|
+
if (!options.waitForQuote || observed.noQuote) break;
|
|
28
|
+
if (/^(?:Get new quote|Refresh quote)$/iu.test(String(observed.cta))
|
|
29
|
+
&& observed.ctaEnabled && Date.now() - lastRefreshAt >= 1000) {
|
|
30
|
+
await page.click(dataTestId('bridge-cta-button'));
|
|
31
|
+
lastRefreshAt = Date.now();
|
|
32
|
+
}
|
|
33
|
+
if (Number(observed.destinationAmount) > 0 && observed.ctaEnabled
|
|
34
|
+
&& /^(?:Swap|Bridge)$/iu.test(String(observed.cta))) break;
|
|
27
35
|
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 250))');
|
|
28
36
|
} while (Date.now() < deadline);
|
|
29
37
|
if (observed.sourceAmount !== options.amount) {
|
|
30
38
|
throw new Error(`Swap source amount mismatch: expected ${options.amount}, observed ${observed.sourceAmount}.`);
|
|
31
39
|
}
|
|
32
|
-
if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
|
|
40
|
+
if (options.waitForQuote && !observed.noQuote && !(Number(observed.destinationAmount) > 0
|
|
41
|
+
&& observed.ctaEnabled && /^(?:Swap|Bridge)$/iu.test(String(observed.cta)))) {
|
|
33
42
|
throw new Error(`Swap quote did not resolve within ${options.timeoutMs}ms.`);
|
|
34
43
|
}
|
|
35
44
|
return { action: input.action, ...observed, proofPath: 'visible-ui-amount' };
|
|
@@ -21,6 +21,8 @@ const BACK_CONTROLS = [
|
|
|
21
21
|
'page-header-back-button',
|
|
22
22
|
'back-button',
|
|
23
23
|
].map(dataTestId).concat('button[aria-label="Back"]');
|
|
24
|
+
const UI_POLL_MS = 200;
|
|
25
|
+
const PERPS_HOME_SETTLE_MS = 1000;
|
|
24
26
|
|
|
25
27
|
function text(value) {
|
|
26
28
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
@@ -73,7 +75,7 @@ async function hasVisibleSelector(page, selector) {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
async function pauseForUi(page) {
|
|
76
|
-
await page.evaluate(
|
|
78
|
+
await page.evaluate(`new Promise((resolve) => setTimeout(resolve, ${UI_POLL_MS}))`);
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
async function waitForFirstVisible(page, selectors, deadline) {
|
|
@@ -180,15 +182,26 @@ export async function openPerpsHome(page, timeoutMs) {
|
|
|
180
182
|
const deadline = Date.now() + timeoutMs;
|
|
181
183
|
let pendingActivation;
|
|
182
184
|
let perpsActivation;
|
|
185
|
+
let perpsHomeVisibleAt;
|
|
183
186
|
|
|
184
187
|
while (Date.now() < deadline) {
|
|
185
188
|
if (await hasVisibleSelector(page, tutorialSkip)) {
|
|
186
189
|
await activateSemanticControl(page, tutorialSkip);
|
|
187
190
|
steps.push(tutorialSkip);
|
|
191
|
+
perpsHomeVisibleAt = undefined;
|
|
188
192
|
await pauseForUi(page);
|
|
189
193
|
continue;
|
|
190
194
|
}
|
|
191
195
|
if (await hasVisibleSelector(page, perpsHome)) {
|
|
196
|
+
perpsHomeVisibleAt ??= Date.now();
|
|
197
|
+
const settleUntil = Math.min(
|
|
198
|
+
perpsHomeVisibleAt + PERPS_HOME_SETTLE_MS,
|
|
199
|
+
deadline - UI_POLL_MS,
|
|
200
|
+
);
|
|
201
|
+
if (Date.now() + UI_POLL_MS <= settleUntil) {
|
|
202
|
+
await pauseForUi(page);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
192
205
|
return {
|
|
193
206
|
...(perpsActivation ? { navigation: perpsActivation, selector: bottomNav } : {}),
|
|
194
207
|
method: 'visible-ui',
|
|
@@ -196,6 +209,7 @@ export async function openPerpsHome(page, timeoutMs) {
|
|
|
196
209
|
steps,
|
|
197
210
|
};
|
|
198
211
|
}
|
|
212
|
+
perpsHomeVisibleAt = undefined;
|
|
199
213
|
if (await hasVisibleSelector(page, bottomNav)) {
|
|
200
214
|
if (!perpsActivation) {
|
|
201
215
|
perpsActivation = await activateSemanticControl(page, bottomNav);
|
|
@@ -347,15 +361,41 @@ export async function openPerpsMarketList(page, timeoutMs) {
|
|
|
347
361
|
}
|
|
348
362
|
const home = await openPerpsHome(page, Math.max(1, deadline - Date.now()));
|
|
349
363
|
const explore = dataTestId('perps-explore-markets-row');
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
364
|
+
const tutorialSkip = dataTestId('perps-tutorial-skip-button');
|
|
365
|
+
if (!(await waitForFirstVisible(page, [explore], deadline))) {
|
|
366
|
+
throw new Error('Perps Explore Markets did not become visible before timeout.');
|
|
367
|
+
}
|
|
368
|
+
let navigation = await activateSemanticControl(page, explore);
|
|
369
|
+
const destination = await waitForFirstVisible(page, [marketList, tutorialSkip], deadline);
|
|
370
|
+
const steps = [...home.steps, explore];
|
|
371
|
+
if (destination === tutorialSkip) {
|
|
372
|
+
await activateSemanticControl(page, tutorialSkip);
|
|
373
|
+
steps.push(tutorialSkip);
|
|
374
|
+
let exploreReady = false;
|
|
375
|
+
while (Date.now() < deadline) {
|
|
376
|
+
if (!(await hasVisibleSelector(page, tutorialSkip)) && await hasVisibleSelector(page, explore)) {
|
|
377
|
+
exploreReady = true;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
await pauseForUi(page);
|
|
381
|
+
}
|
|
382
|
+
if (!exploreReady) {
|
|
383
|
+
throw new Error('Perps Explore Markets did not become visible after dismissing the tutorial.');
|
|
384
|
+
}
|
|
385
|
+
navigation = await activateSemanticControl(page, explore);
|
|
386
|
+
steps.push(explore);
|
|
387
|
+
if (!(await waitForFirstVisible(page, [marketList], deadline))) {
|
|
388
|
+
throw new Error('Perps market list did not become visible after dismissing the tutorial.');
|
|
389
|
+
}
|
|
390
|
+
} else if (destination !== marketList) {
|
|
391
|
+
throw new Error('Perps market list did not become visible before timeout.');
|
|
392
|
+
}
|
|
353
393
|
return {
|
|
354
394
|
navigation,
|
|
355
395
|
method: 'visible-ui',
|
|
356
396
|
href: await currentHref(page),
|
|
357
397
|
selector: explore,
|
|
358
|
-
steps
|
|
398
|
+
steps,
|
|
359
399
|
};
|
|
360
400
|
}
|
|
361
401
|
|
|
@@ -2,6 +2,10 @@ import { resolveWalletImportCredentials, validateWalletImportOptions, walletImpo
|
|
|
2
2
|
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
3
3
|
import { pasteSecretRecoveryPhrase, setSecretInput } from './secret-input.mjs';
|
|
4
4
|
import { readWalletStateExpression } from './state.mjs';
|
|
5
|
+
import {
|
|
6
|
+
dismissVisibleOnboardingCompletion,
|
|
7
|
+
ensureVisibleSelectedAccount,
|
|
8
|
+
} from './visible-session.mjs';
|
|
5
9
|
|
|
6
10
|
const SELECTORS = {
|
|
7
11
|
importWallet: '[data-testid="onboarding-import-wallet"]',
|
|
@@ -16,6 +20,8 @@ const SELECTORS = {
|
|
|
16
20
|
metricsCheckbox: '[data-testid="metametrics-checkbox"]',
|
|
17
21
|
metricsContinue: '[data-testid="metametrics-i-agree"]',
|
|
18
22
|
complete: '[data-testid="onboarding-complete-done"]',
|
|
23
|
+
unlockPassword: '[data-testid="unlock-password"]',
|
|
24
|
+
unlockSubmit: '[data-testid="unlock-submit"]',
|
|
19
25
|
};
|
|
20
26
|
|
|
21
27
|
async function hasSelector(page, selector) {
|
|
@@ -26,6 +32,20 @@ async function hasSelector(page, selector) {
|
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
async function hasVisibleSelector(page, selector) {
|
|
36
|
+
try {
|
|
37
|
+
return await page.evaluate(`(() => {
|
|
38
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
39
|
+
if (!element) return false;
|
|
40
|
+
const style = getComputedStyle(element);
|
|
41
|
+
const rect = element.getBoundingClientRect();
|
|
42
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
43
|
+
})()`);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
async function waitForSelector(page, selector, timeoutMs) {
|
|
30
50
|
const deadline = Date.now() + timeoutMs;
|
|
31
51
|
while (Date.now() <= deadline) {
|
|
@@ -73,9 +93,9 @@ async function readState(page) {
|
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
async function unlockProfile(page, credentials, timeoutMs) {
|
|
76
|
-
await setSecretInput(page,
|
|
77
|
-
await page.click(
|
|
78
|
-
await waitForSelectorToDisappear(page,
|
|
96
|
+
await setSecretInput(page, SELECTORS.unlockPassword, credentials.password);
|
|
97
|
+
await page.click(SELECTORS.unlockSubmit);
|
|
98
|
+
await waitForSelectorToDisappear(page, SELECTORS.unlockPassword, timeoutMs);
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
async function importThroughUi(page, input, timeoutMs) {
|
|
@@ -127,38 +147,57 @@ async function importThroughUi(page, input, timeoutMs) {
|
|
|
127
147
|
await waitForSelector(page, SELECTORS.complete, timeoutMs);
|
|
128
148
|
await page.click(SELECTORS.complete);
|
|
129
149
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
while (
|
|
133
|
-
Date.now() <= deadline &&
|
|
134
|
-
(!state.completedOnboarding || !state.selectedAccount?.address)
|
|
135
|
-
) {
|
|
136
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
137
|
-
state = await readState(page);
|
|
138
|
-
}
|
|
139
|
-
if (!state.completedOnboarding || !state.selectedAccount?.address) {
|
|
140
|
-
throw new Error('Extension wallet import did not complete onboarding with a selected account.');
|
|
141
|
-
}
|
|
142
|
-
if (state.metametricsOptedIn !== metametricsEnabled) {
|
|
143
|
-
throw new Error(`Extension MetaMetrics preference was ${String(state.metametricsOptedIn)}, expected ${String(metametricsEnabled)}.`);
|
|
144
|
-
}
|
|
145
|
-
const actualAddress = String(state.selectedAccount?.address ?? '').toLowerCase();
|
|
146
|
-
if (actualAddress !== credentials.expectedAddress) {
|
|
147
|
-
throw new Error(`Wallet import selected ${actualAddress || 'no address'}, expected ${credentials.expectedAddress}.`);
|
|
148
|
-
}
|
|
149
|
-
return { credentials, state, metametricsEnabled };
|
|
150
|
+
const visibleSession = await ensureVisibleSelectedAccount(page, credentials.expectedAddress, timeoutMs);
|
|
151
|
+
return { credentials, visibleSession, metametricsEnabled };
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
153
155
|
validateWalletImportOptions('extension', input.node);
|
|
154
156
|
const method = walletImportMethod(input);
|
|
155
157
|
const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
|
|
156
|
-
const
|
|
157
|
-
const onboarding =
|
|
158
|
+
const onboardingRoute = await page.evaluate(`(() => String(location.hash || '').includes('onboarding'))()`);
|
|
159
|
+
const onboarding = onboardingRoute && (
|
|
160
|
+
await hasVisibleSelector(page, SELECTORS.importWallet) ||
|
|
161
|
+
await hasVisibleSelector(page, SELECTORS.importWithSrp) ||
|
|
162
|
+
await hasVisibleSelector(page, SELECTORS.srp)
|
|
163
|
+
);
|
|
158
164
|
|
|
159
|
-
if (method === 'ui'
|
|
160
|
-
|
|
165
|
+
if (method === 'ui') {
|
|
166
|
+
if (!onboarding) {
|
|
167
|
+
const credentials = await resolveWalletImportCredentials(input);
|
|
168
|
+
const completionTriggered = await dismissVisibleOnboardingCompletion(page);
|
|
169
|
+
const locked = await hasVisibleSelector(page, SELECTORS.unlockPassword);
|
|
170
|
+
if (locked) await unlockProfile(page, credentials, timeoutMs);
|
|
171
|
+
const visibleSession = await ensureVisibleSelectedAccount(page, credentials.expectedAddress, timeoutMs);
|
|
172
|
+
return {
|
|
173
|
+
action: input.action,
|
|
174
|
+
method: 'ui-visible-session',
|
|
175
|
+
credentialSource: credentials.source,
|
|
176
|
+
credentialSourceName: credentials.sourceName,
|
|
177
|
+
completedOnboarding: true,
|
|
178
|
+
completionTriggered,
|
|
179
|
+
...visibleSession,
|
|
180
|
+
proofPath: 'extension-visible-wallet-session',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const imported = await importThroughUi(page, input, timeoutMs);
|
|
184
|
+
return {
|
|
185
|
+
action: input.action,
|
|
186
|
+
method: 'ui-import',
|
|
187
|
+
credentialSource: imported.credentials.source,
|
|
188
|
+
credentialSourceName: imported.credentials.sourceName,
|
|
189
|
+
selectedIdentitySha256: imported.visibleSession.selectedIdentitySha256,
|
|
190
|
+
selectedAddressMatched: imported.visibleSession.selectedAddressMatched,
|
|
191
|
+
selectionPerformed: imported.visibleSession.selectionPerformed,
|
|
192
|
+
completedOnboarding: true,
|
|
193
|
+
metametricsEnabled: imported.metametricsEnabled,
|
|
194
|
+
interests: [],
|
|
195
|
+
redacted: true,
|
|
196
|
+
proofPath: 'extension-visible-onboarding-import',
|
|
197
|
+
};
|
|
161
198
|
}
|
|
199
|
+
|
|
200
|
+
const before = await readState(page);
|
|
162
201
|
if (onboarding) {
|
|
163
202
|
if (method === 'profile') {
|
|
164
203
|
throw new Error('metamask.wallet.import method=profile found a fresh Extension onboarding profile.');
|
|
@@ -169,8 +208,10 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
|
169
208
|
method: 'ui-import',
|
|
170
209
|
credentialSource: imported.credentials.source,
|
|
171
210
|
credentialSourceName: imported.credentials.sourceName,
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
selectedIdentitySha256: imported.visibleSession.selectedIdentitySha256,
|
|
212
|
+
selectedAddressMatched: imported.visibleSession.selectedAddressMatched,
|
|
213
|
+
selectionPerformed: imported.visibleSession.selectionPerformed,
|
|
214
|
+
completedOnboarding: true,
|
|
174
215
|
metametricsEnabled: imported.metametricsEnabled,
|
|
175
216
|
interests: [],
|
|
176
217
|
redacted: true,
|
|
@@ -3,6 +3,7 @@ import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
|
3
3
|
const MENU = dataTestId('account-options-menu-button');
|
|
4
4
|
const LOCK = dataTestId('global-menu-lock');
|
|
5
5
|
const UNLOCK_PAGE = dataTestId('unlock-page');
|
|
6
|
+
const HOME = dataTestId('bottom-nav-home');
|
|
6
7
|
|
|
7
8
|
async function visible(page, selector) {
|
|
8
9
|
return page.evaluate(`(() => {
|
|
@@ -14,9 +15,61 @@ async function visible(page, selector) {
|
|
|
14
15
|
})()`);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
async function clickUncovered(page, selector, deadline) {
|
|
19
|
+
let point;
|
|
20
|
+
while (!point && Date.now() < deadline) {
|
|
21
|
+
point = await page.evaluate(`(() => {
|
|
22
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
23
|
+
if (!element) return null;
|
|
24
|
+
const style = getComputedStyle(element);
|
|
25
|
+
const rect = element.getBoundingClientRect();
|
|
26
|
+
if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
|
|
27
|
+
const offsets = [[0.25, 0.25], [0.25, 0.5], [0.25, 0.75], [0.5, 0.75], [0.75, 0.75]];
|
|
28
|
+
for (const [xOffset, yOffset] of offsets) {
|
|
29
|
+
const x = rect.left + (rect.width * xOffset);
|
|
30
|
+
const y = rect.top + (rect.height * yOffset);
|
|
31
|
+
const hit = document.elementFromPoint(x, y);
|
|
32
|
+
if (hit === element || element.contains(hit)) return { x, y };
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
})()`);
|
|
36
|
+
if (!point) await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
|
|
37
|
+
}
|
|
38
|
+
if (!point) throw new Error(`Wallet control has no uncovered pointer target: ${selector}`);
|
|
39
|
+
await page.session.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
|
|
40
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
41
|
+
type: 'mousePressed',
|
|
42
|
+
x: point.x,
|
|
43
|
+
y: point.y,
|
|
44
|
+
button: 'left',
|
|
45
|
+
buttons: 1,
|
|
46
|
+
clickCount: 1,
|
|
47
|
+
});
|
|
48
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
49
|
+
type: 'mouseReleased',
|
|
50
|
+
x: point.x,
|
|
51
|
+
y: point.y,
|
|
52
|
+
button: 'left',
|
|
53
|
+
buttons: 0,
|
|
54
|
+
clickCount: 1,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function lockWallet(page, timeoutMs = 15_000) {
|
|
59
|
+
const deadline = Date.now() + timeoutMs;
|
|
60
|
+
if (!(await visible(page, LOCK))) {
|
|
61
|
+
if (!(await visible(page, MENU))) {
|
|
62
|
+
if (!(await visible(page, HOME))) {
|
|
63
|
+
throw new Error('Wallet lock requires a visible Home control when the global menu is unavailable.');
|
|
64
|
+
}
|
|
65
|
+
await page.click(HOME);
|
|
66
|
+
while (!(await visible(page, MENU))) {
|
|
67
|
+
if (Date.now() >= deadline) throw new Error('Wallet Home did not expose the account options menu.');
|
|
68
|
+
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await clickUncovered(page, MENU, deadline);
|
|
72
|
+
}
|
|
20
73
|
while (!(await visible(page, LOCK))) {
|
|
21
74
|
if (Date.now() >= deadline) throw new Error('Wallet lock control did not become visible.');
|
|
22
75
|
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
|
|
@@ -27,9 +80,13 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
27
80
|
await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
|
|
28
81
|
}
|
|
29
82
|
return {
|
|
30
|
-
action: input.action,
|
|
31
83
|
locked: true,
|
|
32
84
|
route: await page.evaluate('location.hash'),
|
|
33
85
|
proofPath: 'trusted-pointer-visible-ui',
|
|
34
86
|
};
|
|
35
|
-
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
runAdapter(async (input) => withExtensionPage(input, async (page) => ({
|
|
90
|
+
action: input.action,
|
|
91
|
+
...await lockWallet(page, Number(input.node?.timeout_ms ?? 15_000)),
|
|
92
|
+
})));
|