@deeeed/metamask-harness 0.34.1 → 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.
@@ -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,74 +332,143 @@ 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
+ ];
205
472
  });
206
473
  }
207
474
 
@@ -218,8 +485,8 @@ function firstFreshDeliveryChecks(records, requirements) {
218
485
  );
219
486
  const socket = records.find(
220
487
  (record) =>
221
- record.stage === 'socket_received' &&
222
- record.source === 'fresh_socket' &&
488
+ record.stage === "socket_received" &&
489
+ record.source === "fresh_socket" &&
223
490
  record.stream === stream &&
224
491
  (requiredLifecycle === null ||
225
492
  record.lifecycle === requiredLifecycle) &&
@@ -228,7 +495,7 @@ function firstFreshDeliveryChecks(records, requirements) {
228
495
  const delivery = socket
229
496
  ? records.find(
230
497
  (record) =>
231
- record.stage === 'subscriber_delivery' &&
498
+ record.stage === "subscriber_delivery" &&
232
499
  record.delivery_id === socket.delivery_id &&
233
500
  record.stream === stream &&
234
501
  record.lifecycle === socket.lifecycle &&
@@ -251,20 +518,25 @@ function firstFreshDeliveryChecks(records, requirements) {
251
518
  status:
252
519
  socketToSubscriberMs !== null &&
253
520
  socketToSubscriberMs <= maxSocketToSubscriberMs
254
- ? 'pass'
255
- : 'fail',
521
+ ? "pass"
522
+ : "fail",
256
523
  };
257
524
  });
258
525
  }
259
526
 
