@deeeed/metamask-harness 0.34.0 → 0.34.2

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 (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/adapters/manifest.json +0 -8
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +236 -3
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
  5. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +22 -15
  6. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
  7. package/adapters/mobile/launch-metro.cjs +6 -4
  8. package/adapters/mobile/open-device.sh +170 -19
  9. package/adapters/mobile/start-metro.sh +165 -93
  10. package/adapters/mobile/stop-metro.sh +1 -0
  11. package/dist/adapters/mobile/prepare.js +1 -4
  12. package/dist/adapters/mobile/video-recorder.js +12 -10
  13. package/dist/adapters.js +134 -6
  14. package/dist/commands/launch/index.js +9 -0
  15. package/dist/commands/launch/mobile.js +17 -3
  16. package/dist/live-adapter-contract.js +10 -3
  17. package/dist/recipe-security.js +2 -0
  18. package/dist/runner.js +53 -8
  19. package/dist/runtime-context.js +1 -0
  20. package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
  21. package/library/actions/mobile/perps/performance-capture.mjs +579 -111
  22. package/library/actions/mobile/perps/perps.mjs +221 -38
  23. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
  24. package/library/actions/mobile/platform/bridge.mjs +102 -8
  25. package/library/actions/mobile/wallet/ensure_unlocked.mjs +35 -8
  26. package/library/manifests/mobile.action-manifest.json +243 -511
  27. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +20 -2
  28. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +2 -0
  29. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
  30. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
  31. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
  32. package/package.json +1 -1
  33. package/adapters/mobile/metro-config.cjs +0 -93
@@ -1,14 +1,21 @@
1
- import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
3
 
4
- const MARKER = '[HomepagePerf]';
5
- const STATE_FILE = '.homepage-performance-capture.json';
4
+ const MARKER = "[HomepagePerf]";
5
+ const STARTUP_MARKER = "[StartupPerf]";
6
+ const STATE_FILE = ".homepage-performance-capture.json";
7
+ const FRESH_SOURCES = new Set([
8
+ "fresh_socket",
9
+ "provider_snapshot",
10
+ "terminal_global_snapshot_v2",
11
+ "provider",
12
+ ]);
6
13
 
7
14
  function resolveWithin(root, relativePath, label) {
8
15
  const absoluteRoot = path.resolve(root);
9
16
  const absolutePath = path.resolve(absoluteRoot, relativePath);
10
17
  const relative = path.relative(absoluteRoot, absolutePath);
11
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
18
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
12
19
  throw new Error(`${label} must stay within ${absoluteRoot}.`);
13
20
  }
14
21
  return absolutePath;
@@ -18,25 +25,55 @@ async function fileSize(file) {
18
25
  try {
19
26
  return (await stat(file)).size;
20
27
  } catch (error) {
21
- if (error?.code === 'ENOENT') return 0;
28
+ if (error?.code === "ENOENT") return 0;
22
29
  throw error;
23
30
  }
24
31
  }
25
32
 
26
- function parseRecord(line) {
27
- const markerIndex = line.indexOf(MARKER);
33
+ function parseRecord(line, marker) {
34
+ const markerIndex = line.indexOf(marker);
28
35
  if (markerIndex < 0) return null;
29
- const payload = line.slice(markerIndex + MARKER.length).trim();
36
+ const payload = line.slice(markerIndex + marker.length).trim();
30
37
  try {
31
38
  const event = JSON.parse(payload);
32
- return event && typeof event === 'object' && !Array.isArray(event) ? event : null;
39
+ return event && typeof event === "object" && !Array.isArray(event)
40
+ ? event
41
+ : null;
33
42
  } catch {
34
43
  return null;
35
44
  }
36
45
  }
37
46
 
47
+ function startupMeasurements(records) {
48
+ return records.map((record) => {
49
+ const processToStageMs = Number(
50
+ record.process_to_unlock_demand_ms ?? record.process_to_frame_ms,
51
+ );
52
+ const unlockToFrameMs = Number(record.unlock_to_frame_ms);
53
+ const monotonicMs = Number(record.monotonic_ms);
54
+ const appLaunchEpochMs = Number(record.app_launch_epoch_ms);
55
+ return {
56
+ stage: String(record.stage ?? "unknown"),
57
+ monotonicMs: Number.isFinite(monotonicMs) ? monotonicMs : null,
58
+ appLaunchEpochMs: Number.isFinite(appLaunchEpochMs)
59
+ ? appLaunchEpochMs
60
+ : null,
61
+ processStartMonotonicMs:
62
+ Number.isFinite(monotonicMs) && Number.isFinite(processToStageMs)
63
+ ? roundMs(monotonicMs - processToStageMs)
64
+ : null,
65
+ processToStageMs: Number.isFinite(processToStageMs)
66
+ ? processToStageMs
67
+ : null,
68
+ unlockToFrameMs: Number.isFinite(unlockToFrameMs)
69
+ ? unlockToFrameMs
70
+ : null,
71
+ };
72
+ });
73
+ }
74
+
38
75
  function increment(counts, value) {
39
- const key = value == null || value === '' ? 'unknown' : String(value);
76
+ const key = value == null || value === "" ? "unknown" : String(value);
40
77
  counts[key] = (counts[key] ?? 0) + 1;
41
78
  }
42
79
 
@@ -47,66 +84,227 @@ function roundMs(value) {
47
84
  function contentVariantSatisfies(actual, required) {
48
85
  if (actual === required) return true;
49
86
  return (
50
- actual === 'positions_and_orders' &&
51
- (required === 'positions' || required === 'orders')
87
+ actual === "positions_and_orders" &&
88
+ (required === "positions" || required === "orders")
52
89
  );
53
90
  }
54
91
 
55
92
  function requiredFreshStreams(contentVariant, requiredVariants) {
56
- if (requiredVariants.length === 0) return ['positions', 'orders'];
93
+ if (requiredVariants.length === 0) return ["positions", "orders"];
57
94
  const streams = new Set();
58
95
  for (const required of requiredVariants) {
59
96
  if (!contentVariantSatisfies(contentVariant, required)) continue;
60
- if (required === 'positions') streams.add('positions');
61
- else if (required === 'orders') streams.add('orders');
62
- else if (required === 'positions_and_orders') {
63
- streams.add('positions');
64
- streams.add('orders');
97
+ if (required === "positions") streams.add("positions");
98
+ else if (required === "orders") streams.add("orders");
99
+ else if (required === "positions_and_orders") {
100
+ streams.add("positions");
101
+ streams.add("orders");
102
+ } else if (required === "trending" || required === "pills") {
103
+ streams.add("positions");
104
+ streams.add("orders");
105
+ streams.add("markets");
65
106
  } else {
66
- // Empty/trending content is derived from both collections.
67
- streams.add('positions');
68
- streams.add('orders');
107
+ // Empty content is derived from both account collections.
108
+ streams.add("positions");
109
+ streams.add("orders");
69
110
  }
70
111
  }
71
112
  return [...streams];
72
113
  }
73
114
 
115
+ function frameTime(record) {
116
+ const value = Number(
117
+ record.frame_checkpoint_monotonic_ms ?? record.monotonic_ms,
118
+ );
119
+ return Number.isFinite(value) ? value : null;
120
+ }
121
+
122
+ function frameIsFresh(record) {
123
+ if (record.fresh_for_lifecycle === false) return false;
124
+ return (
125
+ FRESH_SOURCES.has(record.source) ||
126
+ ((record.source === "resident_state" || record.source === "memory_cache") &&
127
+ FRESH_SOURCES.has(record.origin_source) &&
128
+ record.fresh_for_lifecycle === true)
129
+ );
130
+ }
131
+
132
+ function frameIsCached(record) {
133
+ return (
134
+ !frameIsFresh(record) &&
135
+ ["disk_cache", "memory_cache", "resident_state"].includes(record.source)
136
+ );
137
+ }
138
+
139
+ function visibleFrameGroups(records) {
140
+ const demandLifecycles = new Map(
141
+ records
142
+ .filter((record) => record.stage === "viewport_demand")
143
+ .map((record) => [
144
+ String(record.demand_id ?? ""),
145
+ record.lifecycle ?? "unknown",
146
+ ]),
147
+ );
148
+ const groups = new Map();
149
+ for (const record of records) {
150
+ if (record.stage !== "next_frame_checkpoint") continue;
151
+ const at = frameTime(record);
152
+ if (at === null) continue;
153
+ const demandId = String(record.demand_id ?? "");
154
+ const key = `${demandId}:${at}`;
155
+ const group = groups.get(key) ?? {
156
+ at,
157
+ demandId,
158
+ lifecycle:
159
+ record.lifecycle ?? demandLifecycles.get(demandId) ?? "unknown",
160
+ records: [],
161
+ };
162
+ group.records.push(record);
163
+ groups.set(key, group);
164
+ }
165
+ return [...groups.values()].sort((left, right) => left.at - right.at);
166
+ }
167
+
168
+ function frameGroupSatisfies(group, requiredVariants, predicate) {
169
+ const records = group.records.filter(predicate);
170
+ const variants = [
171
+ ...new Set(
172
+ records
173
+ .map((record) => String(record.content_variant ?? ""))
174
+ .filter(Boolean),
175
+ ),
176
+ ];
177
+ const contentMatches =
178
+ requiredVariants.length === 0 ||
179
+ requiredVariants.every((required) =>
180
+ variants.some((actual) => contentVariantSatisfies(actual, required)),
181
+ );
182
+ if (!contentMatches) return false;
183
+ const requiredStreams = requiredFreshStreams(
184
+ variants[0] ?? "",
185
+ requiredVariants,
186
+ );
187
+ const streams = new Set(records.map((record) => String(record.stream ?? "")));
188
+ return (
189
+ requiredStreams.length > 0 &&
190
+ requiredStreams.every((stream) => streams.has(stream))
191
+ );
192
+ }
193
+
194
+ function cacheTakeoverMeasurement(records, requiredContentVariants = []) {
195
+ const groups = visibleFrameGroups(records);
196
+ const cached = groups.find((group) =>
197
+ frameGroupSatisfies(group, requiredContentVariants, frameIsCached),
198
+ );
199
+ const fresh = cached
200
+ ? groups.find(
201
+ (group) =>
202
+ group.at > cached.at &&
203
+ frameGroupSatisfies(group, requiredContentVariants, frameIsFresh),
204
+ )
205
+ : null;
206
+ const cacheHydratedValues = records
207
+ .filter((record) => record.stage === "disk_cache_hydrated")
208
+ .map((record) => Number(record.monotonic_ms))
209
+ .filter(
210
+ (value) =>
211
+ Number.isFinite(value) && (cached === undefined || value <= cached.at),
212
+ );
213
+ const cacheHydratedAt =
214
+ cacheHydratedValues.length > 0 ? Math.max(...cacheHydratedValues) : null;
215
+ const failureReason = !cached
216
+ ? "missing_cached_visible_frame"
217
+ : !fresh
218
+ ? "missing_later_fresh_visible_frame"
219
+ : cacheHydratedAt === null
220
+ ? "missing_disk_cache_hydrated"
221
+ : null;
222
+ return {
223
+ status: failureReason === null ? "pass" : "fail",
224
+ requiredContentVariants: requiredContentVariants,
225
+ cacheHydratedAtMonotonicMs: cacheHydratedAt,
226
+ cachedVisibleAtMonotonicMs: cached?.at ?? null,
227
+ cachedVisibleLifecycle: cached?.lifecycle ?? null,
228
+ cachedVisibleSources: cached
229
+ ? [
230
+ ...new Set(
231
+ cached.records
232
+ .filter(frameIsCached)
233
+ .map((record) => String(record.source)),
234
+ ),
235
+ ]
236
+ : [],
237
+ freshVisibleAtMonotonicMs: fresh?.at ?? null,
238
+ freshVisibleLifecycle: fresh?.lifecycle ?? null,
239
+ cacheToVisibleMs:
240
+ cached && cacheHydratedAt !== null
241
+ ? roundMs(cached.at - cacheHydratedAt)
242
+ : null,
243
+ cachedToFreshVisibleMs:
244
+ cached && fresh ? roundMs(fresh.at - cached.at) : null,
245
+ failureReason,
246
+ };
247
+ }
248
+
74
249
  function visibleMeasurements(records, requiredFreshContentVariants = []) {
75
- const demands = records.filter((record) => record.stage === 'viewport_demand');
250
+ const demands = records.filter(
251
+ (record) => record.stage === "viewport_demand",
252
+ );
76
253
  return demands.map((demand) => {
77
- const demandId = String(demand.demand_id ?? '');
254
+ const demandId = String(demand.demand_id ?? "");
78
255
  const startedAt = Number(demand.monotonic_ms);
256
+ const belongsToDemand = (record) =>
257
+ String(record.demand_id ?? "") === demandId &&
258
+ ["account_generation", "context_generation"].every(
259
+ (key) =>
260
+ demand[key] === undefined ||
261
+ record[key] === undefined ||
262
+ String(record[key]) === String(demand[key]),
263
+ );
79
264
  const frames = records.filter(
80
265
  (record) =>
81
- record.stage === 'next_frame_checkpoint' &&
82
- String(record.demand_id ?? '') === demandId,
266
+ record.stage === "next_frame_checkpoint" && belongsToDemand(record),
267
+ );
268
+ const firstVisibleBoundary = records.find(
269
+ (record) =>
270
+ record.stage === "first_visible_recorded" && belongsToDemand(record),
271
+ );
272
+ const freshVisibleBoundary = records.find(
273
+ (record) =>
274
+ record.stage === "fresh_visible_recorded" && belongsToDemand(record),
83
275
  );
84
276
  const frameTimes = frames
85
- .map((record) => Number(record.frame_checkpoint_monotonic_ms ?? record.monotonic_ms))
277
+ .map((record) =>
278
+ Number(record.frame_checkpoint_monotonic_ms ?? record.monotonic_ms),
279
+ )
86
280
  .filter(Number.isFinite);
87
- const firstVisibleAt = frameTimes.length > 0 ? Math.min(...frameTimes) : null;
281
+ const recordedFirstVisibleAt = frameTime(firstVisibleBoundary ?? {});
282
+ const firstVisibleAt =
283
+ recordedFirstVisibleAt ??
284
+ (frameTimes.length > 0 ? Math.min(...frameTimes) : null);
88
285
  const firstFreshByStream = {};
89
286
  const freshStreamsSeen = new Set();
90
- let freshVisibleAt = null;
91
- let freshContentVariant = null;
287
+ let freshVisibleAt = frameTime(freshVisibleBoundary ?? {});
288
+ let freshContentVariant = freshVisibleBoundary?.content_variant ?? null;
92
289
  const sortedFrames = [...frames].sort(
93
290
  (left, right) =>
94
291
  Number(left.frame_checkpoint_monotonic_ms ?? left.monotonic_ms) -
95
292
  Number(right.frame_checkpoint_monotonic_ms ?? right.monotonic_ms),
96
293
  );
97
294
  for (const frame of sortedFrames) {
98
- const freshVisible =
99
- frame.source === 'fresh_socket' ||
100
- (frame.source === 'resident_state' &&
101
- frame.origin_source === 'fresh_socket' &&
102
- frame.fresh_for_lifecycle === true);
295
+ const freshVisible = frameIsFresh(frame);
103
296
  if (!freshVisible || !frame.stream) continue;
104
- const frameAt = Number(frame.frame_checkpoint_monotonic_ms ?? frame.monotonic_ms);
297
+ const frameAt = Number(
298
+ frame.frame_checkpoint_monotonic_ms ?? frame.monotonic_ms,
299
+ );
105
300
  if (!Number.isFinite(frameAt)) continue;
106
301
  const stream = String(frame.stream);
107
- firstFreshByStream[stream] = Math.min(firstFreshByStream[stream] ?? frameAt, frameAt);
302
+ firstFreshByStream[stream] = Math.min(
303
+ firstFreshByStream[stream] ?? frameAt,
304
+ frameAt,
305
+ );
108
306
  freshStreamsSeen.add(stream);
109
- const contentVariant = String(frame.content_variant ?? '');
307
+ const contentVariant = String(frame.content_variant ?? "");
110
308
  const contentMatches =
111
309
  requiredFreshContentVariants.length === 0 ||
112
310
  requiredFreshContentVariants.every((required) =>
@@ -134,84 +332,211 @@ function visibleMeasurements(records, requiredFreshContentVariants = []) {
134
332
  firstFreshByStream.positions != null &&
135
333
  firstFreshByStream.orders != null
136
334
  ) {
137
- freshVisibleAt = Math.max(firstFreshByStream.positions, firstFreshByStream.orders);
335
+ freshVisibleAt = Math.max(
336
+ firstFreshByStream.positions,
337
+ firstFreshByStream.orders,
338
+ );
138
339
  }
139
- const dataAges = frames.map((record) => Number(record.data_age_ms)).filter(Number.isFinite);
340
+ const dataAges = frames
341
+ .map((record) => Number(record.data_age_ms))
342
+ .filter(Number.isFinite);
140
343
  return {
141
344
  demandId,
142
- lifecycle: demand.lifecycle ?? 'unknown',
345
+ lifecycle: demand.lifecycle ?? "unknown",
143
346
  startedAtMonotonicMs: Number.isFinite(startedAt) ? startedAt : null,
144
347
  firstVisibleAtMonotonicMs: firstVisibleAt,
145
- ttcMs:
146
- Number.isFinite(startedAt) && firstVisibleAt != null
348
+ ttcMs: Number.isFinite(Number(firstVisibleBoundary?.duration_ms))
349
+ ? roundMs(Number(firstVisibleBoundary.duration_ms))
350
+ : Number.isFinite(startedAt) && firstVisibleAt != null
147
351
  ? roundMs(firstVisibleAt - startedAt)
148
352
  : null,
149
353
  freshVisibleAtMonotonicMs: freshVisibleAt,
150
354
  freshContentVariant,
151
355
  dataReadyAtDemand: sortedFrames.some(
152
- (frame) =>
153
- frame.data_ready_at_demand === true &&
154
- (frame.source === 'fresh_socket' ||
155
- (frame.origin_source === 'fresh_socket' && frame.fresh_for_lifecycle === true)),
356
+ (frame) => frame.data_ready_at_demand === true && frameIsFresh(frame),
156
357
  ),
157
- dfdMs:
158
- Number.isFinite(startedAt) && freshVisibleAt != null
358
+ dfdMs: Number.isFinite(Number(freshVisibleBoundary?.duration_ms))
359
+ ? roundMs(Number(freshVisibleBoundary.duration_ms))
360
+ : Number.isFinite(startedAt) && freshVisibleAt != null
159
361
  ? roundMs(freshVisibleAt - startedAt)
160
362
  : null,
161
- deliverySources: [...new Set(frames.map((record) => String(record.source ?? 'unknown')))],
162
- contentVariant: frames.find((record) => record.content_variant)?.content_variant ?? null,
363
+ deliverySources: [
364
+ ...new Set(frames.map((record) => String(record.source ?? "unknown"))),
365
+ ],
366
+ contentVariant:
367
+ frames.find((record) => record.content_variant)?.content_variant ??
368
+ null,
163
369
  maxDataAgeMs: dataAges.length > 0 ? Math.max(...dataAges) : null,
164
370
  };
165
371
  });
166
372
  }
167
373
 
374
+ function percentile(values, fraction) {
375
+ if (values.length === 0) return null;
376
+ const sorted = [...values].sort((left, right) => left - right);
377
+ return sorted[Math.max(0, Math.ceil(fraction * sorted.length) - 1)];
378
+ }
379
+
380
+ function measurementStats(measurements, field) {
381
+ const rawMs = measurements
382
+ .map((measurement) => measurement[field])
383
+ .filter((value) => Number.isFinite(value))
384
+ .sort((left, right) => left - right);
385
+ return {
386
+ successCount: rawMs.length,
387
+ failureCount: measurements.length - rawMs.length,
388
+ minMs: rawMs[0] ?? null,
389
+ p50Ms: percentile(rawMs, 0.5),
390
+ p75Ms: percentile(rawMs, 0.75),
391
+ p95Ms: percentile(rawMs, 0.95),
392
+ maxMs: rawMs.at(-1) ?? null,
393
+ rawMs,
394
+ };
395
+ }
396
+
397
+ function visibleMeasurementCohorts(measurements) {
398
+ const byLifecycle = new Map();
399
+ for (const measurement of measurements) {
400
+ const lifecycle = String(measurement.lifecycle ?? "unknown");
401
+ const cohort = byLifecycle.get(lifecycle) ?? [];
402
+ cohort.push(measurement);
403
+ byLifecycle.set(lifecycle, cohort);
404
+ }
405
+ return Object.fromEntries(
406
+ [...byLifecycle.entries()].map(([lifecycle, cohort]) => {
407
+ const sourceDistribution = {};
408
+ for (const measurement of cohort) {
409
+ for (const source of measurement.deliverySources) {
410
+ increment(sourceDistribution, source);
411
+ }
412
+ }
413
+ return [
414
+ lifecycle,
415
+ {
416
+ sampleCount: cohort.length,
417
+ ttc: measurementStats(cohort, "ttcMs"),
418
+ dfd: measurementStats(cohort, "dfdMs"),
419
+ sourceDistribution,
420
+ },
421
+ ];
422
+ }),
423
+ );
424
+ }
425
+
168
426
  function socketPipelineMeasurements(records) {
169
427
  const sockets = records.filter(
170
- (record) => record.stage === 'socket_received' && record.delivery_id,
428
+ (record) => record.stage === "socket_received" && record.delivery_id,
171
429
  );
172
430
  return sockets.flatMap((socket) => {
173
431
  const deliveryId = String(socket.delivery_id);
174
432
  const socketAt = Number(socket.monotonic_ms);
175
433
  const commit = records.find(
176
- (record) => record.stage === 'react_commit' && String(record.delivery_id ?? '') === deliveryId,
434
+ (record) =>
435
+ record.stage === "react_commit" &&
436
+ String(record.delivery_id ?? "") === deliveryId,
177
437
  );
178
438
  const frame = records.find(
179
- (record) => record.stage === 'next_frame_checkpoint' && String(record.delivery_id ?? '') === deliveryId,
439
+ (record) =>
440
+ record.stage === "next_frame_checkpoint" &&
441
+ String(record.delivery_id ?? "") === deliveryId,
180
442
  );
181
443
  if (!commit || !frame || !Number.isFinite(socketAt)) return [];
182
444
  const commitAt = Number(commit.monotonic_ms);
183
- const frameAt = Number(frame.frame_checkpoint_monotonic_ms ?? frame.monotonic_ms);
445
+ const frameAt = Number(
446
+ frame.frame_checkpoint_monotonic_ms ?? frame.monotonic_ms,
447
+ );
184
448
  const subscriberCandidates = records
185
449
  .filter(
186
450
  (record) =>
187
- record.stage === 'subscriber_delivery' &&
188
- String(record.delivery_id ?? '') === deliveryId &&
451
+ record.stage === "subscriber_delivery" &&
452
+ String(record.delivery_id ?? "") === deliveryId &&
189
453
  Number(record.monotonic_ms) <= commitAt,
190
454
  )
191
455
  .map((record) => Number(record.monotonic_ms))
192
456
  .filter(Number.isFinite);
193
- const subscriberAt = subscriberCandidates.length > 0
194
- ? Math.max(...subscriberCandidates)
195
- : socketAt;
196
- return [{
197
- deliveryId,
198
- stream: socket.stream ?? null,
199
- lifecycle: commit.lifecycle ?? socket.lifecycle ?? 'unknown',
200
- socketToVisibleMs: roundMs(frameAt - socketAt),
201
- socketToSubscriberMs: roundMs(subscriberAt - socketAt),
202
- subscriberToCommitMs: roundMs(commitAt - subscriberAt),
203
- commitToFrameMs: roundMs(frameAt - commitAt),
204
- }];
457
+ const subscriberAt =
458
+ subscriberCandidates.length > 0
459
+ ? Math.max(...subscriberCandidates)
460
+ : socketAt;
461
+ return [
462
+ {
463
+ deliveryId,
464
+ stream: socket.stream ?? null,
465
+ lifecycle: commit.lifecycle ?? socket.lifecycle ?? "unknown",
466
+ socketToVisibleMs: roundMs(frameAt - socketAt),
467
+ socketToSubscriberMs: roundMs(subscriberAt - socketAt),
468
+ subscriberToCommitMs: roundMs(commitAt - subscriberAt),
469
+ commitToFrameMs: roundMs(frameAt - commitAt),
470
+ },
471
+ ];
472
+ });
473
+ }
474
+
475
+ function firstFreshDeliveryChecks(records, requirements) {
476
+ return requirements.map((requirement) => {
477
+ const stream = String(requirement.stream);
478
+ const requiredLifecycle =
479
+ requirement.lifecycle === undefined
480
+ ? null
481
+ : String(requirement.lifecycle);
482
+ const subscriberThrottleMs = Number(requirement.subscriber_throttle_ms);
483
+ const maxSocketToSubscriberMs = Number(
484
+ requirement.max_socket_to_subscriber_ms,
485
+ );
486
+ const socket = records.find(
487
+ (record) =>
488
+ record.stage === "socket_received" &&
489
+ record.source === "fresh_socket" &&
490
+ record.stream === stream &&
491
+ (requiredLifecycle === null ||
492
+ record.lifecycle === requiredLifecycle) &&
493
+ record.delivery_id,
494
+ );
495
+ const delivery = socket
496
+ ? records.find(
497
+ (record) =>
498
+ record.stage === "subscriber_delivery" &&
499
+ record.delivery_id === socket.delivery_id &&
500
+ record.stream === stream &&
501
+ record.lifecycle === socket.lifecycle &&
502
+ Number(record.throttle_ms ?? 0) === subscriberThrottleMs,
503
+ )
504
+ : null;
505
+ const socketAt = Number(socket?.monotonic_ms);
506
+ const subscriberAt = Number(delivery?.monotonic_ms);
507
+ const socketToSubscriberMs =
508
+ Number.isFinite(socketAt) && Number.isFinite(subscriberAt)
509
+ ? roundMs(subscriberAt - socketAt)
510
+ : null;
511
+ return {
512
+ stream,
513
+ lifecycle: socket?.lifecycle ?? requiredLifecycle,
514
+ subscriberThrottleMs,
515
+ maxSocketToSubscriberMs,
516
+ deliveryId: socket?.delivery_id ?? null,
517
+ socketToSubscriberMs,
518
+ status:
519
+ socketToSubscriberMs !== null &&
520
+ socketToSubscriberMs <= maxSocketToSubscriberMs
521
+ ? "pass"
522
+ : "fail",
523
+ };
205
524
  });
206
525
  }
207
526
 
208
527
  function summarize(
209
528
  records,
529
+ startupRecords,
210
530
  sourcePath,
211
531
  offset,
212
532
  bytesScanned,
213
533
  requiredFreshContentVariants = [],
534
+ firstFreshDeliveryRequirements = [],
214
535
  ) {
536
+ const measurements = visibleMeasurements(
537
+ records,
538
+ requiredFreshContentVariants,
539
+ );
215
540
  const stages = {};
216
541
  const lifecycles = {};
217
542
  const streams = {};
@@ -225,6 +550,10 @@ function summarize(
225
550
  const monotonicValues = records
226
551
  .map((record) => Number(record.monotonic_ms))
227
552
  .filter(Number.isFinite);
553
+ const startupStages = {};
554
+ for (const record of startupRecords) {
555
+ increment(startupStages, record.stage);
556
+ }
228
557
  return {
229
558
  schemaVersion: 1,
230
559
  marker: MARKER,
@@ -232,84 +561,157 @@ function summarize(
232
561
  offset,
233
562
  bytesScanned,
234
563
  recordCount: records.length,
564
+ startupMarker: STARTUP_MARKER,
565
+ startupRecordCount: startupRecords.length,
566
+ startupStages,
567
+ startupMeasurements: startupMeasurements(startupRecords),
235
568
  stages,
236
569
  lifecycles,
237
570
  streams,
238
571
  sources,
239
- firstMonotonicMs: monotonicValues.length > 0 ? Math.min(...monotonicValues) : null,
240
- lastMonotonicMs: monotonicValues.length > 0 ? Math.max(...monotonicValues) : null,
241
- visibleMeasurements: visibleMeasurements(records, requiredFreshContentVariants),
572
+ firstMonotonicMs:
573
+ monotonicValues.length > 0 ? Math.min(...monotonicValues) : null,
574
+ lastMonotonicMs:
575
+ monotonicValues.length > 0 ? Math.max(...monotonicValues) : null,
576
+ visibleMeasurements: measurements,
577
+ visibleMeasurementCohorts: visibleMeasurementCohorts(measurements),
578
+ cacheTakeoverMeasurement: cacheTakeoverMeasurement(
579
+ records,
580
+ requiredFreshContentVariants,
581
+ ),
242
582
  socketPipelineMeasurements: socketPipelineMeasurements(records),
583
+ firstFreshDeliveryChecks: firstFreshDeliveryChecks(
584
+ records,
585
+ firstFreshDeliveryRequirements,
586
+ ),
243
587
  };
244
588
  }
245
589
 
246
590
  export async function captureHomepagePerformance(input) {
247
591
  const node = input.node ?? {};
248
- const phase = String(node.phase ?? '').toLowerCase();
249
- if (phase !== 'start' && phase !== 'end') {
250
- throw new Error('metamask.perps.capture_performance requires phase=start or phase=end.');
592
+ const phase = String(node.phase ?? "").toLowerCase();
593
+ if (phase !== "start" && phase !== "end") {
594
+ throw new Error(
595
+ "metamask.perps.capture_performance requires phase=start or phase=end.",
596
+ );
251
597
  }
252
598
 
253
599
  const projectRoot = input.context?.projectRoot;
254
600
  const artifactsDir = input.context?.artifactsDir;
255
601
  if (!projectRoot || !artifactsDir) {
256
- throw new Error('metamask.perps.capture_performance requires projectRoot and artifactsDir.');
602
+ throw new Error(
603
+ "metamask.perps.capture_performance requires projectRoot and artifactsDir.",
604
+ );
257
605
  }
258
- const sourcePath = String(node.source_path ?? 'temp/recipe/runtime/app-console.log');
259
- const sourceFile = resolveWithin(projectRoot, sourcePath, 'source_path');
260
- const stateFile = resolveWithin(artifactsDir, STATE_FILE, 'capture state');
606
+ const sourcePath = String(
607
+ node.source_path ?? "temp/recipe/runtime/app-console.log",
608
+ );
609
+ const sourceFile = resolveWithin(projectRoot, sourcePath, "source_path");
610
+ const stateFile = resolveWithin(artifactsDir, STATE_FILE, "capture state");
261
611
  await mkdir(artifactsDir, { recursive: true });
262
612
 
263
- if (phase === 'start') {
613
+ if (phase === "start") {
264
614
  const offset = await fileSize(sourceFile);
265
- await writeFile(stateFile, `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`);
615
+ await writeFile(
616
+ stateFile,
617
+ `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`,
618
+ );
266
619
  return { phase, sourcePath, offset };
267
620
  }
268
621
 
269
622
  let state;
270
623
  try {
271
- state = JSON.parse(await readFile(stateFile, 'utf8'));
624
+ state = JSON.parse(await readFile(stateFile, "utf8"));
272
625
  } catch (error) {
273
626
  throw new Error(
274
- 'metamask.perps.capture_performance phase=end requires a successful phase=start in the same recipe run.',
627
+ "metamask.perps.capture_performance phase=end requires a successful phase=start in the same recipe run.",
275
628
  { cause: error },
276
629
  );
277
630
  }
278
- if (state.sourcePath !== sourcePath || !Number.isInteger(state.offset) || state.offset < 0) {
279
- throw new Error('metamask.perps.capture_performance capture state is invalid or uses a different source_path.');
631
+ if (
632
+ state.sourcePath !== sourcePath ||
633
+ !Number.isInteger(state.offset) ||
634
+ state.offset < 0
635
+ ) {
636
+ throw new Error(
637
+ "metamask.perps.capture_performance capture state is invalid or uses a different source_path.",
638
+ );
280
639
  }
281
640
 
282
641
  const content = await readFile(sourceFile);
283
642
  const offset = state.offset <= content.length ? state.offset : 0;
284
- const segment = content.subarray(offset).toString('utf8');
285
- const lines = segment.split(/\r?\n/u).filter((line) => line.includes(MARKER));
286
- const records = lines.map(parseRecord).filter(Boolean);
643
+ const segment = content.subarray(offset).toString("utf8");
644
+ const lines = segment
645
+ .split(/\r?\n/u)
646
+ .filter((line) => line.includes(MARKER) || line.includes(STARTUP_MARKER));
647
+ const records = lines
648
+ .filter((line) => line.includes(MARKER))
649
+ .map((line) => parseRecord(line, MARKER))
650
+ .filter(Boolean);
651
+ const startupRecords = lines
652
+ .filter((line) => line.includes(STARTUP_MARKER))
653
+ .map((line) => parseRecord(line, STARTUP_MARKER))
654
+ .filter(Boolean);
287
655
  const requireRecords = node.require_records !== false;
288
656
  const requiredStages = Array.isArray(node.required_stages)
289
657
  ? node.required_stages.map(String)
290
658
  : [];
291
- const presentStages = new Set(records.map((record) => String(record.stage ?? '')));
292
- const missingStages = requiredStages.filter((stage) => !presentStages.has(stage));
659
+ const presentStages = new Set(
660
+ records.map((record) => String(record.stage ?? "")),
661
+ );
662
+ const missingStages = requiredStages.filter(
663
+ (stage) => !presentStages.has(stage),
664
+ );
665
+ const requiredStartupStages = Array.isArray(node.required_startup_stages)
666
+ ? node.required_startup_stages.map(String)
667
+ : [];
668
+ const presentStartupStages = new Set(
669
+ startupRecords.map((record) => String(record.stage ?? "")),
670
+ );
671
+ const missingStartupStages = requiredStartupStages.filter(
672
+ (stage) => !presentStartupStages.has(stage),
673
+ );
293
674
  const requiredLifecycles = Array.isArray(node.required_lifecycles)
294
675
  ? node.required_lifecycles.map(String)
295
676
  : [];
296
- const presentLifecycles = new Set(records.map((record) => String(record.lifecycle ?? '')));
677
+ const presentLifecycles = new Set(
678
+ records.map((record) => String(record.lifecycle ?? "")),
679
+ );
297
680
  const missingLifecycles = requiredLifecycles.filter(
298
681
  (lifecycle) => !presentLifecycles.has(lifecycle),
299
682
  );
300
683
  const requiredSources = Array.isArray(node.required_sources)
301
684
  ? node.required_sources.map(String)
302
685
  : [];
303
- const presentSources = new Set(records.map((record) => String(record.source ?? '')));
304
- const missingSources = requiredSources.filter((source) => !presentSources.has(source));
305
- const requiredFreshContentVariants = Array.isArray(node.required_fresh_content_variants)
686
+ const presentSources = new Set(
687
+ records.map((record) => String(record.source ?? "")),
688
+ );
689
+ const missingSources = requiredSources.filter(
690
+ (source) => !presentSources.has(source),
691
+ );
692
+ const requiredFreshContentVariants = Array.isArray(
693
+ node.required_fresh_content_variants,
694
+ )
306
695
  ? node.required_fresh_content_variants.map(String)
307
696
  : [];
308
- const measurements = visibleMeasurements(records, requiredFreshContentVariants);
697
+ const measurements = visibleMeasurements(
698
+ records,
699
+ requiredFreshContentVariants,
700
+ );
701
+ const requiredFreshLifecycles = Array.isArray(node.required_fresh_lifecycles)
702
+ ? node.required_fresh_lifecycles.map(String)
703
+ : [];
704
+ const missingFreshLifecycles = requiredFreshLifecycles.filter(
705
+ (lifecycle) =>
706
+ !measurements.some(
707
+ (measurement) =>
708
+ measurement.lifecycle === lifecycle && measurement.dfdMs !== null,
709
+ ),
710
+ );
309
711
  const presentFreshContentVariants = new Set(
310
712
  measurements
311
713
  .filter((measurement) => measurement.dfdMs !== null)
312
- .map((measurement) => String(measurement.freshContentVariant ?? '')),
714
+ .map((measurement) => String(measurement.freshContentVariant ?? "")),
313
715
  );
314
716
  const missingFreshContentVariants = requiredFreshContentVariants.filter(
315
717
  (variant) =>
@@ -317,58 +719,124 @@ export async function captureHomepagePerformance(input) {
317
719
  contentVariantSatisfies(actual, variant),
318
720
  ),
319
721
  );
722
+ const firstFreshDeliveryRequirements = Array.isArray(
723
+ node.first_fresh_delivery_requirements,
724
+ )
725
+ ? node.first_fresh_delivery_requirements
726
+ : [];
727
+ const deliveryChecks = firstFreshDeliveryChecks(
728
+ records,
729
+ firstFreshDeliveryRequirements,
730
+ );
731
+ const failedFirstFreshDeliveries = deliveryChecks.filter(
732
+ (check) => check.status === "fail",
733
+ );
734
+ const requireCacheBeforeFresh = node.require_cache_before_fresh === true;
735
+ const cacheTakeover = cacheTakeoverMeasurement(
736
+ records,
737
+ requiredFreshContentVariants,
738
+ );
320
739
 
321
- const prefix = String(node.artifact_prefix ?? 'logs/homepage-performance');
740
+ const prefix = String(node.artifact_prefix ?? "logs/homepage-performance");
322
741
  const logPath = `${prefix}.log`;
323
742
  const summaryPath = `${prefix}-summary.json`;
324
- const logFile = resolveWithin(artifactsDir, logPath, 'artifact_prefix');
325
- const summaryFile = resolveWithin(artifactsDir, summaryPath, 'artifact_prefix');
743
+ const logFile = resolveWithin(artifactsDir, logPath, "artifact_prefix");
744
+ const summaryFile = resolveWithin(
745
+ artifactsDir,
746
+ summaryPath,
747
+ "artifact_prefix",
748
+ );
326
749
  const validation = {
327
750
  status:
328
751
  (!requireRecords || records.length > 0) &&
329
752
  missingStages.length === 0 &&
753
+ missingStartupStages.length === 0 &&
330
754
  missingLifecycles.length === 0 &&
331
755
  missingSources.length === 0 &&
332
- missingFreshContentVariants.length === 0
333
- ? 'pass'
334
- : 'fail',
756
+ missingFreshLifecycles.length === 0 &&
757
+ missingFreshContentVariants.length === 0 &&
758
+ failedFirstFreshDeliveries.length === 0 &&
759
+ (!requireCacheBeforeFresh || cacheTakeover.status === "pass")
760
+ ? "pass"
761
+ : "fail",
335
762
  requireRecords,
336
763
  missingStages,
764
+ missingStartupStages,
337
765
  missingLifecycles,
338
766
  missingSources,
767
+ missingFreshLifecycles,
339
768
  missingFreshContentVariants,
769
+ failedFirstFreshDeliveries,
770
+ cacheBeforeFresh: requireCacheBeforeFresh
771
+ ? cacheTakeover.status
772
+ : "not_required",
340
773
  };
341
774
  const summary = {
342
775
  ...summarize(
343
776
  records,
777
+ startupRecords,
344
778
  sourcePath,
345
779
  offset,
346
780
  content.length - offset,
347
781
  requiredFreshContentVariants,
782
+ firstFreshDeliveryRequirements,
348
783
  ),
349
784
  validation,
350
785
  };
351
- const nodeId = String(input.context?.nodeId ?? 'capture-performance');
786
+ const nodeId = String(input.context?.nodeId ?? "capture-performance");
352
787
  await mkdir(path.dirname(logFile), { recursive: true });
353
- await writeFile(logFile, lines.length > 0 ? `${lines.join('\n')}\n` : '');
788
+ await writeFile(logFile, lines.length > 0 ? `${lines.join("\n")}\n` : "");
354
789
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
355
790
  await rm(stateFile, { force: true });
356
791
 
357
792
  if (requireRecords && records.length === 0) {
358
- throw new Error(`No ${MARKER} records were emitted after the capture started.`);
793
+ throw new Error(
794
+ `No ${MARKER} records were emitted after the capture started.`,
795
+ );
359
796
  }
360
797
  if (missingStages.length > 0) {
361
- throw new Error(`Missing required ${MARKER} stages: ${missingStages.join(', ')}.`);
798
+ throw new Error(
799
+ `Missing required ${MARKER} stages: ${missingStages.join(", ")}.`,
800
+ );
801
+ }
802
+ if (missingStartupStages.length > 0) {
803
+ throw new Error(
804
+ `Missing required ${STARTUP_MARKER} stages: ${missingStartupStages.join(", ")}.`,
805
+ );
362
806
  }
363
807
  if (missingLifecycles.length > 0) {
364
- throw new Error(`Missing required ${MARKER} lifecycles: ${missingLifecycles.join(', ')}.`);
808
+ throw new Error(
809
+ `Missing required ${MARKER} lifecycles: ${missingLifecycles.join(", ")}.`,
810
+ );
365
811
  }
366
812
  if (missingSources.length > 0) {
367
- throw new Error(`Missing required ${MARKER} sources: ${missingSources.join(', ')}.`);
813
+ throw new Error(
814
+ `Missing required ${MARKER} sources: ${missingSources.join(", ")}.`,
815
+ );
816
+ }
817
+ if (missingFreshLifecycles.length > 0) {
818
+ throw new Error(
819
+ `Missing required fresh ${MARKER} lifecycles: ${missingFreshLifecycles.join(", ")}.`,
820
+ );
368
821
  }
369
822
  if (missingFreshContentVariants.length > 0) {
370
823
  throw new Error(
371
- `Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(', ')}.`,
824
+ `Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(", ")}.`,
825
+ );
826
+ }
827
+ if (failedFirstFreshDeliveries.length > 0) {
828
+ const failures = failedFirstFreshDeliveries.map(
829
+ (check) =>
830
+ `${check.stream}/${check.lifecycle ?? "any"} throttle=${check.subscriberThrottleMs}ms ` +
831
+ `measured=${check.socketToSubscriberMs ?? "missing"}ms max=${check.maxSocketToSubscriberMs}ms`,
832
+ );
833
+ throw new Error(
834
+ `First fresh ${MARKER} delivery requirements failed: ${failures.join("; ")}.`,
835
+ );
836
+ }
837
+ if (requireCacheBeforeFresh && cacheTakeover.status !== "pass") {
838
+ throw new Error(
839
+ `Cache-before-fresh ${MARKER} requirement failed: ${cacheTakeover.failureReason}.`,
372
840
  );
373
841
  }
374
842
 
@@ -376,8 +844,8 @@ export async function captureHomepagePerformance(input) {
376
844
  phase,
377
845
  ...summary,
378
846
  artifacts: [
379
- { path: logPath, type: 'log', nodeId },
380
- { path: summaryPath, type: 'report', nodeId },
847
+ { path: logPath, type: "log", nodeId },
848
+ { path: summaryPath, type: "report", nodeId },
381
849
  ],
382
850
  };
383
851
  }