@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/adapters.js +5 -2
  3. package/dist/recipe-security.js +1 -0
  4. package/library/actions/core/perps/_controller.mjs +75 -23
  5. package/library/actions/core/perps/read_account.mjs +6 -12
  6. package/library/actions/core/perps/read_snapshot.mjs +66 -0
  7. package/library/actions/extension/performance/_navigation-memory.mjs +372 -0
  8. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +18 -0
  9. package/library/actions/extension/performance/measure_detached_dom.mjs +18 -0
  10. package/library/actions/extension/performance/measure_navigation_memory.mjs +18 -0
  11. package/library/actions/mobile/app/network-control.mjs +24 -12
  12. package/library/manifests/core.action-manifest.json +55 -0
  13. package/library/manifests/extension.action-manifest.json +100 -0
  14. package/library/recipes/core/perps/snapshot.recipe.json +25 -0
  15. package/library/recipes/extension/performance/navigation-memory.recipe.json +175 -0
  16. package/package.json +1 -1
  17. package/scripts/site-contrast.mjs +39 -3
  18. package/site/architecture.html +22 -16
  19. package/site/assets/progress.mjs +1 -1
  20. package/site/assets/style.css +113 -1
  21. package/site/cheatsheet.html +14 -12
  22. package/site/how-it-works.html +693 -0
  23. package/site/index.html +70 -640
  24. package/site/perps.html +7 -6
  25. package/site/recipes.html +23 -17
  26. package/site/reviewers.html +7 -6
  27. package/site/tutorials/index.html +7 -6
  28. package/site/tutorials/v1.html +12 -11
  29. package/site/tutorials/v2.html +7 -6
  30. package/site/tutorials/v3.html +15 -11
  31. package/site/tutorials/v4.html +6 -5
  32. package/site/tutorials/v5.html +6 -5
  33. package/site/tutorials/v6.html +6 -5
  34. 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(["metamask.perps.read_account"]);
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])];
@@ -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 (cached && cached.projectRoot === projectRoot && cached.network === network) {
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 = { controller, messenger, rootMessenger, projectRoot, network };
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, address) {
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, signerAddress);
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 { getCoreController, isDirectRun, runAdapter } from './_controller.mjs';
2
-
3
- function redactAccount(account) {
4
- return {
5
- totalBalance: account?.totalBalance ?? null,
6
- spendableBalance: account?.spendableBalance ?? null,
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
+ }