@deeeed/metamask-harness 0.86.2 → 0.88.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 +49 -0
- package/adapters/extension/ensure-browser.sh +6 -10
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +31 -4
- package/adapters/extension/profile-extension-state.cjs +156 -0
- package/adapters/extension/readiness.mjs +21 -2
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/wait-for-bridge.cjs +9 -3
- package/dist/adapters/extension/actions.js +2 -1
- package/dist/adapters/extension/run.js +6 -0
- package/dist/adapters/mobile/action-inputs.js +20 -0
- package/dist/adapters/mobile/actions.js +18 -1
- package/dist/adapters/mobile/android-screenrecord.js +274 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +54 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +58 -15
- package/dist/adapters/mobile/ui.js +96 -2
- package/dist/adapters/perps-mainnet-cleanup.js +122 -0
- package/dist/adapters/perps-order-inputs.js +43 -28
- package/dist/adapters.js +4 -1
- package/dist/commands/doctor.js +33 -14
- package/dist/commands/status-probe.js +2 -1
- package/dist/engine.js +11 -2
- package/dist/funding-execution-context.js +13 -8
- package/dist/host.js +6 -2
- package/dist/index.js +3 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/provider.js +72 -0
- package/dist/recipe-security.js +1 -0
- package/docs/CONTRIBUTING.md +7 -0
- package/docs/RECIPES.md +96 -9
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
- package/docs/cli-contract.md +1 -1
- package/library/actions/core/perps/read_account.mjs +4 -2
- package/library/actions/core/perps/read_orders.mjs +4 -2
- package/library/actions/core/perps/read_positions.mjs +4 -2
- package/library/actions/extension/analytics/consent.mjs +7 -2
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
- package/library/actions/extension/perps/perps.mjs +298 -39
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- package/library/actions/extension/perps/set_network.mjs +6 -0
- package/library/actions/extension/platform/cdp.mjs +6 -2
- package/library/actions/extension/ui/navigate.mjs +8 -0
- package/library/actions/mobile/perps/perps.mjs +264 -102
- package/library/actions/mobile/perps/place_order.mjs +7 -4
- package/library/actions/mobile/platform/bridge.mjs +171 -10
- package/library/actions/mobile/wallet/home.mjs +7 -2
- package/library/actions/mobile/wallet/select_account.mjs +4 -0
- package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
- package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
- package/library/actions/shared/perps/order-policy.mjs +56 -19
- package/library/actions/shared/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
- package/library/manifests/extension.action-manifest.json +36 -5
- package/library/manifests/mobile.action-manifest.json +48 -10
- package/package.json +14 -9
- package/scripts/validate-release-report.mjs +14 -2
- package/site/assets/help-recipes.json +2 -1
|
@@ -31,6 +31,20 @@ import {
|
|
|
31
31
|
marketOrderLot,
|
|
32
32
|
requireHouseNotional,
|
|
33
33
|
} from '../../shared/perps/order-policy.mjs';
|
|
34
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
35
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
36
|
+
import {
|
|
37
|
+
clearMainnetSwitch,
|
|
38
|
+
dropMainnetOrder,
|
|
39
|
+
ledgerOrderTargets,
|
|
40
|
+
markMainnetOrderCancelled,
|
|
41
|
+
ownedMainnetOrder,
|
|
42
|
+
recordMainnetSwitch,
|
|
43
|
+
recordPendingMainnetOrder,
|
|
44
|
+
recordPlacedMainnetOrder,
|
|
45
|
+
requireNoOpenMainnetOrders,
|
|
46
|
+
requirePendingMainnetOrder,
|
|
47
|
+
} from '../../shared/perps/mainnet-order-ledger.mjs';
|
|
34
48
|
import {
|
|
35
49
|
orderIdentityMatches,
|
|
36
50
|
parseOrderCard,
|
|
@@ -238,8 +252,13 @@ export async function closePositions(input) {
|
|
|
238
252
|
}
|
|
239
253
|
|
|
240
254
|
async function closeOrderThroughBackground(input, page, timeoutMs) {
|
|
241
|
-
|
|
255
|
+
// On mainnet the fixture lane cancels only an order this checkout's runs placed.
|
|
256
|
+
const owned = await ownedMainnetOrder(input.context?.projectRoot, input.node?.order_id);
|
|
257
|
+
let network;
|
|
258
|
+
({ input, network } = await withFixturePolicyContext(input, page, {
|
|
242
259
|
operations: [{ operation: 'perps-cancel-order', amount: String(input.node?.amount ?? '') }],
|
|
260
|
+
mainnetAllowed: Boolean(owned),
|
|
261
|
+
mainnetRefusal: 'cancels only orders this run placed',
|
|
243
262
|
}));
|
|
244
263
|
await requirePerpsTradingContext(input, page);
|
|
245
264
|
const symbol = marketSymbol(input);
|
|
@@ -333,6 +352,10 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
|
|
|
333
352
|
throw new Error('Exact background order did not become absent after cancellation.');
|
|
334
353
|
},
|
|
335
354
|
});
|
|
355
|
+
// The run's teardown settles it once the market's position is checked too.
|
|
356
|
+
if (network === 'mainnet' && owned) {
|
|
357
|
+
await markMainnetOrderCancelled(input.context.projectRoot, owned.id, { via: input.action, canceled: [{ orderId, receipt: mutation.receipt }] });
|
|
358
|
+
}
|
|
336
359
|
return {
|
|
337
360
|
action: input.action,
|
|
338
361
|
canceled: true,
|
|
@@ -345,23 +368,36 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
|
|
|
345
368
|
};
|
|
346
369
|
}
|
|
347
370
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
371
|
+
function hyperliquidOrigin(network) {
|
|
372
|
+
return network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function postHyperliquidInfo(network, body, signal = AbortSignal.timeout(10_000)) {
|
|
376
|
+
return fetch(`${hyperliquidOrigin(network)}/info`, {
|
|
377
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
378
|
+
body: JSON.stringify(body), signal,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The Hyperliquid network and user a receipt's order went to, read from the live
|
|
383
|
+
// app and bound to the receipt's selected-account hash.
|
|
384
|
+
async function receiptVenueUser(input, page, identity, purpose) {
|
|
351
385
|
const { provider, network } = await requirePerpsTradingContext(input, page);
|
|
352
|
-
if (provider !== 'hyperliquid') throw new Error(
|
|
386
|
+
if (provider !== 'hyperliquid') throw new Error(`${purpose} requires the original Hyperliquid provider.\nNext: mm-harness last --json`);
|
|
353
387
|
const wallet = await page.evaluate(readWalletStateExpression());
|
|
354
388
|
const user = String(wallet.selectedAccount?.address ?? '').toLowerCase();
|
|
355
389
|
if (!/^0x[a-f0-9]{40}$/u.test(user)
|
|
356
|
-
|| createHash('sha256').update(user).digest('hex') !==
|
|
357
|
-
throw new Error(
|
|
390
|
+
|| createHash('sha256').update(user).digest('hex') !== identity.selectedIdentitySha256) {
|
|
391
|
+
throw new Error(`${purpose} wallet differs from the placement receipt.\nNext: mm-harness last --json`);
|
|
358
392
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
393
|
+
return { network, user };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
|
|
397
|
+
const accepted = placement.record?.acceptance;
|
|
398
|
+
if (!accepted?.venueOrderId) return null;
|
|
399
|
+
const { network, user } = await receiptVenueUser(input, page, placement.record.identity, 'Accepted-order status');
|
|
400
|
+
const response = await postHyperliquidInfo(network, { type: 'orderStatus', user, oid: Number(accepted.venueOrderId) });
|
|
365
401
|
if (!response.ok) throw new Error('Accepted-order status request failed.\nNext: mm-harness last --json');
|
|
366
402
|
const result = await response.json();
|
|
367
403
|
if (result.status === 'unknownOid') throw new Error('Accepted venue order status is unavailable.\nNext: mm-harness last --json');
|
|
@@ -671,7 +707,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
|
|
|
671
707
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
672
708
|
throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
|
|
673
709
|
}
|
|
674
|
-
const price = await page.evaluate(String.raw`(async () => {
|
|
710
|
+
const price = await withRateLimitRetry('perpsGetMarketDataWithPrices', () => page.evaluate(String.raw`(async () => {
|
|
675
711
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
676
712
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
677
713
|
const symbol = ${JSON.stringify(symbol)};
|
|
@@ -709,7 +745,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
|
|
|
709
745
|
const line = body.split('\n').find((text) => /\$[0-9][0-9,]*(?:\.[0-9]+)?/.test(text));
|
|
710
746
|
const fromBody = parsePrice(line);
|
|
711
747
|
return fromBody ? { price: fromBody, source: 'document-body-price-line' } : null;
|
|
712
|
-
})()`);
|
|
748
|
+
})()`), { deadline: page.actionDeadline });
|
|
713
749
|
const numericPrice = Number(price?.price ?? price);
|
|
714
750
|
if (!Number.isFinite(numericPrice) || numericPrice <= 0) throw new Error(`Unable to determine current Perps price for ${symbol}.`);
|
|
715
751
|
return numericPrice;
|
|
@@ -871,6 +907,7 @@ async function dispatchBackgroundPlace(page, {
|
|
|
871
907
|
orderType,
|
|
872
908
|
resolvedPrice,
|
|
873
909
|
symbol,
|
|
910
|
+
timeInForce = 'GTC',
|
|
874
911
|
}) {
|
|
875
912
|
return page.evaluate(`(async () => {
|
|
876
913
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
@@ -887,7 +924,7 @@ async function dispatchBackgroundPlace(page, {
|
|
|
887
924
|
size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
|
|
888
925
|
orderType,
|
|
889
926
|
price: String(limitPrice),
|
|
890
|
-
timeInForce:
|
|
927
|
+
timeInForce: ${JSON.stringify(timeInForce)},
|
|
891
928
|
leverage,
|
|
892
929
|
currentPrice,
|
|
893
930
|
priceAtCalculation: currentPrice,
|
|
@@ -915,7 +952,7 @@ async function dispatchBackgroundPlace(page, {
|
|
|
915
952
|
}
|
|
916
953
|
|
|
917
954
|
async function readOpenOrdersState(page) {
|
|
918
|
-
const state = await page.evaluate(`(async () => {
|
|
955
|
+
const state = await withRateLimitRetry('perpsGetOpenOrders', () => page.evaluate(`(async () => {
|
|
919
956
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
920
957
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
921
958
|
const cached = manager?.orders?.cache;
|
|
@@ -933,13 +970,13 @@ async function readOpenOrdersState(page) {
|
|
|
933
970
|
connected: Boolean(manager?.orders?.isConnected),
|
|
934
971
|
orders: Array.isArray(cached) ? cached : [],
|
|
935
972
|
};
|
|
936
|
-
})()`);
|
|
973
|
+
})()`), { deadline: page.actionDeadline });
|
|
937
974
|
if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
|
|
938
975
|
return state;
|
|
939
976
|
}
|
|
940
977
|
|
|
941
978
|
async function readAccountState(page) {
|
|
942
|
-
const state = await page.evaluate(`(async () => {
|
|
979
|
+
const state = await withRateLimitRetry('perpsGetAccountState', () => page.evaluate(`(async () => {
|
|
943
980
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
944
981
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
945
982
|
const cached = manager?.account?.cache;
|
|
@@ -957,7 +994,7 @@ async function readAccountState(page) {
|
|
|
957
994
|
connected: Boolean(manager?.account?.isConnected),
|
|
958
995
|
account: cached && typeof cached === 'object' ? cached : null,
|
|
959
996
|
};
|
|
960
|
-
})()`);
|
|
997
|
+
})()`), { deadline: page.actionDeadline });
|
|
961
998
|
if (!state.available) throw new Error('Perps account state is unavailable from both background and stream manager cache.');
|
|
962
999
|
return state.account;
|
|
963
1000
|
}
|
|
@@ -1242,9 +1279,56 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
|
|
|
1242
1279
|
throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
|
|
1243
1280
|
}
|
|
1244
1281
|
|
|
1282
|
+
// Fills and open orders up to this long before the first dispatch count as this
|
|
1283
|
+
// order's, so a local clock behind the venue's cannot hide one.
|
|
1284
|
+
const VENUE_CLOCK_SKEW_MS = 30_000;
|
|
1285
|
+
// All three venue reads together; a retry starts only if they fit before its deadline.
|
|
1286
|
+
const VENUE_VERIFY_TIMEOUT_MS = 5_000;
|
|
1287
|
+
|
|
1288
|
+
// After a 429 on a background placement: Hyperliquid's own account state, not the
|
|
1289
|
+
// extension's cache, shows no open order and no fill in the market since the first
|
|
1290
|
+
// dispatch, and for a market order no position on its side (the pre-order baseline
|
|
1291
|
+
// had none). Open orders are read before fills, so an order of ours that fills
|
|
1292
|
+
// between the two reads still shows in one of them. Anything else, or a failed
|
|
1293
|
+
// read, leaves the outcome unproven.
|
|
1294
|
+
async function verifyNothingPlacedOnVenue(input, page, identity, sinceMs) {
|
|
1295
|
+
const coin = identity.market;
|
|
1296
|
+
if (coin.includes(':')) {
|
|
1297
|
+
return { proven: false, reason: `the venue read covers only the main Hyperliquid dex, not ${coin}` };
|
|
1298
|
+
}
|
|
1299
|
+
const { network, user } = await receiptVenueUser(input, page, identity, 'Rate-limit verification');
|
|
1300
|
+
const startTime = sinceMs - VENUE_CLOCK_SKEW_MS;
|
|
1301
|
+
const signal = AbortSignal.timeout(VENUE_VERIFY_TIMEOUT_MS);
|
|
1302
|
+
const read = async (body) => {
|
|
1303
|
+
const response = await postHyperliquidInfo(network, body, signal);
|
|
1304
|
+
if (!response.ok) throw new Error(`Hyperliquid ${body.type} answered HTTP ${response.status}`);
|
|
1305
|
+
return response.json();
|
|
1306
|
+
};
|
|
1307
|
+
const orders = await read({ type: 'openOrders', user });
|
|
1308
|
+
const fills = await read({ type: 'userFillsByTime', user, startTime });
|
|
1309
|
+
const account = await read({ type: 'clearinghouseState', user });
|
|
1310
|
+
if (!Array.isArray(fills) || !Array.isArray(orders) || !Array.isArray(account?.assetPositions)) {
|
|
1311
|
+
return { proven: false, reason: 'the Hyperliquid account reads returned an unexpected shape' };
|
|
1312
|
+
}
|
|
1313
|
+
const size = Number(account.assetPositions.find((entry) => entry?.position?.coin === coin)?.position?.szi ?? 0);
|
|
1314
|
+
if (!Number.isFinite(size)) return { proven: false, reason: `the Hyperliquid ${coin} position size is unreadable` };
|
|
1315
|
+
const facts = {
|
|
1316
|
+
source: 'hyperliquid-info',
|
|
1317
|
+
startTime,
|
|
1318
|
+
openOrders: orders.filter((order) => order?.coin === coin && !(Number(order?.timestamp) < startTime)).length,
|
|
1319
|
+
fills: fills.filter((fill) => fill?.coin === coin && !(Number(fill?.time) < startTime)).length,
|
|
1320
|
+
sameSidePosition: identity.orderType === 'market' && (identity.side === 'long' ? size > 0 : size < 0),
|
|
1321
|
+
};
|
|
1322
|
+
if (facts.openOrders > 0 || facts.fills > 0 || facts.sameSidePosition) {
|
|
1323
|
+
return { proven: false, reason: `Hyperliquid shows ${coin} activity since the first dispatch`, facts };
|
|
1324
|
+
}
|
|
1325
|
+
return { proven: true, facts };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1245
1328
|
async function placeOrderThroughBackground(input, page, {
|
|
1246
1329
|
amount,
|
|
1247
1330
|
bound = null,
|
|
1331
|
+
mainnetLimit = false,
|
|
1248
1332
|
onDispatch = () => {},
|
|
1249
1333
|
expected,
|
|
1250
1334
|
identity,
|
|
@@ -1293,9 +1377,30 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1293
1377
|
// A resume checks the fill against the lot sized at publication, not one
|
|
1294
1378
|
// recomputed at a price that has since moved.
|
|
1295
1379
|
const marketLot = existing?.record?.facts?.marketLot ?? expected.marketLot ?? null;
|
|
1380
|
+
const projectRoot = input.context?.projectRoot;
|
|
1381
|
+
// A mainnet limit order is tracked for the run's forced cancel (mainnet-order-ledger.mjs).
|
|
1382
|
+
const ledgerFields = mainnetLimit ? {
|
|
1383
|
+
platform: 'extension',
|
|
1384
|
+
provider: String(input.node?.provider ?? '').toLowerCase(),
|
|
1385
|
+
symbol,
|
|
1386
|
+
side: expected.side,
|
|
1387
|
+
limitPrice,
|
|
1388
|
+
address: bound.address,
|
|
1389
|
+
baselineOrderIds: [...baselineIds],
|
|
1390
|
+
artifactsDir: input.context.artifactsDir,
|
|
1391
|
+
connection: { cdpPort: page.runtimeIdentity?.cdpPort },
|
|
1392
|
+
} : null;
|
|
1393
|
+
let ledgerId = null;
|
|
1296
1394
|
const mutation = await runGuardedExternalMutation({
|
|
1297
1395
|
input,
|
|
1298
1396
|
identity,
|
|
1397
|
+
rateLimit: {
|
|
1398
|
+
call: 'perpsPlaceOrder',
|
|
1399
|
+
// A retry leaves the postcondition its timeout_ms and 5 s before the runner's process limit.
|
|
1400
|
+
deadline: liveAdapterDeadline(input.node, Number(input.node?.timeout_ms ?? 30_000) + 5_000),
|
|
1401
|
+
verifyBudgetMs: VENUE_VERIFY_TIMEOUT_MS,
|
|
1402
|
+
verifyNotPlaced: ({ sinceMs }) => verifyNothingPlacedOnVenue(input, page, identity, sinceMs),
|
|
1403
|
+
},
|
|
1299
1404
|
// The pre-order balance lets a receipt-bound close reconcile its final balance.
|
|
1300
1405
|
publicationFacts: {
|
|
1301
1406
|
balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
|
|
@@ -1303,19 +1408,27 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1303
1408
|
fundingBalance,
|
|
1304
1409
|
...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
|
|
1305
1410
|
},
|
|
1306
|
-
dispatch: async () => {
|
|
1411
|
+
dispatch: async ({ deadline = null } = {}) => {
|
|
1307
1412
|
try {
|
|
1413
|
+
const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
|
|
1308
1414
|
await requireCurrentVisibleFundingBalance(
|
|
1309
1415
|
input,
|
|
1310
1416
|
page,
|
|
1311
|
-
|
|
1417
|
+
deadline == null ? timeoutMs : Math.max(0, Math.min(timeoutMs, deadline - Date.now())),
|
|
1312
1418
|
fundingBalance.observedBaseUnits,
|
|
1313
1419
|
);
|
|
1314
1420
|
await requirePerpsTradingContext(input, page);
|
|
1315
1421
|
assertTrustedExternalMutationTarget(input, page, identity);
|
|
1422
|
+
// A 429 retry (proven not placed) reuses the pending entry and its pre-order
|
|
1423
|
+
// baseline, unless the run began aborting or its cleanup settled the entry.
|
|
1424
|
+
if (ledgerId) await requirePendingMainnetOrder(projectRoot, ledgerId);
|
|
1425
|
+
if (deadline != null && Date.now() >= deadline) {
|
|
1426
|
+
throw new Error('it reached the action deadline before dispatch');
|
|
1427
|
+
}
|
|
1316
1428
|
} catch (error) {
|
|
1317
1429
|
throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
|
|
1318
1430
|
}
|
|
1431
|
+
if (ledgerFields && !ledgerId) ledgerId = await recordPendingMainnetOrder(projectRoot, ledgerFields);
|
|
1319
1432
|
onDispatch();
|
|
1320
1433
|
const accepted = await dispatchBackgroundPlace(page, {
|
|
1321
1434
|
amount,
|
|
@@ -1327,8 +1440,11 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1327
1440
|
orderType,
|
|
1328
1441
|
resolvedPrice,
|
|
1329
1442
|
symbol,
|
|
1443
|
+
// Post-only on mainnet: the venue rejects a limit that would fill now.
|
|
1444
|
+
timeInForce: mainnetLimit ? 'ALO' : 'GTC',
|
|
1330
1445
|
});
|
|
1331
1446
|
if (accepted?.changed) {
|
|
1447
|
+
if (ledgerId) await dropMainnetOrder(projectRoot, ledgerId);
|
|
1332
1448
|
throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
|
|
1333
1449
|
}
|
|
1334
1450
|
// The venue's filled order ID goes into the receipt, so cleanup can prove the
|
|
@@ -1353,6 +1469,14 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1353
1469
|
receipt?.acceptance,
|
|
1354
1470
|
),
|
|
1355
1471
|
});
|
|
1472
|
+
const placedOrderId = mutation.observation?.order?.id;
|
|
1473
|
+
if (ledgerFields && placedOrderId) {
|
|
1474
|
+
// A resumed placement dispatched in an earlier attempt: track it if that attempt did not.
|
|
1475
|
+
if (ledgerId) await recordPlacedMainnetOrder(projectRoot, ledgerId, placedOrderId);
|
|
1476
|
+
else if (!(await ownedMainnetOrder(projectRoot, placedOrderId))) {
|
|
1477
|
+
await recordPlacedMainnetOrder(projectRoot, await recordPendingMainnetOrder(projectRoot, ledgerFields), placedOrderId);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1356
1480
|
return {
|
|
1357
1481
|
action: input.action,
|
|
1358
1482
|
market: symbol,
|
|
@@ -1567,12 +1691,8 @@ export async function observeHyperliquidSubmission(page, expected) {
|
|
|
1567
1691
|
if (expected.provider !== 'hyperliquid' || !['market', 'limit'].includes(expected.orderType)) {
|
|
1568
1692
|
throw new Error('Exact visible submission observation requires a Hyperliquid market or limit order.');
|
|
1569
1693
|
}
|
|
1570
|
-
const origin = expected.network
|
|
1571
|
-
|
|
1572
|
-
const metadataResponse = await fetch(`${origin}/info`, {
|
|
1573
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1574
|
-
body: JSON.stringify({ type: 'meta' }), signal: AbortSignal.timeout(10_000),
|
|
1575
|
-
});
|
|
1694
|
+
const origin = hyperliquidOrigin(expected.network);
|
|
1695
|
+
const metadataResponse = await postHyperliquidInfo(expected.network, { type: 'meta' });
|
|
1576
1696
|
if (!metadataResponse.ok) throw new Error('Hyperliquid market metadata is unavailable before submission.');
|
|
1577
1697
|
const metadata = await metadataResponse.json();
|
|
1578
1698
|
const asset = metadata.universe?.findIndex((market) => market.name === expected.market);
|
|
@@ -1718,11 +1838,15 @@ export async function placeOrder(input) {
|
|
|
1718
1838
|
// The account and network the order rule allowed, rechecked before any close.
|
|
1719
1839
|
let bound = null;
|
|
1720
1840
|
const place = () => withExtensionPage(input, async (page) => {
|
|
1841
|
+
const hasBackgroundRequest = await page.evaluate(
|
|
1842
|
+
`typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
|
|
1843
|
+
);
|
|
1721
1844
|
const lane = await withFixturePolicyContext(input, page, {
|
|
1722
1845
|
operations: [
|
|
1723
1846
|
{ operation: orderType === 'limit' ? 'perps-limit-order' : 'perps-market-order', amount, order: true },
|
|
1724
1847
|
],
|
|
1725
|
-
order
|
|
1848
|
+
// Only the background placement tracks a limit order for the run's forced cancel.
|
|
1849
|
+
order: { amount, leverage, orderType, closeImmediately, runCancelsLimit: hasBackgroundRequest && !fromCurrentForm },
|
|
1726
1850
|
});
|
|
1727
1851
|
input = lane.input;
|
|
1728
1852
|
if (lane.network) {
|
|
@@ -1788,9 +1912,6 @@ export async function placeOrder(input) {
|
|
|
1788
1912
|
} : {}),
|
|
1789
1913
|
...(fromCurrentForm && orderType === 'limit' ? { maximumNotionalBaseUnits: placementBudget.maxBaseUnits } : {}),
|
|
1790
1914
|
};
|
|
1791
|
-
const hasBackgroundRequest = await page.evaluate(
|
|
1792
|
-
`typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
|
|
1793
|
-
);
|
|
1794
1915
|
if (closeImmediately) {
|
|
1795
1916
|
// The close goes through the background request, so the order must too.
|
|
1796
1917
|
if (!hasBackgroundRequest) throw new Error('close_immediately needs the background Perps request; it is unavailable.');
|
|
@@ -1805,6 +1926,9 @@ export async function placeOrder(input) {
|
|
|
1805
1926
|
}
|
|
1806
1927
|
if (hasBackgroundRequest && !fromCurrentForm) {
|
|
1807
1928
|
await requirePerpsTradingContext(input, page);
|
|
1929
|
+
if (lane.network === 'mainnet' && (input.node?.current_price ?? input.node?.currentPrice) != null) {
|
|
1930
|
+
throw new Error('Mainnet orders are bounded at the live market price; drop current_price.');
|
|
1931
|
+
}
|
|
1808
1932
|
const resolvedPrice = await readCurrentMarketPrice(
|
|
1809
1933
|
page,
|
|
1810
1934
|
symbol,
|
|
@@ -1812,6 +1936,9 @@ export async function placeOrder(input) {
|
|
|
1812
1936
|
);
|
|
1813
1937
|
const szDecimals = lane.network === 'mainnet' || orderType === 'market'
|
|
1814
1938
|
? await readSizeDecimals(page, symbol) : null;
|
|
1939
|
+
const backgroundLimitPrice = orderType === 'limit'
|
|
1940
|
+
? resolveLimitPrice(input, isBuy, resolvedPrice)
|
|
1941
|
+
: null;
|
|
1815
1942
|
houseNotional = requireHouseNotional({
|
|
1816
1943
|
network: lane.network,
|
|
1817
1944
|
symbol,
|
|
@@ -1820,14 +1947,13 @@ export async function placeOrder(input) {
|
|
|
1820
1947
|
price: resolvedPrice,
|
|
1821
1948
|
szDecimals,
|
|
1822
1949
|
maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
|
|
1950
|
+
limit: orderType === 'limit' ? { price: backgroundLimitPrice, isBuy } : null,
|
|
1823
1951
|
});
|
|
1824
1952
|
const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
|
|
1825
|
-
const backgroundLimitPrice = orderType === 'limit'
|
|
1826
|
-
? resolveLimitPrice(input, isBuy, resolvedPrice)
|
|
1827
|
-
: null;
|
|
1828
1953
|
return placeOrderThroughBackground(input, page, {
|
|
1829
1954
|
amount,
|
|
1830
1955
|
bound,
|
|
1956
|
+
mainnetLimit: lane.network === 'mainnet' && orderType === 'limit',
|
|
1831
1957
|
onDispatch: () => {
|
|
1832
1958
|
dispatchMayHaveHappened = true;
|
|
1833
1959
|
},
|
|
@@ -2178,7 +2304,7 @@ export async function placeOrder(input) {
|
|
|
2178
2304
|
// read in one evaluation with the context guard before, between and after the
|
|
2179
2305
|
// reads, so a switch to another account's book and back can't pass as flat.
|
|
2180
2306
|
async function readBoundBook(page, bound) {
|
|
2181
|
-
const book = await page.evaluate(`(async () => {
|
|
2307
|
+
const book = await withRateLimitRetry('perpsGetPositions/perpsGetOpenOrders', () => page.evaluate(`(async () => {
|
|
2182
2308
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
2183
2309
|
if (typeof request !== 'function') return { unavailable: true };
|
|
2184
2310
|
const guard = async () => { ${boundContextGuard(bound)} return null; };
|
|
@@ -2188,7 +2314,7 @@ async function readBoundBook(page, bound) {
|
|
|
2188
2314
|
const orders = await request('perpsGetOpenOrders', []);
|
|
2189
2315
|
if (await guard()) return { changed: true };
|
|
2190
2316
|
return { positions, orders };
|
|
2191
|
-
})()`);
|
|
2317
|
+
})()`), { deadline: page.actionDeadline });
|
|
2192
2318
|
if (book?.changed) {
|
|
2193
2319
|
throw Object.assign(
|
|
2194
2320
|
new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
|
|
@@ -2202,9 +2328,9 @@ async function readBoundBook(page, bound) {
|
|
|
2202
2328
|
}
|
|
2203
2329
|
|
|
2204
2330
|
async function readSizeDecimals(page, symbol) {
|
|
2205
|
-
const markets = await page.evaluate(
|
|
2331
|
+
const markets = await withRateLimitRetry('perpsGetMarkets', () => page.evaluate(
|
|
2206
2332
|
`globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
|
|
2207
|
-
);
|
|
2333
|
+
), { deadline: page.actionDeadline });
|
|
2208
2334
|
const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
|
|
2209
2335
|
return market?.szDecimals ?? null;
|
|
2210
2336
|
}
|
|
@@ -2482,6 +2608,139 @@ async function ensureNetwork(input, config) {
|
|
|
2482
2608
|
return withExtensionPage(input, (page) => assertNetwork(page, config.network));
|
|
2483
2609
|
}
|
|
2484
2610
|
|
|
2611
|
+
// metamask.perps.set_network network=<testnet|mainnet> (default testnet): switch
|
|
2612
|
+
// the Perps network when it differs; the fixture loads testnet. A switch to
|
|
2613
|
+
// mainnet is recorded so the run's teardown switches back to testnet
|
|
2614
|
+
// (mainnet-order-ledger.mjs): mainnet lasts one run.
|
|
2615
|
+
export async function setNetwork(input) {
|
|
2616
|
+
const network = String(input.node?.network ?? 'testnet').toLowerCase();
|
|
2617
|
+
if (network !== 'testnet' && network !== 'mainnet') throw new Error(`${input.action} network must be testnet or mainnet, got ${JSON.stringify(input.node?.network)}.`);
|
|
2618
|
+
await applyStateNavigation(input, { page: 'home' });
|
|
2619
|
+
return withExtensionPage(input, async (page) => {
|
|
2620
|
+
if (network === 'testnet') {
|
|
2621
|
+
const state = await readPerpsRuntimeState(page);
|
|
2622
|
+
if (state.networkKnown && !state.isTestnet) await requireNoOpenMainnetOrders(input.context.projectRoot, input.action);
|
|
2623
|
+
}
|
|
2624
|
+
// Recorded before the toggle, so an abort mid-switch still switches back.
|
|
2625
|
+
if (network === 'mainnet') {
|
|
2626
|
+
await recordMainnetSwitch(input.context.projectRoot, {
|
|
2627
|
+
platform: 'extension',
|
|
2628
|
+
artifactsDir: input.context.artifactsDir,
|
|
2629
|
+
connection: { cdpPort: page.runtimeIdentity?.cdpPort },
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
const switched = await togglePerpsNetwork(page, network, Number(input.node?.timeout_ms ?? 30_000));
|
|
2633
|
+
if (network === 'testnet' && switched.changed) await clearMainnetSwitch(input.context.projectRoot);
|
|
2634
|
+
return { action: input.action, ...switched, proofPath: 'background-perpsToggleTestnet' };
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
async function togglePerpsNetwork(page, network, timeoutMs) {
|
|
2639
|
+
const before = await readPerpsRuntimeState(page);
|
|
2640
|
+
if (!before.available || !before.networkKnown) {
|
|
2641
|
+
throw new Error('Cannot verify the current Perps network.\nNext: mm-harness verify');
|
|
2642
|
+
}
|
|
2643
|
+
if ((before.isTestnet ? 'testnet' : 'mainnet') === network) return { requested: network, isTestnet: before.isTestnet, changed: false };
|
|
2644
|
+
await requestBackground(page, 'perpsToggleTestnet', []);
|
|
2645
|
+
const deadline = Date.now() + timeoutMs;
|
|
2646
|
+
for (;;) {
|
|
2647
|
+
const after = await readPerpsRuntimeState(page);
|
|
2648
|
+
if (after.networkKnown && (after.isTestnet ? 'testnet' : 'mainnet') === network) {
|
|
2649
|
+
return { requested: network, isTestnet: after.isTestnet, changed: true };
|
|
2650
|
+
}
|
|
2651
|
+
if (Date.now() >= deadline) throw new Error(`Perps network did not switch to ${network} within ${timeoutMs} ms of perpsToggleTestnet.`);
|
|
2652
|
+
await sleep(250);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function cleanupInput(projectRoot, artifactsDir, cdpPort) {
|
|
2657
|
+
return {
|
|
2658
|
+
action: 'metamask.perps.close_orders',
|
|
2659
|
+
node: { cdp_port: cdpPort },
|
|
2660
|
+
context: { nodeId: 'mainnet-order-cleanup', projectRoot, artifactsDir },
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// The order book fields the mainnet order ledger matches on.
|
|
2665
|
+
const LEDGER_ORDER_FIELDS = {
|
|
2666
|
+
idOf: (order) => order?.orderId ?? order?.id,
|
|
2667
|
+
symbolOf: symbolForItem,
|
|
2668
|
+
sideOf: sideForItem,
|
|
2669
|
+
priceOf: (order) => order?.price ?? order?.limitPrice ?? order?.limitPx ?? order?.px,
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2672
|
+
// The account's open orders and the market's signed position size, from the
|
|
2673
|
+
// background requests only: a stream cache can be stale or empty after a
|
|
2674
|
+
// reload, which would read as "cancelled".
|
|
2675
|
+
async function readAuthoritativeBook(page, symbol) {
|
|
2676
|
+
const orders = await readOpenOrdersState(page);
|
|
2677
|
+
const positions = await page.readPositions();
|
|
2678
|
+
if (orders.source !== 'background-perpsGetOpenOrders' || positions.source !== 'background-perpsGetPositions') {
|
|
2679
|
+
throw new Error(`cannot read the ${symbol} book from the Extension background (got ${orders.source} and ${positions.source})`);
|
|
2680
|
+
}
|
|
2681
|
+
return {
|
|
2682
|
+
orders: orders.orders,
|
|
2683
|
+
positionSize: positions.positions
|
|
2684
|
+
.filter((position) => symbolForItem(position) === symbol)
|
|
2685
|
+
.reduce((total, position) => total + Number(position.size ?? position.szi ?? 0), 0),
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// The run's forced cancel of one mainnet limit order it opened (see
|
|
2690
|
+
// mainnet-order-ledger.mjs), from the run's process: on the account that placed
|
|
2691
|
+
// it, still on mainnet, cancel it and read the book until it is gone. The read
|
|
2692
|
+
// also carries the market's position, which the caller compares with the one
|
|
2693
|
+
// before the order (a fill is reported, never closed here).
|
|
2694
|
+
export async function cancelMainnetLedgerOrder(entry, { projectRoot }) {
|
|
2695
|
+
const bound = { address: entry.address, network: 'mainnet' };
|
|
2696
|
+
// Only the proof: withExtensionPage adds the page's runtime identity to its result.
|
|
2697
|
+
const { canceled, read } = await withExtensionPage(cleanupInput(projectRoot, entry.artifactsDir, entry.connection?.cdpPort), async (page) => {
|
|
2698
|
+
const guard = await page.evaluate(`(async () => { ${boundContextGuard(bound)} return { changed: false }; })()`, { awaitPromise: true });
|
|
2699
|
+
const runtime = await readPerpsRuntimeState(page);
|
|
2700
|
+
if (guard?.changed || String(runtime.activeProvider ?? '').toLowerCase() !== entry.provider) {
|
|
2701
|
+
throw new Error(`the Extension is not on mainnet ${entry.provider} with the placing account ${entry.address} selected`);
|
|
2702
|
+
}
|
|
2703
|
+
const bookRead = (book, outcome) => ({
|
|
2704
|
+
source: 'background-perpsGetOpenOrders+perpsGetPositions',
|
|
2705
|
+
openOrderIds: book.orders.map((order) => String(LEDGER_ORDER_FIELDS.idOf(order) ?? '')),
|
|
2706
|
+
positionSize: book.positionSize,
|
|
2707
|
+
...outcome,
|
|
2708
|
+
});
|
|
2709
|
+
const before = await readAuthoritativeBook(page, entry.symbol);
|
|
2710
|
+
const targets = ledgerOrderTargets(entry, before.orders, LEDGER_ORDER_FIELDS);
|
|
2711
|
+
if (targets.length === 0) return { canceled: [], read: bookRead(before, { notOpen: true }) };
|
|
2712
|
+
const canceled = [];
|
|
2713
|
+
for (const order of targets) {
|
|
2714
|
+
const orderId = String(LEDGER_ORDER_FIELDS.idOf(order));
|
|
2715
|
+
const result = await page.evaluate(`(async () => {
|
|
2716
|
+
${boundContextGuard(bound)}
|
|
2717
|
+
return globalThis.stateHooks.submitRequestToBackground('perpsCancelOrder', [${JSON.stringify({ orderId, symbol: entry.symbol })}]);
|
|
2718
|
+
})()`, { awaitPromise: true });
|
|
2719
|
+
if (result?.changed) throw new Error(`the Extension left mainnet or account ${entry.address} before the cancel`);
|
|
2720
|
+
if (!result || result.success === false) throw new Error(`perpsCancelOrder ${orderId} failed: ${result?.error ?? JSON.stringify(result)}`);
|
|
2721
|
+
canceled.push({ orderId, result });
|
|
2722
|
+
}
|
|
2723
|
+
const deadline = Date.now() + 30_000;
|
|
2724
|
+
for (;;) {
|
|
2725
|
+
const after = await readAuthoritativeBook(page, entry.symbol);
|
|
2726
|
+
const open = after.orders.map((order) => String(LEDGER_ORDER_FIELDS.idOf(order))).filter((id) => canceled.some((item) => item.orderId === id));
|
|
2727
|
+
if (open.length === 0) return { canceled, read: bookRead(after, { absent: true }) };
|
|
2728
|
+
if (Date.now() >= deadline) throw new Error(`order(s) ${open.join(', ')} still open 30 s after the cancel`);
|
|
2729
|
+
await sleep(500);
|
|
2730
|
+
}
|
|
2731
|
+
});
|
|
2732
|
+
return { canceled, read };
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// The run's teardown: switch Perps back to the fixture's testnet after a
|
|
2736
|
+
// start_state network=mainnet.
|
|
2737
|
+
export async function restoreTestnetAfterRun(networkSwitch, { projectRoot }) {
|
|
2738
|
+
return withExtensionPage(
|
|
2739
|
+
cleanupInput(projectRoot, networkSwitch.artifactsDir, networkSwitch.connection?.cdpPort),
|
|
2740
|
+
(page) => togglePerpsNetwork(page, 'testnet', 30_000),
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2485
2744
|
async function assertReadyToTrade(input, config) {
|
|
2486
2745
|
if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
|
|
2487
2746
|
return withExtensionPage(input, async (page) => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import { normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
6
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
6
7
|
|
|
7
8
|
const ALL_SECTIONS = ['account', 'markets', 'positions', 'orders', 'history', 'watchlist'];
|
|
8
9
|
const DEFAULT_HISTORY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -533,8 +534,11 @@ export async function readSnapshot(input, testOptions = {}) {
|
|
|
533
534
|
const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
534
535
|
const samples = [];
|
|
535
536
|
for (let index = 0; index < options.sampling.count; index += 1) {
|
|
536
|
-
|
|
537
|
-
samples.push(
|
|
537
|
+
// A section a 429 left unavailable fails normalization; the sample reruns.
|
|
538
|
+
samples.push(await withRateLimitRetry('Perps snapshot', async () => normalizeSnapshotResult(
|
|
539
|
+
await page.evaluate(snapshotRequestExpression(options)),
|
|
540
|
+
options,
|
|
541
|
+
), { sleep: pause, deadline: page.actionDeadline }));
|
|
538
542
|
if (index + 1 < options.sampling.count) await pause(options.sampling.intervalMs);
|
|
539
543
|
}
|
|
540
544
|
const sections = samples.at(-1);
|
|
@@ -22,6 +22,8 @@ import path from 'node:path';
|
|
|
22
22
|
|
|
23
23
|
import { captureActiveRecipeRecordingSnapshot, recipeHarnessPath } from '@farmslot/recipe-cli/harness';
|
|
24
24
|
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, extensionIdPath, walletFixturePath } from '../../harness-exports.mjs';
|
|
25
|
+
import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
|
|
26
|
+
import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
25
27
|
|
|
26
28
|
// Resolve the Farmslot harness through normal package dependencies by default.
|
|
27
29
|
// Local Farmslot source is only a dev override handled by the harness paths module.
|
|
@@ -843,6 +845,8 @@ export async function withExtensionPage(input, callback) {
|
|
|
843
845
|
const origin = `chrome-extension://${extensionId}`;
|
|
844
846
|
const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
|
|
845
847
|
const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
|
|
848
|
+
// Rate-limit retries on this page's reads stop before the runner's process limit.
|
|
849
|
+
page.actionDeadline = liveAdapterDeadline(input.node);
|
|
846
850
|
const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
|
|
847
851
|
if (recoverVisibleWallet) {
|
|
848
852
|
try {
|
|
@@ -1030,7 +1034,7 @@ export class ExtensionPage extends CdpWebPage {
|
|
|
1030
1034
|
}
|
|
1031
1035
|
|
|
1032
1036
|
async readPositions() {
|
|
1033
|
-
return this.evaluate(`(async () => {
|
|
1037
|
+
return withRateLimitRetry('perpsGetPositions', () => this.evaluate(`(async () => {
|
|
1034
1038
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
1035
1039
|
const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
|
|
1036
1040
|
const cached = manager?.positions?.cache;
|
|
@@ -1050,7 +1054,7 @@ export class ExtensionPage extends CdpWebPage {
|
|
|
1050
1054
|
connected: Boolean(manager?.positions?.isConnected),
|
|
1051
1055
|
positions: Array.isArray(cached) ? cached : [],
|
|
1052
1056
|
};
|
|
1053
|
-
})()`);
|
|
1057
|
+
})()`), { deadline: this.actionDeadline });
|
|
1054
1058
|
}
|
|
1055
1059
|
|
|
1056
1060
|
async screenshot(contextOrInput, relPath, metadata = {}) {
|
|
@@ -280,6 +280,8 @@ export async function openPerpsHome(page, timeoutMs) {
|
|
|
280
280
|
await pauseForUi(page);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// The sample is capped at 30 and the header and tabs come first in the DOM, so loading
|
|
284
|
+
// markers are listed first; otherwise a Perps view still loading reads as a wrong route.
|
|
283
285
|
const state = await page.evaluate(`({
|
|
284
286
|
href: location.href,
|
|
285
287
|
title: document.title,
|
|
@@ -291,8 +293,14 @@ export async function openPerpsHome(page, timeoutMs) {
|
|
|
291
293
|
})
|
|
292
294
|
.map((element) => element.getAttribute('data-testid'))
|
|
293
295
|
.filter(Boolean)
|
|
296
|
+
.sort((a, b) => Number(b.endsWith('-loading')) - Number(a.endsWith('-loading')))
|
|
294
297
|
.slice(0, 30),
|
|
295
298
|
})`);
|
|
299
|
+
if (await hasVisibleSelector(page, perpsLoading)) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`extension ui.navigate page=perps: Perps home stayed on its loading view (perps-view-loading visible) within the ${timeoutMs}ms budget; observed ${JSON.stringify(state)}.\nNext: check the Perps stream/provider, or raise timeout_ms; a raw hash route does not wait for Perps readiness.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
296
304
|
throw new Error(
|
|
297
305
|
`extension ui.navigate page=perps could not resolve the Perps home through visible UI; observed ${JSON.stringify(state)}. Capture the current UI with mm-harness call ui.screenshot, then use a verified visible control or raw hash/path.`,
|
|
298
306
|
);
|