@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
|
@@ -75,6 +75,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
75
75
|
}
|
|
76
76
|
const requested = input.node.visible;
|
|
77
77
|
const requireUnchanged = input.node?.require_unchanged === true;
|
|
78
|
+
const requireChange = input.node?.require_change === true;
|
|
78
79
|
const timeoutMs = Number(input.node?.timeout_ms ?? 45_000);
|
|
79
80
|
|
|
80
81
|
if (!(await visible(page, MANAGE))) await page.click(OPTIONS);
|
|
@@ -91,6 +92,9 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
91
92
|
if (requireUnchanged && before.enabled !== requested) {
|
|
92
93
|
throw new Error(`${query} token visibility did not persist as ${requested ? 'enabled' : 'disabled'}.`);
|
|
93
94
|
}
|
|
95
|
+
if (requireChange && before.enabled === requested) {
|
|
96
|
+
throw new Error(`${query} token visibility was already ${requested ? 'enabled' : 'disabled'}.`);
|
|
97
|
+
}
|
|
94
98
|
if (before.enabled !== requested) {
|
|
95
99
|
await page.click(dataTestId(before.testId));
|
|
96
100
|
}
|
|
@@ -120,6 +124,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
120
124
|
query,
|
|
121
125
|
requested,
|
|
122
126
|
requireUnchanged,
|
|
127
|
+
requireChange,
|
|
123
128
|
changed: before.enabled !== after.enabled,
|
|
124
129
|
searchResult: after.label,
|
|
125
130
|
toggleTestId: after.testId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
2
|
import {
|
|
3
|
+
assertNetworkAdditionAllowed,
|
|
3
4
|
findNetworkItem,
|
|
4
5
|
openNetworkManager,
|
|
5
6
|
selectHomeNetwork,
|
|
@@ -17,6 +18,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
17
18
|
const query = String(input.node?.query || network).trim();
|
|
18
19
|
const chainId = Number(input.node?.chain_id);
|
|
19
20
|
const symbol = String(input.node?.symbol || '').trim().toUpperCase();
|
|
21
|
+
const requireAbsent = input.node?.require_absent === true;
|
|
20
22
|
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
21
23
|
if (!network || !query || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
|
|
22
24
|
throw new Error('metamask.networks.add_chainlist requires network, query, positive chain_id, and symbol.');
|
|
@@ -25,6 +27,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
25
27
|
const chainlistOpen = await page.evaluate(`Boolean(document.querySelector(${JSON.stringify(CHAINLIST_SEARCH)}))`);
|
|
26
28
|
if (!chainlistOpen) await openNetworkManager(page, timeoutMs);
|
|
27
29
|
let item = chainlistOpen ? null : await findNetworkItem(page, network, chainId);
|
|
30
|
+
assertNetworkAdditionAllowed(item, requireAbsent, network, chainId);
|
|
28
31
|
let added = false;
|
|
29
32
|
if (!item) {
|
|
30
33
|
if (!chainlistOpen) {
|
|
@@ -87,6 +90,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
87
90
|
chainId,
|
|
88
91
|
symbol,
|
|
89
92
|
query,
|
|
93
|
+
requireAbsent,
|
|
90
94
|
added,
|
|
91
95
|
item,
|
|
92
96
|
proofPath: 'trusted-pointer-chainlist-ui',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dataTestId, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
2
|
import {
|
|
3
|
+
assertNetworkAdditionAllowed,
|
|
3
4
|
findNetworkItem,
|
|
4
5
|
openNetworkManager,
|
|
5
6
|
selectHomeNetwork,
|
|
@@ -14,6 +15,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
14
15
|
const chainId = Number(input.node?.chain_id);
|
|
15
16
|
const symbol = String(input.node?.symbol || '').trim();
|
|
16
17
|
const explorerUrl = String(input.node?.explorer_url || '').trim();
|
|
18
|
+
const requireAbsent = input.node?.require_absent === true;
|
|
17
19
|
const timeoutMs = Number(input.node?.timeout_ms ?? 60_000);
|
|
18
20
|
if (!network || !/^https?:\/\//u.test(rpcUrl) || !Number.isInteger(chainId) || chainId <= 0 || !symbol) {
|
|
19
21
|
throw new Error('metamask.networks.add_custom requires network, rpc_url, positive chain_id, and symbol.');
|
|
@@ -21,6 +23,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
21
23
|
|
|
22
24
|
await openNetworkManager(page, timeoutMs);
|
|
23
25
|
let item = await findNetworkItem(page, network, chainId);
|
|
26
|
+
assertNetworkAdditionAllowed(item, requireAbsent, network, chainId);
|
|
24
27
|
let added = false;
|
|
25
28
|
if (!item) {
|
|
26
29
|
await page.click(dataTestId('networks-page-add-custom-network-button'));
|
|
@@ -116,6 +119,7 @@ runAdapter(async (input) => withExtensionPage(input, async (page) => {
|
|
|
116
119
|
symbol,
|
|
117
120
|
rpcUrl,
|
|
118
121
|
explorerUrl: explorerUrl || null,
|
|
122
|
+
requireAbsent,
|
|
119
123
|
added,
|
|
120
124
|
item,
|
|
121
125
|
proofPath: 'trusted-pointer-visible-ui',
|
|
@@ -10,6 +10,46 @@ async function visible(page, selector) {
|
|
|
10
10
|
})()`);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export async function clickUncovered(page, selector, deadline) {
|
|
14
|
+
let point;
|
|
15
|
+
while (!point && Date.now() < deadline) {
|
|
16
|
+
point = await page.evaluate(`(() => {
|
|
17
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
18
|
+
if (!element) return null;
|
|
19
|
+
const style = getComputedStyle(element);
|
|
20
|
+
const rect = element.getBoundingClientRect();
|
|
21
|
+
if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) return null;
|
|
22
|
+
const offsets = [[0.25, 0.25], [0.25, 0.5], [0.25, 0.75], [0.5, 0.75], [0.75, 0.75]];
|
|
23
|
+
for (const [xOffset, yOffset] of offsets) {
|
|
24
|
+
const x = rect.left + (rect.width * xOffset);
|
|
25
|
+
const y = rect.top + (rect.height * yOffset);
|
|
26
|
+
const hit = document.elementFromPoint(x, y);
|
|
27
|
+
if (hit === element || element.contains(hit)) return { x, y };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
})()`);
|
|
31
|
+
if (!point) await page.evaluate('new Promise((resolve) => setTimeout(resolve, 100))');
|
|
32
|
+
}
|
|
33
|
+
if (!point) throw new Error(`Network manager control has no uncovered pointer target: ${selector}`);
|
|
34
|
+
await page.session.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
|
|
35
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
36
|
+
type: 'mousePressed',
|
|
37
|
+
x: point.x,
|
|
38
|
+
y: point.y,
|
|
39
|
+
button: 'left',
|
|
40
|
+
buttons: 1,
|
|
41
|
+
clickCount: 1,
|
|
42
|
+
});
|
|
43
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
44
|
+
type: 'mouseReleased',
|
|
45
|
+
x: point.x,
|
|
46
|
+
y: point.y,
|
|
47
|
+
button: 'left',
|
|
48
|
+
buttons: 0,
|
|
49
|
+
clickCount: 1,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
export async function waitFor(page, predicate, message, timeoutMs) {
|
|
14
54
|
const deadline = Date.now() + timeoutMs;
|
|
15
55
|
while (Date.now() < deadline) {
|
|
@@ -48,7 +88,8 @@ export async function semanticButton(page, label) {
|
|
|
48
88
|
export async function openNetworkManager(page, timeoutMs) {
|
|
49
89
|
const menu = dataTestId('account-options-menu-button');
|
|
50
90
|
const networks = dataTestId('global-menu-networks');
|
|
51
|
-
|
|
91
|
+
const deadline = Date.now() + timeoutMs;
|
|
92
|
+
await clickUncovered(page, menu, deadline);
|
|
52
93
|
await waitFor(page, () => visible(page, networks), 'Networks did not become visible in the account menu.', timeoutMs);
|
|
53
94
|
await page.click(networks);
|
|
54
95
|
await waitFor(
|
|
@@ -80,6 +121,12 @@ export async function findNetworkItem(page, network, chainId) {
|
|
|
80
121
|
})()`);
|
|
81
122
|
}
|
|
82
123
|
|
|
124
|
+
export function assertNetworkAdditionAllowed(item, requireAbsent, network, chainId) {
|
|
125
|
+
if (requireAbsent && item) {
|
|
126
|
+
throw new Error(`${network} (${chainId}) is already configured; refusing to reuse it because cleanup must not remove pre-existing state.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
83
130
|
export async function selectHomeNetwork(page, network, chainId, timeoutMs) {
|
|
84
131
|
const sort = dataTestId('sort-by-networks');
|
|
85
132
|
const chainHex = `0x${chainId.toString(16)}`;
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
|
|
3
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
4
|
+
import {
|
|
5
|
+
openPerpsActivity,
|
|
6
|
+
openPerpsHome,
|
|
7
|
+
openPerpsMarketList,
|
|
8
|
+
} from '../ui/navigate.mjs';
|
|
9
|
+
import { selectActivityFilter } from './select_activity_filter.mjs';
|
|
10
|
+
|
|
11
|
+
const ACTIVITY_FILTERS = ['trades', 'orders', 'funding', 'deposits'];
|
|
12
|
+
const PRICE_SURFACES = new Set(['home', 'market-list', 'market-details', 'order-entry']);
|
|
13
|
+
|
|
14
|
+
function boundedInteger(value, fallback, minimum, maximum, name) {
|
|
15
|
+
const resolved = value == null ? fallback : Number(value);
|
|
16
|
+
if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
17
|
+
throw new Error(`metamask.perps.assert_visible_consistency ${name} must be an integer from ${minimum} to ${maximum}.`);
|
|
18
|
+
}
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveVisibleConsistencyOptions(input) {
|
|
23
|
+
const node = input.node ?? {};
|
|
24
|
+
const mode = String(node.mode ?? '').trim().toLowerCase();
|
|
25
|
+
if (mode !== 'market-stream' && mode !== 'watchlist-stream' && mode !== 'activity-containment' && mode !== 'price-stream') {
|
|
26
|
+
throw new Error(`metamask.perps.assert_visible_consistency received unsupported mode ${JSON.stringify(mode)}.`);
|
|
27
|
+
}
|
|
28
|
+
const surface = node.surface == null ? undefined : String(node.surface).trim().toLowerCase();
|
|
29
|
+
if (mode === 'price-stream' && !PRICE_SURFACES.has(surface)) {
|
|
30
|
+
throw new Error('metamask.perps.assert_visible_consistency price-stream requires a supported surface.');
|
|
31
|
+
}
|
|
32
|
+
const market = node.market == null ? undefined : String(node.market).trim().toUpperCase();
|
|
33
|
+
if (mode === 'price-stream' && (surface === 'home' || surface === 'market-list') && !market) {
|
|
34
|
+
throw new Error(`metamask.perps.assert_visible_consistency price-stream surface ${surface} requires market.`);
|
|
35
|
+
}
|
|
36
|
+
const requireUniqueIds = node.require_unique_ids === true;
|
|
37
|
+
if (mode === 'price-stream' && requireUniqueIds) {
|
|
38
|
+
throw new Error('metamask.perps.assert_visible_consistency require_unique_ids is unavailable for price-stream.');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
mode,
|
|
42
|
+
surface,
|
|
43
|
+
market,
|
|
44
|
+
sampleCount: boundedInteger(node.sample_count, 3, 2, 10, 'sample_count'),
|
|
45
|
+
sampleIntervalMs: boundedInteger(node.sample_interval_ms, 1_000, 100, 30_000, 'sample_interval_ms'),
|
|
46
|
+
minimumChangedCount: boundedInteger(node.minimum_changed_count, 1, 0, 200, 'minimum_changed_count'),
|
|
47
|
+
minimumRecentActivityCount: boundedInteger(
|
|
48
|
+
node.minimum_recent_activity_count,
|
|
49
|
+
1,
|
|
50
|
+
0,
|
|
51
|
+
50,
|
|
52
|
+
'minimum_recent_activity_count',
|
|
53
|
+
),
|
|
54
|
+
timeoutMs: boundedInteger(node.timeout_ms, 30_000, 1_000, 120_000, 'timeout_ms'),
|
|
55
|
+
maxItems: boundedInteger(node.max_items, 100, 1, 200, 'max_items'),
|
|
56
|
+
requireUniqueIds,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertUniqueIds(entries, boundary) {
|
|
61
|
+
const ids = entries.map((entry) => typeof entry === 'string' ? entry : entry.testId);
|
|
62
|
+
const duplicates = [...new Set(ids.filter((id, index) => id && ids.indexOf(id) !== index))];
|
|
63
|
+
if (duplicates.length > 0) {
|
|
64
|
+
throw new Error(`Visible Perps ${boundary} contains duplicate identities: ${duplicates.join(', ')}.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function visiblePrice(page, surface, market) {
|
|
69
|
+
const rowTestId = surface === 'home'
|
|
70
|
+
? `perps-watchlist-${market}`
|
|
71
|
+
: surface === 'market-list'
|
|
72
|
+
? `market-row-${market?.replace(':', '-')}`
|
|
73
|
+
: undefined;
|
|
74
|
+
const rootTestId = surface === 'market-details'
|
|
75
|
+
? 'perps-market-detail-page'
|
|
76
|
+
: surface === 'order-entry'
|
|
77
|
+
? 'perps-order-entry-page'
|
|
78
|
+
: rowTestId;
|
|
79
|
+
const priceTestId = surface === 'market-details'
|
|
80
|
+
? 'perps-market-detail-price'
|
|
81
|
+
: surface === 'order-entry'
|
|
82
|
+
? 'perps-order-entry-price'
|
|
83
|
+
: undefined;
|
|
84
|
+
return page.evaluate(`(() => {
|
|
85
|
+
const visible = (element) => {
|
|
86
|
+
if (!element) return false;
|
|
87
|
+
const style = getComputedStyle(element);
|
|
88
|
+
const rect = element.getBoundingClientRect();
|
|
89
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
90
|
+
};
|
|
91
|
+
const root = document.querySelector('[data-testid=${JSON.stringify(rootTestId)}]');
|
|
92
|
+
const price = ${priceTestId
|
|
93
|
+
? `document.querySelector('[data-testid=${JSON.stringify(priceTestId)}]')`
|
|
94
|
+
: 'root?.lastElementChild?.firstElementChild'};
|
|
95
|
+
const value = String(price?.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 100);
|
|
96
|
+
const numeric = Number(value.replace(/[^0-9.-]/gu, ''));
|
|
97
|
+
return {
|
|
98
|
+
rootVisible: visible(root),
|
|
99
|
+
priceVisible: visible(price),
|
|
100
|
+
value,
|
|
101
|
+
positive: Number.isFinite(numeric) && numeric > 0,
|
|
102
|
+
};
|
|
103
|
+
})()`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function marketRows(page, maxItems) {
|
|
107
|
+
return page.evaluate(`[
|
|
108
|
+
...document.querySelectorAll('[data-testid^="market-row-"]')
|
|
109
|
+
].filter((element) => {
|
|
110
|
+
const testId = element.getAttribute('data-testid') || '';
|
|
111
|
+
if (testId.startsWith('market-row-ticker-') || /-(?:token-logo|asset-label)$/u.test(testId)) return false;
|
|
112
|
+
const style = getComputedStyle(element);
|
|
113
|
+
const rect = element.getBoundingClientRect();
|
|
114
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
115
|
+
}).slice(0, ${JSON.stringify(maxItems)}).map((element) => ({
|
|
116
|
+
testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
|
|
117
|
+
label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
|
|
118
|
+
}))`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function watchlistRows(page, maxItems) {
|
|
122
|
+
return page.evaluate(`[
|
|
123
|
+
...document.querySelectorAll('[data-testid^="perps-watchlist-"]')
|
|
124
|
+
].filter((element) => {
|
|
125
|
+
const style = getComputedStyle(element);
|
|
126
|
+
const rect = element.getBoundingClientRect();
|
|
127
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
128
|
+
}).slice(0, ${JSON.stringify(maxItems)}).map((element) => ({
|
|
129
|
+
testId: String(element.getAttribute('data-testid') || '').slice(0, 500),
|
|
130
|
+
label: String(element.innerText || '').replace(/\\s+/gu, ' ').trim().slice(0, 500),
|
|
131
|
+
}))`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function changedRows(first, last) {
|
|
135
|
+
const initial = new Map(first.map((entry) => [entry.testId, entry.label]));
|
|
136
|
+
return last
|
|
137
|
+
.filter((entry) => initial.has(entry.testId) && initial.get(entry.testId) !== entry.label)
|
|
138
|
+
.map((entry) => entry.testId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function activityIds(page, maxItems) {
|
|
142
|
+
const selector = '[data-testid^="transaction-card-"]';
|
|
143
|
+
return page.evaluate(`[
|
|
144
|
+
...document.querySelectorAll(${JSON.stringify(selector)})
|
|
145
|
+
].filter((element) => {
|
|
146
|
+
const style = getComputedStyle(element);
|
|
147
|
+
const rect = element.getBoundingClientRect();
|
|
148
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
149
|
+
}).slice(0, ${JSON.stringify(maxItems)}).map((element) => String(element.getAttribute('data-testid') || ''))`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function recentActivityState(page, maxItems) {
|
|
153
|
+
return page.evaluate(`(() => {
|
|
154
|
+
const visible = (element) => {
|
|
155
|
+
if (!element) return false;
|
|
156
|
+
const style = getComputedStyle(element);
|
|
157
|
+
const rect = element.getBoundingClientRect();
|
|
158
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
ids: [...document.querySelectorAll('[data-testid^="transaction-card-"]')]
|
|
162
|
+
.filter(visible)
|
|
163
|
+
.slice(0, ${JSON.stringify(maxItems)})
|
|
164
|
+
.map((element) => String(element.getAttribute('data-testid') || '')),
|
|
165
|
+
emptyVisible: visible(document.querySelector('[data-testid="perps-recent-activity-empty"]')),
|
|
166
|
+
};
|
|
167
|
+
})()`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function pollLoaded(read, accept, timeoutMs, pause, failure) {
|
|
171
|
+
const deadline = Date.now() + timeoutMs;
|
|
172
|
+
let observed;
|
|
173
|
+
while (true) {
|
|
174
|
+
observed = await read();
|
|
175
|
+
if (accept(observed)) return observed;
|
|
176
|
+
if (Date.now() >= deadline) throw new Error(failure(observed));
|
|
177
|
+
await pause();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function filteredActivityIds(page, options, selectFilter) {
|
|
182
|
+
const ids = new Set();
|
|
183
|
+
const counts = {};
|
|
184
|
+
for (const filter of ACTIVITY_FILTERS) {
|
|
185
|
+
await selectFilter(page, filter, options.timeoutMs);
|
|
186
|
+
const filteredIds = await activityIds(page, options.maxItems);
|
|
187
|
+
if (options.requireUniqueIds) assertUniqueIds(filteredIds, `${filter} activity`);
|
|
188
|
+
counts[filter] = filteredIds.length;
|
|
189
|
+
for (const testId of filteredIds) ids.add(testId);
|
|
190
|
+
}
|
|
191
|
+
return { ids: [...ids], counts };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function pollContained(page, recentIds, options, pause, selectFilter) {
|
|
195
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
196
|
+
let observed = { ids: [], counts: {} };
|
|
197
|
+
while (true) {
|
|
198
|
+
observed = await filteredActivityIds(page, options, selectFilter);
|
|
199
|
+
if (recentIds.every((testId) => observed.ids.includes(testId))) return observed;
|
|
200
|
+
if (Date.now() >= deadline) {
|
|
201
|
+
throw new Error(`Recent Perps activity is not contained across all activity filters: ${JSON.stringify({ recentIds, ...observed })}.`);
|
|
202
|
+
}
|
|
203
|
+
await pause(options.sampleIntervalMs);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function assertVisibleConsistency(page, input, testOptions = {}) {
|
|
208
|
+
const options = resolveVisibleConsistencyOptions(input);
|
|
209
|
+
const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
210
|
+
if (options.mode === 'price-stream') {
|
|
211
|
+
const first = await pollLoaded(
|
|
212
|
+
() => visiblePrice(page, options.surface, options.market),
|
|
213
|
+
(sample) => sample.rootVisible && sample.priceVisible && sample.positive,
|
|
214
|
+
options.timeoutMs,
|
|
215
|
+
() => pause(options.sampleIntervalMs),
|
|
216
|
+
() => `Visible Perps ${options.surface} price did not finish loading before sampling.`,
|
|
217
|
+
);
|
|
218
|
+
const samples = [first];
|
|
219
|
+
for (let index = 1; index < options.sampleCount; index += 1) {
|
|
220
|
+
await pause(options.sampleIntervalMs);
|
|
221
|
+
samples.push(await visiblePrice(page, options.surface, options.market));
|
|
222
|
+
}
|
|
223
|
+
if (samples.some((sample) => !sample.rootVisible || !sample.priceVisible || !sample.positive)) {
|
|
224
|
+
throw new Error(`Visible Perps ${options.surface} price became unavailable while sampling.`);
|
|
225
|
+
}
|
|
226
|
+
const changedCount = samples.slice(1)
|
|
227
|
+
.filter((sample, index) => sample.value !== samples[index].value)
|
|
228
|
+
.length;
|
|
229
|
+
if (changedCount < options.minimumChangedCount) {
|
|
230
|
+
throw new Error(`Expected at least ${options.minimumChangedCount} changing visible Perps ${options.surface} price samples, but observed ${changedCount}.`);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
action: input.action,
|
|
234
|
+
mode: options.mode,
|
|
235
|
+
surface: options.surface,
|
|
236
|
+
market: options.market ?? null,
|
|
237
|
+
sampleCount: samples.length,
|
|
238
|
+
changedCount,
|
|
239
|
+
samples: samples.map((sample) => sample.value),
|
|
240
|
+
proofPath: 'visible-dom-accessibility',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (options.mode === 'market-stream') {
|
|
244
|
+
const openMarketList = testOptions.openMarketList ?? openPerpsMarketList;
|
|
245
|
+
await openMarketList(page, options.timeoutMs);
|
|
246
|
+
const first = await pollLoaded(
|
|
247
|
+
() => marketRows(page, options.maxItems),
|
|
248
|
+
(rows) => rows.length > 0,
|
|
249
|
+
options.timeoutMs,
|
|
250
|
+
() => pause(options.sampleIntervalMs),
|
|
251
|
+
() => 'Visible Perps market rows did not finish loading before sampling.',
|
|
252
|
+
);
|
|
253
|
+
const samples = [first];
|
|
254
|
+
for (let index = 1; index < options.sampleCount; index += 1) {
|
|
255
|
+
await pause(options.sampleIntervalMs);
|
|
256
|
+
samples.push(await marketRows(page, options.maxItems));
|
|
257
|
+
}
|
|
258
|
+
if (options.requireUniqueIds) {
|
|
259
|
+
samples.forEach((sample, index) => assertUniqueIds(sample, `market sample ${index + 1}`));
|
|
260
|
+
}
|
|
261
|
+
const changed = changedRows(samples[0], samples.at(-1));
|
|
262
|
+
if (changed.length < options.minimumChangedCount) {
|
|
263
|
+
throw new Error(`Expected at least ${options.minimumChangedCount} changing visible Perps market rows, but observed ${changed.length}.`);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
action: input.action,
|
|
267
|
+
mode: options.mode,
|
|
268
|
+
sampleCount: samples.length,
|
|
269
|
+
firstCount: samples[0].length,
|
|
270
|
+
finalCount: samples.at(-1).length,
|
|
271
|
+
changedCount: changed.length,
|
|
272
|
+
changedTestIds: changed,
|
|
273
|
+
uniqueIdsVerified: options.requireUniqueIds,
|
|
274
|
+
proofPath: 'visible-dom-accessibility',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (options.mode === 'watchlist-stream') {
|
|
279
|
+
const openHome = testOptions.openHome ?? openPerpsHome;
|
|
280
|
+
await openHome(page, options.timeoutMs);
|
|
281
|
+
const first = await pollLoaded(
|
|
282
|
+
() => watchlistRows(page, options.maxItems),
|
|
283
|
+
(rows) => rows.length > 0,
|
|
284
|
+
options.timeoutMs,
|
|
285
|
+
() => pause(options.sampleIntervalMs),
|
|
286
|
+
() => 'Visible Perps watchlist did not finish loading before sampling.',
|
|
287
|
+
);
|
|
288
|
+
const samples = [first];
|
|
289
|
+
for (let index = 1; index < options.sampleCount; index += 1) {
|
|
290
|
+
await pause(options.sampleIntervalMs);
|
|
291
|
+
samples.push(await watchlistRows(page, options.maxItems));
|
|
292
|
+
}
|
|
293
|
+
if (options.requireUniqueIds) {
|
|
294
|
+
samples.forEach((sample, index) => assertUniqueIds(sample, `watchlist sample ${index + 1}`));
|
|
295
|
+
}
|
|
296
|
+
const changed = changedRows(samples[0], samples.at(-1));
|
|
297
|
+
if (changed.length < options.minimumChangedCount) {
|
|
298
|
+
throw new Error(`Expected at least ${options.minimumChangedCount} changing visible Perps watchlist rows, but observed ${changed.length}.`);
|
|
299
|
+
}
|
|
300
|
+
const selectedTestId = changed[0];
|
|
301
|
+
const selectedMarket = selectedTestId.slice('perps-watchlist-'.length).toUpperCase();
|
|
302
|
+
const visibleSymbol = selectedMarket.replace(/^XYZ:/u, '');
|
|
303
|
+
await page.click(`[data-testid=${JSON.stringify(selectedTestId)}]`);
|
|
304
|
+
await pollLoaded(
|
|
305
|
+
() => page.evaluate(`(() => {
|
|
306
|
+
const pair = String(document.querySelector('[data-testid="perps-market-detail-pair"]')?.innerText || '')
|
|
307
|
+
.trim()
|
|
308
|
+
.toUpperCase();
|
|
309
|
+
return { pair, route: String(location.hash || '') };
|
|
310
|
+
})()`),
|
|
311
|
+
(state) => state.pair.startsWith(`${visibleSymbol}-`) && /\/perps\/market\//u.test(state.route),
|
|
312
|
+
options.timeoutMs,
|
|
313
|
+
() => pause(100),
|
|
314
|
+
() => `Visible Perps watchlist row ${selectedTestId} did not open its matching details.`,
|
|
315
|
+
);
|
|
316
|
+
return {
|
|
317
|
+
action: input.action,
|
|
318
|
+
mode: options.mode,
|
|
319
|
+
sampleCount: samples.length,
|
|
320
|
+
firstCount: samples[0].length,
|
|
321
|
+
finalCount: samples.at(-1).length,
|
|
322
|
+
changedCount: changed.length,
|
|
323
|
+
changedTestIds: changed,
|
|
324
|
+
selectedMarket,
|
|
325
|
+
selectedTestId,
|
|
326
|
+
matchingDetailsVisible: true,
|
|
327
|
+
uniqueIdsVerified: options.requireUniqueIds,
|
|
328
|
+
proofPath: 'visible-dom-accessibility',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const openHome = testOptions.openHome ?? openPerpsHome;
|
|
333
|
+
const openActivity = testOptions.openActivity ?? openPerpsActivity;
|
|
334
|
+
const selectFilter = testOptions.selectActivityFilter ?? ((currentPage, filter, timeoutMs) => selectActivityFilter(
|
|
335
|
+
currentPage,
|
|
336
|
+
{ action: 'metamask.perps.select_activity_filter', node: { filter } },
|
|
337
|
+
{ timeoutMs },
|
|
338
|
+
));
|
|
339
|
+
await openHome(page, options.timeoutMs);
|
|
340
|
+
const recent = await pollLoaded(
|
|
341
|
+
() => recentActivityState(page, options.maxItems),
|
|
342
|
+
(state) => state.ids.length >= options.minimumRecentActivityCount &&
|
|
343
|
+
(state.ids.length > 0 || state.emptyVisible),
|
|
344
|
+
options.timeoutMs,
|
|
345
|
+
() => pause(options.sampleIntervalMs),
|
|
346
|
+
(state) => `Expected loaded recent Perps activity with at least ${options.minimumRecentActivityCount} visible cards, but observed ${state.ids.length}.`,
|
|
347
|
+
);
|
|
348
|
+
const recentIds = recent.ids;
|
|
349
|
+
if (options.requireUniqueIds) assertUniqueIds(recentIds, 'recent activity');
|
|
350
|
+
await openActivity(page, options.timeoutMs);
|
|
351
|
+
const full = await pollContained(page, recentIds, options, pause, selectFilter);
|
|
352
|
+
return {
|
|
353
|
+
action: input.action,
|
|
354
|
+
mode: options.mode,
|
|
355
|
+
recentCount: recentIds.length,
|
|
356
|
+
fullCount: full.ids.length,
|
|
357
|
+
filterCounts: full.counts,
|
|
358
|
+
containedTestIds: recentIds,
|
|
359
|
+
uniqueIdsVerified: options.requireUniqueIds,
|
|
360
|
+
proofPath: 'visible-dom-accessibility',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
365
|
+
runAdapter((input) => withExtensionPage(input, (page) => assertVisibleConsistency(page, input)));
|
|
366
|
+
}
|