260
527
  function summarize(
261
528
  records,
529
+ startupRecords,
262
530
  sourcePath,
263
531
  offset,
264
532
  bytesScanned,
265
533
  requiredFreshContentVariants = [],
266
534
  firstFreshDeliveryRequirements = [],
267
535
  ) {
536
+ const measurements = visibleMeasurements(
537
+ records,
538
+ requiredFreshContentVariants,
539
+ );
268
540
  const stages = {};
269
541
  const lifecycles = {};
270
542
  const streams = {};
@@ -278,6 +550,10 @@ function summarize(
278
550
  const monotonicValues = records
279
551
  .map((record) => Number(record.monotonic_ms))
280
552
  .filter(Number.isFinite);
553
+ const startupStages = {};
554
+ for (const record of startupRecords) {
555
+ increment(startupStages, record.stage);
556
+ }
281
557
  return {
282
558
  schemaVersion: 1,
283
559
  marker: MARKER,
@@ -285,13 +561,24 @@ function summarize(
285
561
  offset,
286
562
  bytesScanned,
287
563
  recordCount: records.length,
564
+ startupMarker: STARTUP_MARKER,
565
+ startupRecordCount: startupRecords.length,
566
+ startupStages,
567
+ startupMeasurements: startupMeasurements(startupRecords),
288
568
  stages,
289
569
  lifecycles,
290
570
  streams,
291
571
  sources,
292
- firstMonotonicMs: monotonicValues.length > 0 ? Math.min(...monotonicValues) : null,
293
- lastMonotonicMs: monotonicValues.length > 0 ? Math.max(...monotonicValues) : null,
294
- 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
+ ),
295
582
  socketPipelineMeasurements: socketPipelineMeasurements(records),
296
583
  firstFreshDeliveryChecks: firstFreshDeliveryChecks(
297
584
  records,
@@ -302,71 +589,129 @@ function summarize(
302
589
 
303
590
  export async function captureHomepagePerformance(input) {
304
591
  const node = input.node ?? {};
305
- const phase = String(node.phase ?? '').toLowerCase();
306
- if (phase !== 'start' && phase !== 'end') {
307
- 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
+ );
308
597
  }
309
598
 
310
599
  const projectRoot = input.context?.projectRoot;
311
600
  const artifactsDir = input.context?.artifactsDir;
312
601
  if (!projectRoot || !artifactsDir) {
313
- 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
+ );
314
605
  }
315
- const sourcePath = String(node.source_path ?? 'temp/recipe/runtime/app-console.log');
316
- const sourceFile = resolveWithin(projectRoot, sourcePath, 'source_path');
317
- 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");
318
611
  await mkdir(artifactsDir, { recursive: true });
319
612
 
320
- if (phase === 'start') {
613
+ if (phase === "start") {
321
614
  const offset = await fileSize(sourceFile);
322
- await writeFile(stateFile, `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`);
615
+ await writeFile(
616
+ stateFile,
617
+ `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`,
618
+ );
323
619
  return { phase, sourcePath, offset };
324
620
  }
325
621
 
326
622
  let state;
327
623
  try {
328
- state = JSON.parse(await readFile(stateFile, 'utf8'));
624
+ state = JSON.parse(await readFile(stateFile, "utf8"));
329
625
  } catch (error) {
330
626
  throw new Error(
331
- '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.",
332
628
  { cause: error },
333
629
  );
334
630
  }
335
- if (state.sourcePath !== sourcePath || !Number.isInteger(state.offset) || state.offset < 0) {
336
- 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
+ );
337
639
  }
338
640
 
339
641
  const content = await readFile(sourceFile);
340
642
  const offset = state.offset <= content.length ? state.offset : 0;
341
- const segment = content.subarray(offset).toString('utf8');
342
- const lines = segment.split(/\r?\n/u).filter((line) => line.includes(MARKER));
343
- 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);
344
655
  const requireRecords = node.require_records !== false;
345
656
  const requiredStages = Array.isArray(node.required_stages)
346
657
  ? node.required_stages.map(String)
347
658
  : [];
348
- const presentStages = new Set(records.map((record) => String(record.stage ?? '')));
349
- 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
+ );
350
674
  const requiredLifecycles = Array.isArray(node.required_lifecycles)
351
675
  ? node.required_lifecycles.map(String)
352
676
  : [];
353
- const presentLifecycles = new Set(records.map((record) => String(record.lifecycle ?? '')));
677
+ const presentLifecycles = new Set(
678
+ records.map((record) => String(record.lifecycle ?? "")),
679
+ );
354
680
  const missingLifecycles = requiredLifecycles.filter(
355
681
  (lifecycle) => !presentLifecycles.has(lifecycle),
356
682
  );
357
683
  const requiredSources = Array.isArray(node.required_sources)
358
684
  ? node.required_sources.map(String)
359
685
  : [];
360
- const presentSources = new Set(records.map((record) => String(record.source ?? '')));
361
- const missingSources = requiredSources.filter((source) => !presentSources.has(source));
362
- 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
+ )
363
695
  ? node.required_fresh_content_variants.map(String)
364
696
  : [];
365
- 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
+ );
366
711
  const presentFreshContentVariants = new Set(
367
712
  measurements
368
713
  .filter((measurement) => measurement.dfdMs !== null)
369
- .map((measurement) => String(measurement.freshContentVariant ?? '')),
714
+ .map((measurement) => String(measurement.freshContentVariant ?? "")),
370
715
  );
371
716
  const missingFreshContentVariants = requiredFreshContentVariants.filter(
372
717
  (variant) =>
@@ -384,34 +729,52 @@ export async function captureHomepagePerformance(input) {
384
729
  firstFreshDeliveryRequirements,
385
730
  );
386
731
  const failedFirstFreshDeliveries = deliveryChecks.filter(
387
- (check) => check.status === 'fail',
732
+ (check) => check.status === "fail",
733
+ );
734
+ const requireCacheBeforeFresh = node.require_cache_before_fresh === true;
735
+ const cacheTakeover = cacheTakeoverMeasurement(
736
+ records,
737
+ requiredFreshContentVariants,
388
738
  );
389
739
 
390
- const prefix = String(node.artifact_prefix ?? 'logs/homepage-performance');
740
+ const prefix = String(node.artifact_prefix ?? "logs/homepage-performance");
391
741
  const logPath = `${prefix}.log`;
392
742
  const summaryPath = `${prefix}-summary.json`;
393
- const logFile = resolveWithin(artifactsDir, logPath, 'artifact_prefix');
394
- 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
+ );
395
749
  const validation = {
396
750
  status:
397
751
  (!requireRecords || records.length > 0) &&
398
752
  missingStages.length === 0 &&
753
+ missingStartupStages.length === 0 &&
399
754
  missingLifecycles.length === 0 &&
400
755
  missingSources.length === 0 &&
756
+ missingFreshLifecycles.length === 0 &&
401
757
  missingFreshContentVariants.length === 0 &&
402
- failedFirstFreshDeliveries.length === 0
403
- ? 'pass'
404
- : 'fail',
758
+ failedFirstFreshDeliveries.length === 0 &&
759
+ (!requireCacheBeforeFresh || cacheTakeover.status === "pass")
760
+ ? "pass"
761
+ : "fail",
405
762
  requireRecords,
406
763
  missingStages,
764
+ missingStartupStages,
407
765
  missingLifecycles,
408
766
  missingSources,
767
+ missingFreshLifecycles,
409
768
  missingFreshContentVariants,
410
769
  failedFirstFreshDeliveries,
770
+ cacheBeforeFresh: requireCacheBeforeFresh
771
+ ? cacheTakeover.status
772
+ : "not_required",
411
773
  };
