@deeeed/metamask-harness 0.34.1 → 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 (53) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +222 -0
  3. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
  4. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
  5. package/adapters/mobile/open-device.sh +6 -4
  6. package/dist/adapters/mobile/prepare.js +1 -4
  7. package/dist/adapters.js +139 -8
  8. package/dist/commands/launch/mobile.js +16 -2
  9. package/dist/live-adapter-contract.js +6 -6
  10. package/dist/recipe-security.js +3 -0
  11. package/dist/runner.js +53 -8
  12. package/dist/runtime-context.js +1 -0
  13. package/library/actions/core/perps/_controller.mjs +75 -23
  14. package/library/actions/core/perps/read_account.mjs +6 -12
  15. package/library/actions/core/perps/read_snapshot.mjs +66 -0
  16. package/library/actions/extension/performance/_navigation-memory.mjs +372 -0
  17. package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +18 -0
  18. package/library/actions/extension/performance/measure_detached_dom.mjs +18 -0
  19. package/library/actions/extension/performance/measure_navigation_memory.mjs +18 -0
  20. package/library/actions/mobile/app/network-control.mjs +24 -12
  21. package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
  22. package/library/actions/mobile/perps/performance-capture.mjs +506 -120
  23. package/library/actions/mobile/perps/perps.mjs +221 -38
  24. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
  25. package/library/actions/mobile/platform/bridge.mjs +38 -5
  26. package/library/actions/mobile/wallet/ensure_unlocked.mjs +5 -1
  27. package/library/manifests/core.action-manifest.json +55 -0
  28. package/library/manifests/extension.action-manifest.json +100 -0
  29. package/library/manifests/mobile.action-manifest.json +226 -512
  30. package/library/recipes/core/perps/snapshot.recipe.json +25 -0
  31. package/library/recipes/extension/performance/navigation-memory.recipe.json +175 -0
  32. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
  33. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
  34. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
  35. package/package.json +1 -1
  36. package/scripts/site-contrast.mjs +39 -3
  37. package/site/architecture.html +22 -16
  38. package/site/assets/progress.mjs +1 -1
  39. package/site/assets/style.css +113 -1
  40. package/site/cheatsheet.html +14 -12
  41. package/site/how-it-works.html +693 -0
  42. package/site/index.html +70 -640
  43. package/site/perps.html +7 -6
  44. package/site/recipes.html +23 -17
  45. package/site/reviewers.html +7 -6
  46. package/site/tutorials/index.html +7 -6
  47. package/site/tutorials/v1.html +12 -11
  48. package/site/tutorials/v2.html +7 -6
  49. package/site/tutorials/v3.html +15 -11
  50. package/site/tutorials/v4.html +6 -5
  51. package/site/tutorials/v5.html +6 -5
  52. package/site/tutorials/v6.html +6 -5
  53. package/site/tutorials/v7.html +6 -5
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ compareIdleNavigation,
4
+ idleControlBound,
5
+ navigationMemoryOptions,
6
+ } from './_navigation-memory.mjs';
7
+
8
+ runAdapter((input) =>
9
+ withExtensionPage(input, async (page) => ({
10
+ action: input.action,
11
+ status: 'pass',
12
+ ...(await compareIdleNavigation(
13
+ page,
14
+ navigationMemoryOptions(input.node, 'rounds', 8),
15
+ idleControlBound(input.node),
16
+ )),
17
+ })),
18
+ );
@@ -0,0 +1,18 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ detachedDomBound,
4
+ measureDetachedDom,
5
+ navigationMemoryOptions,
6
+ } from './_navigation-memory.mjs';
7
+
8
+ runAdapter((input) =>
9
+ withExtensionPage(input, async (page) => ({
10
+ action: input.action,
11
+ status: 'pass',
12
+ ...(await measureDetachedDom(
13
+ page,
14
+ navigationMemoryOptions(input.node, 'cycles', 5),
15
+ detachedDomBound(input.node),
16
+ )),
17
+ })),
18
+ );
@@ -0,0 +1,18 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ measureNavigationSlope,
4
+ navigationMemoryOptions,
5
+ navigationSlopeBounds,
6
+ } from './_navigation-memory.mjs';
7
+
8
+ runAdapter((input) =>
9
+ withExtensionPage(input, async (page) => ({
10
+ action: input.action,
11
+ status: 'pass',
12
+ ...(await measureNavigationSlope(
13
+ page,
14
+ navigationMemoryOptions(input.node),
15
+ navigationSlopeBounds(input.node),
16
+ )),
17
+ })),
18
+ );
@@ -42,6 +42,12 @@ async function defaultRun(file, args) {
42
42
  return execFileAsync(file, args, { timeout: 15_000, encoding: 'utf8' });
43
43
  }
44
44
 
45
+ function isAppFocused(output, appId) {
46
+ return String(output ?? '')
47
+ .split('\n')
48
+ .some((line) => line.includes('mCurrentFocus=') && line.includes(appId));
49
+ }
50
+
45
51
  export async function setMobileNetworkState(input, deps = {}) {
46
52
  const state = String(input.node?.state ?? '').toLowerCase();
47
53
  if (state !== 'offline' && state !== 'online') {
@@ -87,19 +93,26 @@ export async function setMobileNetworkState(input, deps = {}) {
87
93
  `app.network requested ${state}, but Android airplane_mode_on was ${JSON.stringify(observed)} instead of ${expected}.`,
88
94
  );
89
95
  }
90
-
91
-
92
- await run(adb, [
96
+ const focusBefore = await run(adb, [
93
97
  '-s',
94
98
  serial,
95
99
  'shell',
96
- 'monkey',
97
- '-p',
98
- appId,
99
- '-c',
100
- 'android.intent.category.LAUNCHER',
101
- '1',
100
+ 'dumpsys',
101
+ 'window',
102
102
  ]);
103
+ if (!isAppFocused(focusBefore.stdout, appId)) {
104
+ await run(adb, [
105
+ '-s',
106
+ serial,
107
+ 'shell',
108
+ 'monkey',
109
+ '-p',
110
+ appId,
111
+ '-c',
112
+ 'android.intent.category.LAUNCHER',
113
+ '1',
114
+ ]);
115
+ }
103
116
 
104
117
  const fallbackSettleMs = state === 'offline' ? 3000 : 10000;
105
118
  const settleMs = Number(input.node?.settle_ms ?? fallbackSettleMs);
@@ -113,10 +126,9 @@ export async function setMobileNetworkState(input, deps = {}) {
113
126
  serial,
114
127
  'shell',
115
128
  'dumpsys',
116
- 'activity',
117
- 'activities',
129
+ 'window',
118
130
  ]);
119
- if (!String(foreground.stdout ?? '').includes(appId)) {
131
+ if (!isAppFocused(foreground.stdout, appId)) {
120
132
  throw new Error(
121
133
  `app.network changed Android networking but ${appId} was not foregrounded afterward.`,
122
134
  );
@@ -0,0 +1,4 @@
1
+ import { runAdapter } from '../platform/bridge.mjs';
2
+ import { measureHomepageVisible } from './perps.mjs';
3
+
4
+ runAdapter(measureHomepageVisible);