@deeeed/metamask-harness 0.34.2 → 0.34.3
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 +11 -0
- package/dist/adapters.js +5 -2
- package/dist/recipe-security.js +1 -0
- package/library/actions/core/perps/_controller.mjs +75 -23
- package/library/actions/core/perps/read_account.mjs +6 -12
- package/library/actions/core/perps/read_snapshot.mjs +66 -0
- package/library/actions/extension/performance/_navigation-memory.mjs +372 -0
- package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +18 -0
- package/library/actions/extension/performance/measure_detached_dom.mjs +18 -0
- package/library/actions/extension/performance/measure_navigation_memory.mjs +18 -0
- package/library/actions/mobile/app/network-control.mjs +24 -12
- package/library/manifests/core.action-manifest.json +55 -0
- package/library/manifests/extension.action-manifest.json +100 -0
- package/library/recipes/core/perps/snapshot.recipe.json +25 -0
- package/library/recipes/extension/performance/navigation-memory.recipe.json +175 -0
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +39 -3
- package/site/architecture.html +22 -16
- package/site/assets/progress.mjs +1 -1
- package/site/assets/style.css +113 -1
- package/site/cheatsheet.html +14 -12
- package/site/how-it-works.html +693 -0
- package/site/index.html +70 -640
- package/site/perps.html +7 -6
- package/site/recipes.html +23 -17
- package/site/reviewers.html +7 -6
- package/site/tutorials/index.html +7 -6
- package/site/tutorials/v1.html +12 -11
- package/site/tutorials/v2.html +7 -6
- package/site/tutorials/v3.html +15 -11
- package/site/tutorials/v4.html +6 -5
- package/site/tutorials/v5.html +6 -5
- package/site/tutorials/v6.html +6 -5
- package/site/tutorials/v7.html +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.34.3 - 2026-08-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add a composable headless Core Perps snapshot recipe that calls the public controller APIs with configurable Terminal endpoints.
|
|
10
|
+
- Add route-agnostic Extension actions and a reusable recipe for post-GC navigation slopes, detached-DOM growth, and equal-duration idle-control memory measurement.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserve an already-foregrounded Android app while recipes toggle network state, avoiding an unnecessary launcher intent during recovery validation.
|
|
15
|
+
|
|
5
16
|
## 0.34.2 - 2026-08-11
|
|
6
17
|
|
|
7
18
|
### Added
|
package/dist/adapters.js
CHANGED
|
@@ -45,7 +45,10 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
45
45
|
"metamask.perps.teardown_state",
|
|
46
46
|
"metamask.perps.capture_performance"
|
|
47
47
|
]);
|
|
48
|
-
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
48
|
+
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
49
|
+
"metamask.perps.read_account",
|
|
50
|
+
"metamask.perps.read_snapshot"
|
|
51
|
+
]);
|
|
49
52
|
const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
|
|
50
53
|
"metamask.wallet.setup",
|
|
51
54
|
"metamask.wallet.ensure_unlocked",
|
|
@@ -206,7 +209,7 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
206
209
|
"metamask.perps.prepare_local_snapshot_endpoint"
|
|
207
210
|
] : [],
|
|
208
211
|
// read_account is core-only: only the headless core adapter implements it.
|
|
209
|
-
...platform === "core" ? ["metamask.perps.read_account"] : []
|
|
212
|
+
...platform === "core" ? ["metamask.perps.read_account", "metamask.perps.read_snapshot"] : []
|
|
210
213
|
];
|
|
211
214
|
const bundled = new Set(bundledActions);
|
|
212
215
|
const actions = [.../* @__PURE__ */ new Set([...bundledActions, ...declaredCustomActions])];
|
package/dist/recipe-security.js
CHANGED
|
@@ -9,6 +9,7 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
9
9
|
"metamask.perps.read_positions",
|
|
10
10
|
"metamask.perps.read_orders",
|
|
11
11
|
"metamask.perps.read_account",
|
|
12
|
+
"metamask.perps.read_snapshot",
|
|
12
13
|
"metamask.perps.assert_positions",
|
|
13
14
|
"metamask.perps.assert_orders",
|
|
14
15
|
// Collector reads are host-only; starting capture remains arbitrary-code
|
|
@@ -39,8 +39,9 @@ function messengerEntry(projectRoot) {
|
|
|
39
39
|
// a persistent auto-reconnecting HyperLiquid WebSocket (HyperLiquidClientService
|
|
40
40
|
// .initialize → wsTransport.ready()); these logs proved the order returns over
|
|
41
41
|
// HTTP while that open socket kept the event loop alive — see disconnectAndExit.
|
|
42
|
-
function buildInfrastructure(stubbed) {
|
|
42
|
+
function buildInfrastructure(stubbed, input) {
|
|
43
43
|
const noop = () => undefined;
|
|
44
|
+
const terminalApi = resolveTerminalApi(input);
|
|
44
45
|
return {
|
|
45
46
|
logger: {
|
|
46
47
|
error: (error, meta) =>
|
|
@@ -79,6 +80,7 @@ function buildInfrastructure(stubbed) {
|
|
|
79
80
|
setItem: async () => undefined,
|
|
80
81
|
removeItem: async () => undefined,
|
|
81
82
|
},
|
|
83
|
+
...(terminalApi ? { terminalApi } : {}),
|
|
82
84
|
rewards: { getPerpsDiscountForAccount: async () => null },
|
|
83
85
|
};
|
|
84
86
|
}
|
|
@@ -246,6 +248,25 @@ export function resolveNetwork(input) {
|
|
|
246
248
|
return raw;
|
|
247
249
|
}
|
|
248
250
|
|
|
251
|
+
export function resolveTerminalApi(input) {
|
|
252
|
+
const marketDataUrl = String(
|
|
253
|
+
input.node?.terminal_market_data_url ??
|
|
254
|
+
process.env.CORE_PERPS_TERMINAL_MARKET_DATA_URL ??
|
|
255
|
+
'',
|
|
256
|
+
).trim();
|
|
257
|
+
const globalSnapshotUrl = String(
|
|
258
|
+
input.node?.terminal_global_snapshot_url ??
|
|
259
|
+
process.env.CORE_PERPS_TERMINAL_GLOBAL_SNAPSHOT_URL ??
|
|
260
|
+
'',
|
|
261
|
+
).trim();
|
|
262
|
+
|
|
263
|
+
if (!marketDataUrl && !globalSnapshotUrl) return undefined;
|
|
264
|
+
return {
|
|
265
|
+
...(marketDataUrl ? { marketDataUrl } : {}),
|
|
266
|
+
...(globalSnapshotUrl ? { globalSnapshotUrl } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
249
270
|
export function requireRuntimeExport(moduleNamespace, exportName, sourceLabel) {
|
|
250
271
|
const value = moduleNamespace?.[exportName] ?? moduleNamespace?.default?.[exportName];
|
|
251
272
|
if (value === undefined) {
|
|
@@ -264,8 +285,16 @@ export async function getCoreController(input) {
|
|
|
264
285
|
if (!projectRoot) throw new Error('core adapter requires context.projectRoot.');
|
|
265
286
|
const accountAddress = await requireAccountAddress(input);
|
|
266
287
|
const network = resolveNetwork(input);
|
|
288
|
+
const terminalApi = resolveTerminalApi(input);
|
|
289
|
+
const terminalApiKey = JSON.stringify(terminalApi ?? null);
|
|
267
290
|
|
|
268
|
-
if (
|
|
291
|
+
if (
|
|
292
|
+
cached &&
|
|
293
|
+
cached.projectRoot === projectRoot &&
|
|
294
|
+
cached.network === network &&
|
|
295
|
+
cached.accountAddress.toLowerCase() === accountAddress.toLowerCase() &&
|
|
296
|
+
cached.terminalApiKey === terminalApiKey
|
|
297
|
+
) {
|
|
269
298
|
return { ...cached, accountAddress };
|
|
270
299
|
}
|
|
271
300
|
|
|
@@ -290,7 +319,7 @@ export async function getCoreController(input) {
|
|
|
290
319
|
);
|
|
291
320
|
|
|
292
321
|
const stubbed = new Set();
|
|
293
|
-
const infrastructure = buildInfrastructure(stubbed);
|
|
322
|
+
const infrastructure = buildInfrastructure(stubbed, input);
|
|
294
323
|
|
|
295
324
|
// Root + child messenger pair, mirroring the real app wiring (and the core
|
|
296
325
|
// repo's own test harness in
|
|
@@ -305,6 +334,7 @@ export async function getCoreController(input) {
|
|
|
305
334
|
namespace: 'PerpsController',
|
|
306
335
|
parent: rootMessenger,
|
|
307
336
|
});
|
|
337
|
+
registerSelectedAccountHandler(rootMessenger, messenger, accountAddress);
|
|
308
338
|
|
|
309
339
|
// Default testnet. Mainnet only on explicit node.network: "mainnet" — mainnet
|
|
310
340
|
// reads are safe; mainnet mutations use real funds (gated in getCoreControllerWithSigner).
|
|
@@ -339,7 +369,15 @@ export async function getCoreController(input) {
|
|
|
339
369
|
);
|
|
340
370
|
}
|
|
341
371
|
}
|
|
342
|
-
cached = {
|
|
372
|
+
cached = {
|
|
373
|
+
controller,
|
|
374
|
+
messenger,
|
|
375
|
+
rootMessenger,
|
|
376
|
+
projectRoot,
|
|
377
|
+
network,
|
|
378
|
+
accountAddress,
|
|
379
|
+
terminalApiKey,
|
|
380
|
+
};
|
|
343
381
|
return { ...cached, accountAddress };
|
|
344
382
|
}
|
|
345
383
|
|
|
@@ -366,11 +404,30 @@ export async function getCoreController(input) {
|
|
|
366
404
|
// getSelectedEvmAccountFromMessenger). These get registered on the root and
|
|
367
405
|
// delegated into the PerpsController child messenger.
|
|
368
406
|
const SIGNER_ACTIONS = [
|
|
369
|
-
'AccountsController:getSelectedAccount',
|
|
370
407
|
'KeyringController:getState',
|
|
371
408
|
'KeyringController:signTypedMessage',
|
|
372
409
|
];
|
|
373
410
|
|
|
411
|
+
function registerSelectedAccountHandler(
|
|
412
|
+
rootMessenger,
|
|
413
|
+
childMessenger,
|
|
414
|
+
address,
|
|
415
|
+
) {
|
|
416
|
+
rootMessenger.registerActionHandler(
|
|
417
|
+
'AccountsController:getSelectedAccount',
|
|
418
|
+
() => ({
|
|
419
|
+
id: 'core-headless-account',
|
|
420
|
+
address,
|
|
421
|
+
type: 'eip155:eoa',
|
|
422
|
+
metadata: { keyring: { type: 'HD Key Tree' } },
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
rootMessenger.delegate({
|
|
426
|
+
actions: ['AccountsController:getSelectedAccount'],
|
|
427
|
+
messenger: childMessenger,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
374
431
|
/**
|
|
375
432
|
* Register the signer-backed external handlers on the root messenger and
|
|
376
433
|
* delegate them into the PerpsController child, mirroring the real app's
|
|
@@ -379,26 +436,10 @@ const SIGNER_ACTIONS = [
|
|
|
379
436
|
* @param rootMessenger - The permissive (MOCK_ANY_NAMESPACE) root messenger.
|
|
380
437
|
* @param childMessenger - The PerpsController-namespaced messenger.
|
|
381
438
|
* @param account - The viem signer account.
|
|
382
|
-
* @param address - The selected EVM account address (0x).
|
|
383
439
|
*/
|
|
384
|
-
function registerSignerHandlers(rootMessenger, childMessenger, account
|
|
440
|
+
function registerSignerHandlers(rootMessenger, childMessenger, account) {
|
|
385
441
|
if (rootMessenger.__coreSignerRegistered) return;
|
|
386
442
|
|
|
387
|
-
// AccountsController:getSelectedAccount — getSelectedEvmAccountFromMessenger()
|
|
388
|
-
// calls this first and uses it when the returned object looks like an account
|
|
389
|
-
// with an EVM `type`. Minimal InternalAccount shape: address + EVM type, plus
|
|
390
|
-
// metadata.keyring.type so isSelectedHardwareWallet() sees a software (non-
|
|
391
|
-
// hardware) keyring and allows user signing.
|
|
392
|
-
rootMessenger.registerActionHandler(
|
|
393
|
-
'AccountsController:getSelectedAccount',
|
|
394
|
-
() => ({
|
|
395
|
-
id: 'core-headless-signer',
|
|
396
|
-
address,
|
|
397
|
-
type: 'eip155:eoa',
|
|
398
|
-
metadata: { keyring: { type: 'HD Key Tree' } },
|
|
399
|
-
}),
|
|
400
|
-
);
|
|
401
|
-
|
|
402
443
|
// KeyringController:getState — isKeyringUnlocked() reads `.isUnlocked`. The
|
|
403
444
|
// headless keyring is always unlocked (we hold the private key).
|
|
404
445
|
rootMessenger.registerActionHandler('KeyringController:getState', () => ({
|
|
@@ -479,7 +520,7 @@ export async function getCoreControllerWithSigner(input) {
|
|
|
479
520
|
}
|
|
480
521
|
|
|
481
522
|
const { account, address: signerAddress } = await resolveSignerFromFixture(input);
|
|
482
|
-
registerSignerHandlers(rootMessenger, childMessenger, account
|
|
523
|
+
registerSignerHandlers(rootMessenger, childMessenger, account);
|
|
483
524
|
|
|
484
525
|
// Bring up the active provider. placeOrder/closePosition call
|
|
485
526
|
// getActiveProvider(), which throws CLIENT_NOT_INITIALIZED until init()
|
|
@@ -618,6 +659,17 @@ export function redactPosition(position) {
|
|
|
618
659
|
};
|
|
619
660
|
}
|
|
620
661
|
|
|
662
|
+
export function redactAccount(account) {
|
|
663
|
+
return {
|
|
664
|
+
totalBalance: account?.totalBalance ?? null,
|
|
665
|
+
spendableBalance: account?.spendableBalance ?? null,
|
|
666
|
+
withdrawableBalance: account?.withdrawableBalance ?? null,
|
|
667
|
+
marginUsed: account?.marginUsed ?? null,
|
|
668
|
+
unrealizedPnl: account?.unrealizedPnl ?? null,
|
|
669
|
+
returnOnEquity: account?.returnOnEquity ?? null,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
621
673
|
/**
|
|
622
674
|
* Wrap a controller rejection so its typed code survives as data.
|
|
623
675
|
*
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
withdrawableBalance: account?.withdrawableBalance ?? null,
|
|
8
|
-
marginUsed: account?.marginUsed ?? null,
|
|
9
|
-
unrealizedPnl: account?.unrealizedPnl ?? null,
|
|
10
|
-
returnOnEquity: account?.returnOnEquity ?? null,
|
|
11
|
-
};
|
|
12
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
getCoreController,
|
|
3
|
+
isDirectRun,
|
|
4
|
+
redactAccount,
|
|
5
|
+
runAdapter,
|
|
6
|
+
} from './_controller.mjs';
|
|
13
7
|
|
|
14
8
|
export async function readAccount(input) {
|
|
15
9
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCoreController,
|
|
3
|
+
isDirectRun,
|
|
4
|
+
redactAccount,
|
|
5
|
+
redactOrder,
|
|
6
|
+
redactPosition,
|
|
7
|
+
runAdapter,
|
|
8
|
+
} from './_controller.mjs';
|
|
9
|
+
|
|
10
|
+
export async function readSnapshotFromController(controller, scope) {
|
|
11
|
+
const output = {};
|
|
12
|
+
|
|
13
|
+
if (scope === 'markets' || scope === 'all') {
|
|
14
|
+
const markets = await controller.getMarketDataWithPrices({
|
|
15
|
+
standalone: true,
|
|
16
|
+
});
|
|
17
|
+
output.markets = {
|
|
18
|
+
count: markets.length,
|
|
19
|
+
dataSources: Array.from(
|
|
20
|
+
new Set(markets.map((market) => market.dataSource ?? 'provider')),
|
|
21
|
+
),
|
|
22
|
+
marketsWithTrend: markets.filter(
|
|
23
|
+
(market) => Array.isArray(market.trend) && market.trend.length > 0,
|
|
24
|
+
).length,
|
|
25
|
+
sampleSymbols: markets.slice(0, 10).map((market) => market.symbol),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (scope === 'account' || scope === 'all') {
|
|
30
|
+
const snapshot = await controller.getUserDataSnapshot();
|
|
31
|
+
output.user = {
|
|
32
|
+
identity: snapshot.identity,
|
|
33
|
+
positions: snapshot.positions.map(redactPosition),
|
|
34
|
+
orders: snapshot.orders.map(redactOrder),
|
|
35
|
+
accountState: redactAccount(snapshot.accountState),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return output;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readSnapshot(input) {
|
|
43
|
+
const scope = String(input.node?.scope ?? 'all');
|
|
44
|
+
if (!['markets', 'account', 'all'].includes(scope)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`metamask.perps.read_snapshot scope must be markets, account, or all; got ${scope}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { controller, accountAddress, network } =
|
|
51
|
+
await getCoreController(input);
|
|
52
|
+
const snapshot = await readSnapshotFromController(controller, scope);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
action: input.action,
|
|
56
|
+
source: 'perps-controller-public-api',
|
|
57
|
+
network,
|
|
58
|
+
account: accountAddress,
|
|
59
|
+
scope,
|
|
60
|
+
...snapshot,
|
|
61
|
+
proofPath:
|
|
62
|
+
'PerpsController.getMarketDataWithPrices/getUserDataSnapshot',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isDirectRun(import.meta.url)) runAdapter(readSnapshot);
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
const DEFAULT_SCREEN_TIMEOUT_MS = 15_000;
|
|
2
|
+
const DEFAULT_SETTLE_MS = 1_500;
|
|
3
|
+
const DEFAULT_GC_SETTLE_MS = 300;
|
|
4
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 400_000;
|
|
5
|
+
const DEFAULT_ROUND_DURATION_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
function finiteNumber(value, fallback, label) {
|
|
8
|
+
const parsed = value ?? fallback;
|
|
9
|
+
if (!Number.isFinite(parsed)) throw new Error(`${label} must be a finite number.`);
|
|
10
|
+
return parsed;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function positiveInteger(value, fallback, label, minimum = 1) {
|
|
14
|
+
const parsed = finiteNumber(value, fallback, label);
|
|
15
|
+
if (!Number.isInteger(parsed) || parsed < minimum) {
|
|
16
|
+
throw new Error(`${label} must be an integer >= ${minimum}.`);
|
|
17
|
+
}
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requiredText(value, label) {
|
|
22
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
23
|
+
throw new Error(`${label} is required.`);
|
|
24
|
+
}
|
|
25
|
+
return value.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function optionalHash(value, fallback) {
|
|
29
|
+
const hash = typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
30
|
+
return hash.startsWith('#') ? hash : `#${hash}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function leastSquaresSlope(values) {
|
|
34
|
+
if (!Array.isArray(values) || values.length < 2 || values.some((value) => !Number.isFinite(value))) {
|
|
35
|
+
throw new Error('leastSquaresSlope requires at least two finite samples.');
|
|
36
|
+
}
|
|
37
|
+
const meanX = (values.length - 1) / 2;
|
|
38
|
+
const meanY = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
39
|
+
let numerator = 0;
|
|
40
|
+
let denominator = 0;
|
|
41
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
42
|
+
numerator += (index - meanX) * (values[index] - meanY);
|
|
43
|
+
denominator += (index - meanX) ** 2;
|
|
44
|
+
}
|
|
45
|
+
return numerator / denominator;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assessNavigationSlope(slopes, bounds) {
|
|
49
|
+
return {
|
|
50
|
+
nodes: slopes.nodes <= bounds.maxNodesPerCycle,
|
|
51
|
+
listeners: slopes.listeners <= bounds.maxListenersPerCycle,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function countDetachedNodes(snapshot) {
|
|
56
|
+
const fields = snapshot?.snapshot?.meta?.node_fields;
|
|
57
|
+
const nodes = snapshot?.nodes;
|
|
58
|
+
if (!Array.isArray(fields) || !Array.isArray(nodes)) {
|
|
59
|
+
throw new Error('Heap snapshot is missing node metadata.');
|
|
60
|
+
}
|
|
61
|
+
const detachedIndex = fields.indexOf('detachedness');
|
|
62
|
+
if (detachedIndex < 0) throw new Error('Heap snapshot has no detachedness field.');
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (let index = 0; index < nodes.length; index += fields.length) {
|
|
65
|
+
if (nodes[index + detachedIndex] === 2) count += 1;
|
|
66
|
+
}
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function navigationMemoryOptions(node, countField = 'cycles', defaultCount = 8) {
|
|
71
|
+
const screenHash = optionalHash(node.screen_hash ?? node.screenHash, '');
|
|
72
|
+
if (screenHash === '#') throw new Error('screen_hash is required.');
|
|
73
|
+
const screenTestId = requiredText(node.screen_test_id ?? node.screenTestId, 'screen_test_id');
|
|
74
|
+
return {
|
|
75
|
+
screenHash,
|
|
76
|
+
screenTestId,
|
|
77
|
+
returnHash: optionalHash(node.return_hash ?? node.returnHash, '#/'),
|
|
78
|
+
count: positiveInteger(node[countField], defaultCount, countField, 2),
|
|
79
|
+
settleMs: positiveInteger(node.settle_ms ?? node.settleMs, DEFAULT_SETTLE_MS, 'settle_ms', 0),
|
|
80
|
+
gcSettleMs: positiveInteger(node.gc_settle_ms ?? node.gcSettleMs, DEFAULT_GC_SETTLE_MS, 'gc_settle_ms', 0),
|
|
81
|
+
screenTimeoutMs: positiveInteger(
|
|
82
|
+
node.screen_timeout_ms ?? node.screenTimeoutMs,
|
|
83
|
+
DEFAULT_SCREEN_TIMEOUT_MS,
|
|
84
|
+
'screen_timeout_ms',
|
|
85
|
+
),
|
|
86
|
+
timeoutMs: positiveInteger(node.timeout_ms ?? node.timeoutMs, DEFAULT_ACTION_TIMEOUT_MS, 'timeout_ms'),
|
|
87
|
+
roundDurationMs: positiveInteger(
|
|
88
|
+
node.round_duration_ms ?? node.roundDurationMs,
|
|
89
|
+
DEFAULT_ROUND_DURATION_MS,
|
|
90
|
+
'round_duration_ms',
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function deadlineIn(timeoutMs) {
|
|
96
|
+
return Date.now() + timeoutMs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function remaining(deadline, label) {
|
|
100
|
+
const value = deadline - Date.now();
|
|
101
|
+
if (value <= 0) throw new Error(`${label} timed out.`);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function boundedSleep(ms, deadline, label) {
|
|
106
|
+
if (ms === 0) return;
|
|
107
|
+
const available = remaining(deadline, label);
|
|
108
|
+
if (ms > available) throw new Error(`${label} timed out.`);
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function navigateAndSettle(page, hash, testId, options, deadline, label) {
|
|
113
|
+
await page.navigateHash(hash, Math.min(options.screenTimeoutMs, remaining(deadline, label)));
|
|
114
|
+
if (testId) {
|
|
115
|
+
await page.waitForSelector(`[data-testid=${JSON.stringify(testId)}]`, {
|
|
116
|
+
timeoutMs: Math.min(options.screenTimeoutMs, remaining(deadline, label)),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
await boundedSleep(options.settleMs, deadline, label);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function forceGarbageCollection(session, settleMs, deadline) {
|
|
123
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
124
|
+
await session.call('HeapProfiler.collectGarbage', {}, {
|
|
125
|
+
timeoutMs: remaining(deadline, 'Forced garbage collection'),
|
|
126
|
+
});
|
|
127
|
+
await boundedSleep(settleMs, deadline, 'Forced garbage collection');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readPerformanceMetrics(session, deadline) {
|
|
132
|
+
const result = await session.call('Performance.getMetrics', {}, {
|
|
133
|
+
timeoutMs: remaining(deadline, 'Performance metric sampling'),
|
|
134
|
+
});
|
|
135
|
+
const metrics = Object.fromEntries((result?.metrics ?? []).map(({ name, value }) => [name, value]));
|
|
136
|
+
for (const name of ['Nodes', 'JSEventListeners', 'JSHeapUsedSize']) {
|
|
137
|
+
if (!Number.isFinite(metrics[name])) throw new Error(`CDP metric ${name} is unavailable.`);
|
|
138
|
+
}
|
|
139
|
+
return metrics;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function enableMemoryDomains(session, deadline, includePerformance) {
|
|
143
|
+
if (includePerformance) {
|
|
144
|
+
await session.call('Performance.enable', {}, {
|
|
145
|
+
timeoutMs: remaining(deadline, 'Performance domain setup'),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
await session.call('HeapProfiler.enable', {}, {
|
|
149
|
+
timeoutMs: remaining(deadline, 'Heap profiler setup'),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function samplePostGcMetrics(page, options, deadline, index, navigate, roundDurationMs) {
|
|
154
|
+
const startedAt = Date.now();
|
|
155
|
+
if (navigate) {
|
|
156
|
+
await navigateAndSettle(page, options.returnHash, null, options, deadline, `Navigation cycle ${index}`);
|
|
157
|
+
await navigateAndSettle(
|
|
158
|
+
page,
|
|
159
|
+
options.screenHash,
|
|
160
|
+
options.screenTestId,
|
|
161
|
+
options,
|
|
162
|
+
deadline,
|
|
163
|
+
`Navigation cycle ${index}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (roundDurationMs !== undefined) {
|
|
167
|
+
const waitMs = roundDurationMs - (Date.now() - startedAt);
|
|
168
|
+
if (waitMs < 0) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${navigate ? 'Navigation' : 'Idle'} round ${index} exceeded round_duration_ms=${roundDurationMs}.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
await boundedSleep(waitMs, deadline, `${navigate ? 'Navigation' : 'Idle'} sample ${index}`);
|
|
174
|
+
}
|
|
175
|
+
await forceGarbageCollection(page.session, options.gcSettleMs, deadline);
|
|
176
|
+
const metrics = await readPerformanceMetrics(page.session, deadline);
|
|
177
|
+
return {
|
|
178
|
+
index,
|
|
179
|
+
nodes: metrics.Nodes,
|
|
180
|
+
listeners: metrics.JSEventListeners,
|
|
181
|
+
heapBytes: metrics.JSHeapUsedSize,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sampleSlopes(samples) {
|
|
186
|
+
return {
|
|
187
|
+
nodes: leastSquaresSlope(samples.map(({ nodes }) => nodes)),
|
|
188
|
+
listeners: leastSquaresSlope(samples.map(({ listeners }) => listeners)),
|
|
189
|
+
heapBytes: leastSquaresSlope(samples.map(({ heapBytes }) => heapBytes)),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function measureNavigationSlope(page, options, bounds) {
|
|
194
|
+
const deadline = deadlineIn(options.timeoutMs);
|
|
195
|
+
await enableMemoryDomains(page.session, deadline, true);
|
|
196
|
+
await navigateAndSettle(
|
|
197
|
+
page,
|
|
198
|
+
options.screenHash,
|
|
199
|
+
options.screenTestId,
|
|
200
|
+
options,
|
|
201
|
+
deadline,
|
|
202
|
+
'Initial screen navigation',
|
|
203
|
+
);
|
|
204
|
+
const samples = [];
|
|
205
|
+
for (let cycle = 1; cycle <= options.count; cycle += 1) {
|
|
206
|
+
samples.push(await samplePostGcMetrics(page, options, deadline, cycle, true));
|
|
207
|
+
}
|
|
208
|
+
const slopes = sampleSlopes(samples);
|
|
209
|
+
const passed = assessNavigationSlope(slopes, bounds);
|
|
210
|
+
const result = {
|
|
211
|
+
measurement: 'post-gc-navigation-slope',
|
|
212
|
+
screen: { hash: options.screenHash, testId: options.screenTestId },
|
|
213
|
+
cycles: options.count,
|
|
214
|
+
samples,
|
|
215
|
+
slopes,
|
|
216
|
+
bounds,
|
|
217
|
+
passed,
|
|
218
|
+
};
|
|
219
|
+
if (!passed.nodes || !passed.listeners) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Navigation memory bounds exceeded: nodes ${slopes.nodes.toFixed(2)}/${bounds.maxNodesPerCycle} per cycle; listeners ${slopes.listeners.toFixed(2)}/${bounds.maxListenersPerCycle} per cycle.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function takeHeapSnapshot(session, gcSettleMs, deadline) {
|
|
228
|
+
const chunks = [];
|
|
229
|
+
const unsubscribe = session.on('HeapProfiler.addHeapSnapshotChunk', (params) => {
|
|
230
|
+
if (typeof params?.chunk === 'string') chunks.push(params.chunk);
|
|
231
|
+
});
|
|
232
|
+
try {
|
|
233
|
+
await forceGarbageCollection(session, gcSettleMs, deadline);
|
|
234
|
+
await session.call(
|
|
235
|
+
'HeapProfiler.takeHeapSnapshot',
|
|
236
|
+
{ reportProgress: false, captureNumericValue: false },
|
|
237
|
+
{ timeoutMs: remaining(deadline, 'Heap snapshot') },
|
|
238
|
+
);
|
|
239
|
+
} finally {
|
|
240
|
+
unsubscribe();
|
|
241
|
+
}
|
|
242
|
+
if (chunks.length === 0) throw new Error('Heap snapshot produced no chunks.');
|
|
243
|
+
return JSON.parse(chunks.join(''));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function measureDetachedDom(page, options, maxDetachedGrowth) {
|
|
247
|
+
const deadline = deadlineIn(options.timeoutMs);
|
|
248
|
+
await enableMemoryDomains(page.session, deadline, false);
|
|
249
|
+
await navigateAndSettle(
|
|
250
|
+
page,
|
|
251
|
+
options.screenHash,
|
|
252
|
+
options.screenTestId,
|
|
253
|
+
options,
|
|
254
|
+
deadline,
|
|
255
|
+
'Initial screen navigation',
|
|
256
|
+
);
|
|
257
|
+
const before = countDetachedNodes(
|
|
258
|
+
await takeHeapSnapshot(page.session, options.gcSettleMs, deadline),
|
|
259
|
+
);
|
|
260
|
+
for (let cycle = 1; cycle <= options.count; cycle += 1) {
|
|
261
|
+
await navigateAndSettle(page, options.returnHash, null, options, deadline, `Navigation cycle ${cycle}`);
|
|
262
|
+
await navigateAndSettle(
|
|
263
|
+
page,
|
|
264
|
+
options.screenHash,
|
|
265
|
+
options.screenTestId,
|
|
266
|
+
options,
|
|
267
|
+
deadline,
|
|
268
|
+
`Navigation cycle ${cycle}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const after = countDetachedNodes(
|
|
272
|
+
await takeHeapSnapshot(page.session, options.gcSettleMs, deadline),
|
|
273
|
+
);
|
|
274
|
+
const result = {
|
|
275
|
+
measurement: 'post-gc-detached-dom',
|
|
276
|
+
screen: { hash: options.screenHash, testId: options.screenTestId },
|
|
277
|
+
cycles: options.count,
|
|
278
|
+
before,
|
|
279
|
+
after,
|
|
280
|
+
growth: after - before,
|
|
281
|
+
bound: { maxDetachedGrowth },
|
|
282
|
+
passed: after - before <= maxDetachedGrowth,
|
|
283
|
+
};
|
|
284
|
+
if (!result.passed) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Detached DOM bound exceeded: growth ${result.growth}/${maxDetachedGrowth} nodes.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function measureArm(page, options, deadline, navigate) {
|
|
293
|
+
await navigateAndSettle(
|
|
294
|
+
page,
|
|
295
|
+
options.screenHash,
|
|
296
|
+
options.screenTestId,
|
|
297
|
+
options,
|
|
298
|
+
deadline,
|
|
299
|
+
`${navigate ? 'Navigation' : 'Idle'} arm setup`,
|
|
300
|
+
);
|
|
301
|
+
const samples = [];
|
|
302
|
+
for (let round = 1; round <= options.count; round += 1) {
|
|
303
|
+
samples.push(
|
|
304
|
+
await samplePostGcMetrics(
|
|
305
|
+
page,
|
|
306
|
+
options,
|
|
307
|
+
deadline,
|
|
308
|
+
round,
|
|
309
|
+
navigate,
|
|
310
|
+
options.roundDurationMs,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return { samples, slopes: sampleSlopes(samples) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function compareIdleNavigation(page, options, maxAttributableListenersPerCycle) {
|
|
318
|
+
const deadline = deadlineIn(options.timeoutMs);
|
|
319
|
+
await enableMemoryDomains(page.session, deadline, true);
|
|
320
|
+
const idle = await measureArm(page, options, deadline, false);
|
|
321
|
+
const navigation = await measureArm(page, options, deadline, true);
|
|
322
|
+
const idleListenersPerCycle = Math.max(0, idle.slopes.listeners);
|
|
323
|
+
const attributableListenersPerCycle = navigation.slopes.listeners - idleListenersPerCycle;
|
|
324
|
+
const result = {
|
|
325
|
+
measurement: 'post-gc-idle-control',
|
|
326
|
+
screen: { hash: options.screenHash, testId: options.screenTestId },
|
|
327
|
+
rounds: options.count,
|
|
328
|
+
idle,
|
|
329
|
+
navigation,
|
|
330
|
+
idleListenersPerCycle,
|
|
331
|
+
attributableListenersPerCycle,
|
|
332
|
+
bound: { maxAttributableListenersPerCycle },
|
|
333
|
+
passed: attributableListenersPerCycle <= maxAttributableListenersPerCycle,
|
|
334
|
+
};
|
|
335
|
+
if (!result.passed) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Idle-control bound exceeded: attributable listeners ${attributableListenersPerCycle.toFixed(2)}/${maxAttributableListenersPerCycle} per cycle (navigation ${navigation.slopes.listeners.toFixed(2)}, idle ${idleListenersPerCycle.toFixed(2)}).`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function navigationSlopeBounds(node) {
|
|
344
|
+
return {
|
|
345
|
+
maxNodesPerCycle: finiteNumber(
|
|
346
|
+
node.max_nodes_per_cycle ?? node.maxNodesPerCycle,
|
|
347
|
+
2,
|
|
348
|
+
'max_nodes_per_cycle',
|
|
349
|
+
),
|
|
350
|
+
maxListenersPerCycle: finiteNumber(
|
|
351
|
+
node.max_listeners_per_cycle ?? node.maxListenersPerCycle,
|
|
352
|
+
4,
|
|
353
|
+
'max_listeners_per_cycle',
|
|
354
|
+
),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function detachedDomBound(node) {
|
|
359
|
+
return finiteNumber(
|
|
360
|
+
node.max_detached_growth ?? node.maxDetachedGrowth,
|
|
361
|
+
50,
|
|
362
|
+
'max_detached_growth',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function idleControlBound(node) {
|
|
367
|
+
return finiteNumber(
|
|
368
|
+
node.max_attributable_listeners_per_cycle ?? node.maxAttributableListenersPerCycle,
|
|
369
|
+
2,
|
|
370
|
+
'max_attributable_listeners_per_cycle',
|
|
371
|
+
);
|
|
372
|
+
}
|