412
774
  const summary = {
413
775
  ...summarize(
414
776
  records,
777
+ startupRecords,
415
778
  sourcePath,
416
779
  offset,
417
780
  content.length - offset,
@@ -420,37 +783,60 @@ export async function captureHomepagePerformance(input) {
420
783
  ),
421
784
  validation,
422
785
  };
423
- const nodeId = String(input.context?.nodeId ?? 'capture-performance');
786
+ const nodeId = String(input.context?.nodeId ?? "capture-performance");
424
787
  await mkdir(path.dirname(logFile), { recursive: true });
425
- await writeFile(logFile, lines.length > 0 ? `${lines.join('\n')}\n` : '');
788
+ await writeFile(logFile, lines.length > 0 ? `${lines.join("\n")}\n` : "");
426
789
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
427
790
  await rm(stateFile, { force: true });
428
791
 
429
792
  if (requireRecords && records.length === 0) {
430
- 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
+ );
431
796
  }
432
797
  if (missingStages.length > 0) {
433
- 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
+ );
434
806
  }
435
807
  if (missingLifecycles.length > 0) {
436
- throw new Error(`Missing required ${MARKER} lifecycles: ${missingLifecycles.join(', ')}.`);
808
+ throw new Error(
809
+ `Missing required ${MARKER} lifecycles: ${missingLifecycles.join(", ")}.`,
810
+ );
437
811
  }
438
812
  if (missingSources.length > 0) {
439
- 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
+ );
440
821
  }
441
822
  if (missingFreshContentVariants.length > 0) {
442
823
  throw new Error(
443
- `Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(', ')}.`,
824
+ `Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(", ")}.`,
444
825
  );
445
826
  }
446
827
  if (failedFirstFreshDeliveries.length > 0) {
447
828
  const failures = failedFirstFreshDeliveries.map(
448
829
  (check) =>
449
- `${check.stream}/${check.lifecycle ?? 'any'} throttle=${check.subscriberThrottleMs}ms ` +
450
- `measured=${check.socketToSubscriberMs ?? 'missing'}ms max=${check.maxSocketToSubscriberMs}ms`,
830
+ `${check.stream}/${check.lifecycle ?? "any"} throttle=${check.subscriberThrottleMs}ms ` +
831
+ `measured=${check.socketToSubscriberMs ?? "missing"}ms max=${check.maxSocketToSubscriberMs}ms`,
451
832
  );
452
833
  throw new Error(
453
- `First fresh ${MARKER} delivery requirements failed: ${failures.join('; ')}.`,
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}.`,
454
840
  );
455
841
  }
456
842
 
@@ -458,8 +844,8 @@ export async function captureHomepagePerformance(input) {
458
844
  phase,
459
845
  ...summary,
460
846
  artifacts: [
461
- { path: logPath, type: 'log', nodeId },
462
- { path: summaryPath, type: 'report', nodeId },
847
+ { path: logPath, type: "log", nodeId },
848
+ { path: summaryPath, type: "report", nodeId },
463
849
  ],
464
850
  };
465
851
  }