@deeeed/metamask-harness 0.44.3 → 0.45.1

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.
@@ -5,8 +5,7 @@ import { withTimeout } from "../../async.js";
5
5
  import {
6
6
  summarizeJavaScriptTasks
7
7
  } from "./js-task-metrics.js";
8
- const FRAME_BUDGET_MS = 1e3 / 60;
9
- const MAX_TRACE_BYTES = 64 * 1024 * 1024;
8
+ const MAX_TRACE_BYTES = 16 * 1024 * 1024;
10
9
  const MAX_TRACE_EVENTS = 25e4;
11
10
  const MOBILE_TRACE_CATEGORIES = [
12
11
  "blink.user_timing",
@@ -141,7 +140,7 @@ function parseTraceCapture(capture) {
141
140
  const trace = traceEvidence(
142
141
  { ...capture, events: scopedEvents },
143
142
  scope,
144
- finite(marker?.pid) ? marker.pid : void 0
143
+ capture.platform === "extension" && finite(marker?.pid) ? marker.pid : void 0
145
144
  );
146
145
  if (!finite(marker?.ts) || capture.markerHostEpochMs === void 0 || capture.markerUncertaintyMs === void 0) {
147
146
  const reason = "CDP trace clock marker was unavailable.";
@@ -189,9 +188,15 @@ function parseTraceCapture(capture) {
189
188
  traceEventNames(scopedEvents),
190
189
  capture.markerUncertaintyMs
191
190
  ),
192
- trace
191
+ trace,
192
+ rawTraceEvents: normalizeTraceTimestamps(scopedEvents, traceToEpochMs)
193
193
  };
194
194
  }
195
+ function normalizeTraceTimestamps(events, traceToEpochMs) {
196
+ return events.map(
197
+ (event) => finite(event.ts) ? { ...event, ts: traceToEpochMs(event.ts) * 1e3 } : { ...event }
198
+ );
199
+ }
195
200
  function traceEvidence(capture, scope, rendererProcessId) {
196
201
  const counts = /* @__PURE__ */ new Map();
197
202
  for (const event of capture.events) {
@@ -226,6 +231,7 @@ function nativeFrameSamples(events, platform, traceToEpochMs) {
226
231
  frames.push({ beginUs: begin.ts, endUs: event.ts });
227
232
  }
228
233
  frames.sort((first, second) => first.beginUs - second.beginUs);
234
+ const frameBudgetMs = estimateFrameBudgetMs(frames);
229
235
  if (platform === "ios") {
230
236
  const samples = [];
231
237
  for (let index = 1; index < frames.length; index += 1) {
@@ -235,9 +241,14 @@ function nativeFrameSamples(events, platform, traceToEpochMs) {
235
241
  const durationMs = (current.beginUs - previous.beginUs) / 1e3;
236
242
  if (durationMs <= 0) continue;
237
243
  samples.push({
244
+ cadenceInterval: true,
238
245
  completedAtEpochMs: traceToEpochMs(current.beginUs),
239
246
  durationMs,
240
- janky: durationMs > FRAME_BUDGET_MS * 1.01
247
+ ...frameBudgetMs ? {
248
+ frameBudgetMs,
249
+ overBudget: durationMs > frameBudgetMs * 1.01
250
+ } : {},
251
+ startedAtEpochMs: traceToEpochMs(previous.beginUs)
241
252
  });
242
253
  }
243
254
  return samples;
@@ -247,10 +258,38 @@ function nativeFrameSamples(events, platform, traceToEpochMs) {
247
258
  return {
248
259
  completedAtEpochMs: traceToEpochMs(frame.endUs),
249
260
  durationMs,
250
- janky: durationMs > FRAME_BUDGET_MS * 1.01
261
+ ...frameBudgetMs ? {
262
+ frameBudgetMs,
263
+ overBudget: durationMs > frameBudgetMs * 1.01
264
+ } : {},
265
+ startedAtEpochMs: traceToEpochMs(frame.beginUs)
251
266
  };
252
267
  }).filter((sample) => sample.durationMs > 0);
253
268
  }
269
+ function estimateFrameBudgetMs(frames) {
270
+ const cadenceMs = [];
271
+ for (let index = 1; index < frames.length; index += 1) {
272
+ const previous = frames[index - 1];
273
+ const current = frames[index];
274
+ if (!previous || !current) continue;
275
+ const intervalMs = (current.beginUs - previous.beginUs) / 1e3;
276
+ if (intervalMs >= 3.5 && intervalMs <= 40) cadenceMs.push(intervalMs);
277
+ }
278
+ if (cadenceMs.length < 5) return void 0;
279
+ cadenceMs.sort((first, second) => first - second);
280
+ const observedBudgetMs = percentile(cadenceMs, 0.1);
281
+ const refreshRateHz = Math.round(1e3 / observedBudgetMs);
282
+ return refreshRateHz >= 24 && refreshRateHz <= 240 ? 1e3 / refreshRateHz : void 0;
283
+ }
284
+ function percentile(sorted, quantile) {
285
+ if (sorted.length === 1) return sorted[0] ?? 0;
286
+ const position = (sorted.length - 1) * quantile;
287
+ const lower = Math.floor(position);
288
+ const upper = Math.ceil(position);
289
+ const lowerValue = sorted[lower] ?? 0;
290
+ const upperValue = sorted[upper] ?? lowerValue;
291
+ return lowerValue + (upperValue - lowerValue) * (position - lower);
292
+ }
254
293
  function javascriptTaskSamples(events, traceToEpochMs) {
255
294
  return events.filter(
256
295
  (event) => event.name === "RunTask" && event.ph === "X" && finite(event.ts) && finite(event.dur) && event.dur > 0
@@ -272,6 +311,7 @@ function frameSequence(event) {
272
311
  }
273
312
  function javascriptSourceResult(kind, samples, status, coverageGapReasons, observedEventNames, clockSyncUncertaintyMs) {
274
313
  return {
314
+ applicability: "applicable",
275
315
  status: samples.length > 0 ? status : "partial",
276
316
  kind,
277
317
  unavailableReasons: [],
@@ -287,18 +327,23 @@ function javascriptSourceResult(kind, samples, status, coverageGapReasons, obser
287
327
  };
288
328
  }
289
329
  function nativeUiSourceResult(kind, samples, status, coverageGapReasons, observedEventNames, clockSyncUncertaintyMs) {
330
+ const summary = summarizeFrames(samples);
331
+ const hasUnclassifiedCadenceGap = (summary.cadenceGapCount ?? 0) > 0;
290
332
  return {
291
- status: samples.length > 0 ? status : "partial",
333
+ status: samples.length === 0 ? "partial" : hasUnclassifiedCadenceGap ? "partial" : status,
292
334
  kind,
293
335
  unavailableReasons: [],
294
336
  coverageGapReasons: [
295
337
  ...coverageGapReasons,
296
338
  ...samples.length > 0 ? [] : [
297
339
  `No ${kind} samples were recorded. Observed trace events: ${observedEventNames.join(", ") || "none"}.`
298
- ]
340
+ ],
341
+ ...hasUnclassifiedCadenceGap ? [
342
+ "Unclassified native cadence gap prevents a complete FPS result."
343
+ ] : []
299
344
  ],
300
345
  samples,
301
- summary: summarizeFrames(samples),
346
+ summary,
302
347
  clockSyncUncertaintyMs
303
348
  };
304
349
  }
@@ -312,6 +357,7 @@ function traceEventNames(events) {
312
357
  }
313
358
  function unavailableJavaScriptSource(kind, reason) {
314
359
  return {
360
+ applicability: "applicable",
315
361
  status: "unavailable",
316
362
  kind,
317
363
  unavailableReasons: [reason],
@@ -34,7 +34,6 @@ import {
34
34
  executeWithHealBounds,
35
35
  prepareHeal,
36
36
  persistRunEffects,
37
- recoverRunInfra,
38
37
  resolveMetaMaskLibrarySources,
39
38
  preflightRecipe,
40
39
  runRecipe,
@@ -289,7 +288,7 @@ async function handleCall(argv) {
289
288
  skipMobileSourceFreshness: authoredMobileRestart || adapter === "mobile" && readMobileReleaseArtifactState(target) !== null
290
289
  });
291
290
  if (typeof prepared === "number") return prepared;
292
- const { state, heal } = prepared;
291
+ const { state } = prepared;
293
292
  preflightedExecution = await preflightRecipe(
294
293
  adapter,
295
294
  recipe,
@@ -335,14 +334,8 @@ async function handleCall(argv) {
335
334
  state
336
335
  );
337
336
  },
338
- adapter,
339
337
  target,
340
- heal,
341
- state,
342
- () => recoverRunInfra(adapter, target, json, {
343
- cdpPort: optionString(options, "cdpPort"),
344
- watcherPort: optionString(options, "watcherPort") ?? optionString(options, "metroPort")
345
- })
338
+ state
346
339
  );
347
340
  } catch (error) {
348
341
  await networkObservation?.finalize().catch(() => void 0);
@@ -1358,44 +1358,6 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
1358
1358
  });
1359
1359
  return null;
1360
1360
  }
1361
- const RUN_RECOVERY_CODE = {
1362
- mobile: "mobile.runtime_recovered",
1363
- extension: "extension.runtime_recovered",
1364
- core: "run.retried"
1365
- };
1366
- async function recoverRunInfra(adapter, target, json, runtimeOptions = {}) {
1367
- getAdapterSurface(adapter).resolveSlotPorts(target);
1368
- const previousCdpPort = process.env.CDP_PORT;
1369
- const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
1370
- if (runtimeOptions.cdpPort) {
1371
- process.env.CDP_PORT = runtimeOptions.cdpPort;
1372
- process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
1373
- }
1374
- try {
1375
- if (adapter === "extension") {
1376
- const { releaseArtifactState } = await import("../adapters/extension/runtime-decision.js");
1377
- const artifact = releaseArtifactState(target);
1378
- if (artifact.status !== "none" && artifact.status !== "valid") {
1379
- return {
1380
- status: 1,
1381
- output: "Release artifact recovery cannot trust the recorded runtime identity.\nNext: rerun runtime-launch with the same artifact source."
1382
- };
1383
- }
1384
- const { launchExtension } = await import("./launch/extension.js");
1385
- return launchExtension(target, "quick", false);
1386
- }
1387
- if (adapter === "mobile") {
1388
- return {
1389
- status: 1,
1390
- output: "Automatic Mobile runtime recovery is disabled.\nNext: use an explicit app.lifecycle action when the recipe intends to relaunch the app."
1391
- };
1392
- }
1393
- return { status: 0, output: "headless run retry" };
1394
- } finally {
1395
- restoreEnv("CDP_PORT", previousCdpPort);
1396
- restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
1397
- }
1398
- }
1399
1361
  function readRunFailureText(result) {
1400
1362
  try {
1401
1363
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
@@ -1407,38 +1369,13 @@ function readRunFailureText(result) {
1407
1369
  return "";
1408
1370
  }
1409
1371
  }
1410
- async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false), onRecovery = () => void 0) {
1411
- let result = await exec();
1412
- for (; ; ) {
1413
- if (result.status === "pass") return { result, violation: null };
1414
- const violation = checkHealBounds(
1415
- target,
1416
- readRunFailureText(result),
1417
- state
1418
- );
1419
- if (violation !== null) return { result, violation };
1420
- if (adapter === "mobile") return { result, violation: null };
1421
- if (heal === "off") return { result, violation: null };
1422
- const recoveryCode = RUN_RECOVERY_CODE[adapter];
1423
- state.attemptedRecoveries.push(recoveryCode);
1424
- onRecovery(recoveryCode);
1425
- const recovery = await recover();
1426
- if (recovery.status !== 0) {
1427
- return { result, violation: null };
1428
- }
1429
- if (adapter === "extension") {
1430
- state.mutations.push({
1431
- type: "runtime",
1432
- action: "reloaded",
1433
- reasonCode: recoveryCode
1434
- });
1435
- }
1436
- result = await exec();
1437
- if (result.status === "pass") {
1438
- state.recovered.push(recoveryCode);
1439
- return { result, violation: null };
1440
- }
1441
- }
1372
+ async function executeWithHealBounds(exec, target, state) {
1373
+ const result = await exec();
1374
+ if (result.status === "pass") return { result, violation: null };
1375
+ return {
1376
+ result,
1377
+ violation: checkHealBounds(target, readRunFailureText(result), state)
1378
+ };
1442
1379
  }
1443
1380
  function emitHealViolation(json, command, result, violation, state, adapter) {
1444
1381
  const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'create temp/recipe/runtime/wallet-fixture.json, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction ?? `inspect ${shellQuoteArg(result.summaryPath)} and ${shellQuoteArg(result.tracePath)}; fix the application or recipe failure before retrying`;
@@ -1499,7 +1436,6 @@ export {
1499
1436
  preflightRecipe,
1500
1437
  prepareHeal,
1501
1438
  prepareRuntimeIfNeeded,
1502
- recoverRunInfra,
1503
1439
  resolveMetaMaskLibrarySources,
1504
1440
  resolveRunRecipeArg,
1505
1441
  runOneNode,
@@ -22,7 +22,6 @@ import {
22
22
  preflightRecipe,
23
23
  prepareHeal,
24
24
  persistRunEffects,
25
- recoverRunInfra,
26
25
  runRecipe,
27
26
  validateRunRecipeStatic
28
27
  } from "./run-engine.js";
@@ -304,7 +303,7 @@ async function handleRunInner({ positional, options }, stream) {
304
303
  });
305
304
  return prepared;
306
305
  }
307
- const { state, heal } = prepared;
306
+ const { state } = prepared;
308
307
  preflightedExecution = await preflightRecipe(
309
308
  adapter,
310
309
  validated.recipeFile,
@@ -353,12 +352,8 @@ async function handleRunInner({ positional, options }, stream) {
353
352
  state
354
353
  );
355
354
  },
356
- adapter,
357
355
  target,
358
- heal,
359
- state,
360
- () => recoverRunInfra(adapter, target, machine, runtimeOptions),
361
- (code) => stream.phase("recover", { code })
356
+ state
362
357
  );
363
358
  } catch (error) {
364
359
  await networkObservation?.finalize().catch(() => void 0);
@@ -1,4 +1,6 @@
1
1
  import path from "node:path";
2
+ import { promisify } from "node:util";
3
+ import { gzip } from "node:zlib";
2
4
  import {
3
5
  createMobilePerformanceBackend
4
6
  } from "./adapters/mobile/performance-observer.js";
@@ -13,6 +15,7 @@ import {
13
15
  const sessions = /* @__PURE__ */ new Map();
14
16
  const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1e3;
15
17
  const MAX_DURATION_MS = 60 * 60 * 1e3;
18
+ const gzipAsync = promisify(gzip);
16
19
  function startRunPerformanceObservation(adapter, target, artifactsDir, env, ports = {}) {
17
20
  if (adapter === "core") return void 0;
18
21
  const key = path.resolve(artifactsDir);
@@ -31,8 +34,8 @@ function startRunPerformanceObservation(adapter, target, artifactsDir, env, port
31
34
  session.expirySweep = setInterval(() => {
32
35
  const now = Date.now();
33
36
  for (const window of session.windows.values()) {
34
- if (!window.expiredResult && now - window.startedAtEpochMs >= window.maxDurationMs) {
35
- window.expiredResult = endCapture(session, window);
37
+ if (!window.result && now - window.startedAtEpochMs >= window.maxDurationMs) {
38
+ void finishCaptureWindow(session, window);
36
39
  }
37
40
  }
38
41
  }, 100);
@@ -58,11 +61,17 @@ function startRunPerformanceObservation(adapter, target, artifactsDir, env, port
58
61
  };
59
62
  const jsonPath = `performance/${safeId(window.id)}-summary.json`;
60
63
  const htmlPath = `performance/${safeId(window.id)}.html`;
61
- await writeArtifacts(session.artifactsDir, jsonPath, htmlPath, artifact);
64
+ const rawTracePath = await writeArtifacts(
65
+ session.artifactsDir,
66
+ jsonPath,
67
+ htmlPath,
68
+ artifact
69
+ );
62
70
  if (artifactManifestPath) {
63
71
  await indexPerformanceArtifacts(artifactManifestPath, [
64
72
  jsonPath,
65
- htmlPath
73
+ htmlPath,
74
+ ...rawTracePath ? [rawTracePath] : []
66
75
  ]);
67
76
  }
68
77
  }
@@ -118,7 +127,12 @@ async function handleRunPerformanceAction(platform, action, node, context) {
118
127
  const htmlPath = String(
119
128
  node.html_path ?? `performance/${safeId(id)}.html`
120
129
  );
121
- await writeArtifacts(session.artifactsDir, jsonPath, htmlPath, artifact);
130
+ const rawTracePath = await writeArtifacts(
131
+ session.artifactsDir,
132
+ jsonPath,
133
+ htmlPath,
134
+ artifact
135
+ );
122
136
  return {
123
137
  output: {
124
138
  action,
@@ -126,11 +140,13 @@ async function handleRunPerformanceAction(platform, action, node, context) {
126
140
  phase,
127
141
  status: artifact.status,
128
142
  artifactPath: jsonPath,
129
- htmlPath
143
+ htmlPath,
144
+ ...rawTracePath ? { rawTracePath } : {}
130
145
  },
131
146
  artifacts: [
132
147
  { path: jsonPath, type: "metric", nodeId: context.nodeId },
133
- { path: htmlPath, type: "report", nodeId: context.nodeId }
148
+ { path: htmlPath, type: "report", nodeId: context.nodeId },
149
+ ...rawTracePath ? [{ path: rawTracePath, type: "trace", nodeId: context.nodeId }] : []
134
150
  ]
135
151
  };
136
152
  }
@@ -159,24 +175,41 @@ async function endCapture(session, window) {
159
175
  );
160
176
  }
161
177
  function finishCaptureWindow(session, window) {
162
- return window.expiredResult ?? endCapture(session, window);
178
+ if (!window.result) {
179
+ window.result = endCapture(session, window);
180
+ void window.result.catch(() => void 0);
181
+ }
182
+ return window.result;
163
183
  }
164
184
  function buildPerformanceArtifact(window, endedAtEpochMs, result, allEvents) {
185
+ const javascriptTasksApplicable = result.platform === "extension";
186
+ const javascript = javascriptTasksApplicable ? result.javascript : {
187
+ ...result.javascript,
188
+ applicability: "not_applicable",
189
+ status: "unavailable",
190
+ kind: "not-applicable-mobile-js-tasks",
191
+ unavailableReasons: [],
192
+ coverageGapReasons: [],
193
+ samples: [],
194
+ summary: {}
195
+ };
165
196
  const nodeEvents = allEvents.filter(
166
197
  (event) => event.epochMs >= window.startedAtEpochMs && event.epochMs <= endedAtEpochMs
167
198
  );
168
199
  const nodeIntervals = buildNodeIntervals(
169
200
  nodeEvents,
170
- result.javascript,
201
+ javascript,
171
202
  result.nativeUi
172
203
  );
173
204
  const coverageGapReasons = [
174
- ...result.javascript.unavailableReasons.map(
175
- (reason) => `javascript unavailable: ${reason}`
176
- ),
177
- ...result.javascript.coverageGapReasons.map(
178
- (reason) => `javascript: ${reason}`
179
- ),
205
+ ...javascriptTasksApplicable ? [
206
+ ...javascript.unavailableReasons.map(
207
+ (reason) => `javascript unavailable: ${reason}`
208
+ ),
209
+ ...javascript.coverageGapReasons.map(
210
+ (reason) => `javascript: ${reason}`
211
+ )
212
+ ] : [],
180
213
  ...result.nativeUi.unavailableReasons.map(
181
214
  (reason) => `nativeUi unavailable: ${reason}`
182
215
  ),
@@ -188,19 +221,20 @@ function buildPerformanceArtifact(window, endedAtEpochMs, result, allEvents) {
188
221
  if (exceededMaxDuration) {
189
222
  coverageGapReasons.push("Performance capture exceeded max_duration_ms.");
190
223
  }
191
- const status = exceededMaxDuration ? "partial" : result.javascript.status === "unavailable" && result.nativeUi.status === "unavailable" ? "unavailable" : result.javascript.status === "complete" && result.nativeUi.status === "complete" ? "complete" : "partial";
224
+ const status = exceededMaxDuration ? "partial" : javascriptTasksApplicable ? javascript.status === "unavailable" && result.nativeUi.status === "unavailable" ? "unavailable" : javascript.status === "complete" && result.nativeUi.status === "complete" ? "complete" : "partial" : result.nativeUi.status;
192
225
  const worst = worstInteraction(nodeIntervals);
193
226
  return {
194
- schemaVersion: 1,
227
+ schemaVersion: 2,
195
228
  id: window.id,
196
229
  status,
197
230
  platform: result.platform,
198
231
  startedAtEpochMs: window.startedAtEpochMs,
199
232
  endedAtEpochMs,
200
233
  durationMs: endedAtEpochMs - window.startedAtEpochMs,
201
- javascript: { ...result.javascript, samples: [] },
234
+ javascript: { ...javascript, samples: [] },
202
235
  nativeUi: { ...result.nativeUi, samples: [] },
203
236
  trace: result.trace,
237
+ ...result.rawTraceEvents ? { rawTraceEvents: result.rawTraceEvents } : {},
204
238
  nodeEvents,
205
239
  nodeIntervals,
206
240
  ...worst ? { worstInteraction: worst } : {},
@@ -222,9 +256,12 @@ function buildNodeIntervals(events, javascript, nativeUi) {
222
256
  action: event.action,
223
257
  durationMs: event.epochMs - start.epochMs,
224
258
  endedAtEpochMs: event.epochMs,
225
- javascript: summarizeJavaScriptTasks(
226
- samplesWithin(javascript, start.epochMs, event.epochMs)
227
- ),
259
+ javascript: javascript.applicability === "not_applicable" ? { applicability: "not_applicable" } : {
260
+ applicability: "applicable",
261
+ ...summarizeJavaScriptTasks(
262
+ samplesWithin(javascript, start.epochMs, event.epochMs)
263
+ )
264
+ },
228
265
  nativeUi: summarizeFrames(
229
266
  samplesWithin(nativeUi, start.epochMs, event.epochMs)
230
267
  ),
@@ -249,7 +286,10 @@ function worstInteraction(intervals) {
249
286
  source: "javascriptTask"
250
287
  },
251
288
  {
252
- longestSampleMs: interval.nativeUi.longestFrameMs,
289
+ longestSampleMs: Math.max(
290
+ interval.nativeUi.longestFrameMs ?? 0,
291
+ interval.nativeUi.longestCadenceGapMs ?? 0
292
+ ) || void 0,
253
293
  source: "nativeUiFrame"
254
294
  }
255
295
  ];
@@ -274,7 +314,7 @@ async function assertPerformance(node, context) {
274
314
  node.artifact_path ?? `performance/${safeId(id)}-summary.json`
275
315
  );
276
316
  const artifact = await readArtifact(context.artifactsDir, artifactPath);
277
- if (artifact.schemaVersion !== 1 || artifact.id !== id) {
317
+ if (artifact.schemaVersion !== 2 || artifact.id !== id) {
278
318
  throw new Error("app.performance_assert summary contract is invalid.");
279
319
  }
280
320
  const statuses = Array.isArray(node.required_status) ? node.required_status.map(String) : node.required_status ? [String(node.required_status)] : [];
@@ -320,10 +360,11 @@ async function assertPerformance(node, context) {
320
360
  };
321
361
  }
322
362
  async function writeArtifacts(artifactsDir, jsonPath, htmlPath, artifact) {
363
+ const { rawTraceEvents, ...summaryArtifact } = artifact;
323
364
  await writeContainedArtifact(
324
365
  artifactsDir,
325
366
  jsonPath,
326
- `${JSON.stringify(artifact, null, 2)}
367
+ `${JSON.stringify(summaryArtifact, null, 2)}
327
368
  `,
328
369
  "Performance artifact"
329
370
  );
@@ -333,8 +374,70 @@ async function writeArtifacts(artifactsDir, jsonPath, htmlPath, artifact) {
333
374
  renderPerformanceHtml(artifact),
334
375
  "Performance artifact"
335
376
  );
377
+ if (!rawTraceEvents) return void 0;
378
+ const traceEvents = [
379
+ ...rawTraceEvents,
380
+ ...recipeNodeTraceEvents(artifact.nodeIntervals)
381
+ ];
382
+ const rawTracePath = chromeTracePath(jsonPath);
383
+ await writeContainedArtifact(
384
+ artifactsDir,
385
+ rawTracePath,
386
+ await gzipAsync(
387
+ `${JSON.stringify({
388
+ traceEvents,
389
+ displayTimeUnit: "ms",
390
+ metadata: {
391
+ source: "@deeeed/metamask-harness",
392
+ captureId: artifact.id,
393
+ platform: artifact.platform,
394
+ startedAtEpochMs: artifact.startedAtEpochMs,
395
+ endedAtEpochMs: artifact.endedAtEpochMs
396
+ }
397
+ })}
398
+ `
399
+ ),
400
+ "Performance trace artifact"
401
+ );
402
+ return rawTracePath;
403
+ }
404
+ function recipeNodeTraceEvents(intervals) {
405
+ return [
406
+ {
407
+ name: "process_name",
408
+ ph: "M",
409
+ pid: 0,
410
+ tid: 0,
411
+ args: { name: "MetaMask Harness" }
412
+ },
413
+ {
414
+ name: "thread_name",
415
+ ph: "M",
416
+ pid: 0,
417
+ tid: 1,
418
+ args: { name: "Recipe nodes" }
419
+ },
420
+ ...intervals.map((interval) => ({
421
+ name: interval.action,
422
+ cat: "metamask.recipe.node",
423
+ ph: "X",
424
+ ts: interval.startedAtEpochMs * 1e3,
425
+ dur: interval.durationMs * 1e3,
426
+ pid: 0,
427
+ tid: 1,
428
+ args: {
429
+ nodeId: interval.nodeId,
430
+ outcome: interval.outcome
431
+ }
432
+ }))
433
+ ];
434
+ }
435
+ function chromeTracePath(jsonPath) {
436
+ return jsonPath.endsWith(".json") ? `${jsonPath.slice(0, -".json".length)}-trace.json.gz` : `${jsonPath}-trace.json.gz`;
336
437
  }
337
438
  function renderPerformanceHtml(artifact) {
439
+ const javascriptTasksApplicable = artifact.platform === "extension";
440
+ const iosCadence = artifact.platform === "ios";
338
441
  const maxNodeDurationMs = Math.max(
339
442
  1,
340
443
  ...artifact.nodeIntervals.map((interval) => interval.durationMs)
@@ -343,12 +446,20 @@ function renderPerformanceHtml(artifact) {
343
446
  const ui = interval.nativeUi;
344
447
  const js = interval.javascript;
345
448
  const width = Math.max(1, interval.durationMs / maxNodeDurationMs * 100);
346
- return `<tr><td><strong>${escapeHtml(interval.nodeId)}</strong><div class="action">${escapeHtml(interval.action)}</div></td><td><div class="duration"><span style="width:${width.toFixed(1)}%"></span></div><small>${interval.durationMs} ms</small></td><td>${ui.frameCount}</td><td>${metric(ui.averageFps)}</td><td>${metric(ui.p95FrameMs)}</td><td>${metric(ui.jankyFramePercent)}</td><td>${js.taskCount}</td><td>${metric(js.longTaskCount)}</td><td>${metric(js.longestTaskMs)}</td></tr>`;
449
+ const javascriptCells = javascriptTasksApplicable ? `<td>${js.taskCount}</td><td>${metric(js.longTaskCount)}</td><td>${metric(js.longestTaskMs)}</td>` : "<td>N/A</td><td>N/A</td><td>N/A</td>";
450
+ return `<tr><td><strong>${escapeHtml(interval.nodeId)}</strong><div class="action">${escapeHtml(interval.action)}</div></td><td><div class="duration"><span style="width:${width.toFixed(1)}%"></span></div><small>${interval.durationMs} ms</small></td><td>${ui.frameCount}</td><td>${metric(ui.refreshRateHz, 2)}</td><td>${metric(ui.activeFps)}</td><td>${metric(ui.fpsIntervalCount)}</td><td>${metric(ui.p95FrameMs)}</td><td>${metric(ui.overBudgetFramePercent)}</td>${javascriptCells}</tr>`;
347
451
  }).join("");
348
- const worst = artifact.worstInteraction ? `${escapeHtml(artifact.worstInteraction.nodeId)}: ${artifact.worstInteraction.longestSampleMs} ms (${artifact.worstInteraction.source})` : "No attributed UI frame or JS task samples";
349
- const gaps = artifact.coverageGapReasons.length ? `<ul>${artifact.coverageGapReasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}</ul>` : "<p>Both requested sources completed.</p>";
350
- const sources = `<p><strong>JavaScript:</strong> ${escapeHtml(artifact.javascript.kind)} (${artifact.javascript.status})<br><strong>Native UI:</strong> ${escapeHtml(artifact.nativeUi.kind)} (${artifact.nativeUi.status})<br><strong>Trace events:</strong> ${artifact.trace.beginFrameCount} BeginFrame, ${artifact.trace.drawFrameCount} DrawFrame, ${artifact.trace.runTaskCount} RunTask, ${artifact.trace.profileChunkCount} ProfileChunk</p>`;
351
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>UI smoothness</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:15px system-ui,-apple-system,sans-serif;margin:0;background:#0b0d12;color:#f1f5f9}main{max-width:1180px;margin:auto;padding:32px}h1{font-size:28px;margin:0 0 6px}.lede,.action,small{color:#94a3b8}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:24px 0}.card,.coverage{padding:18px;border:1px solid #293244;border-radius:12px;background:#141821}.label{display:block;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}.value{font-size:20px;font-weight:700}.partial{color:#fbbf24}.complete{color:#6ee7b7}.unavailable{color:#f87171}.coverage{margin-top:20px}.coverage h2{font-size:16px;margin:0 0 8px}.coverage ul,.coverage p{margin:8px 0 0;color:#cbd5e1}.coverage ul{padding-left:20px}table{width:100%;border-collapse:collapse;margin-top:24px;background:#141821;border:1px solid #293244;border-radius:12px;overflow:hidden}th,td{text-align:left;padding:12px;border-bottom:1px solid #293244;vertical-align:top}th{color:#93c5fd;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.duration{height:8px;min-width:140px;background:#252c3a;border-radius:99px;overflow:hidden;margin:4px 0 5px}.duration span{display:block;height:100%;background:#7c3aed;border-radius:inherit}.legend{color:#94a3b8;font-size:13px;margin:10px 0 0}@media(max-width:760px){main{padding:20px}.cards{grid-template-columns:1fr}table{display:block;overflow-x:auto}}</style></head><body><main><h1>UI smoothness</h1><p class="lede">CDP trace evidence attributed to recipe-node boundaries. Compare matched runs on the same runtime and build.</p><section class="cards"><div class="card"><span class="label">Evidence status</span><span class="value ${artifact.status}">${artifact.status}</span></div><div class="card"><span class="label">Platform</span><span class="value">${artifact.platform}</span></div><div class="card"><span class="label">Worst observed sample</span><span class="value">${worst}</span></div></section><section class="coverage"><h2>Coverage</h2>${sources}${gaps}</section><table><thead><tr><th>Interaction</th><th>Node duration</th><th>UI frames</th><th>UI avg FPS</th><th>UI p95 frame</th><th>UI jank %</th><th>JS tasks</th><th>JS long tasks</th><th>Longest JS task</th></tr></thead><tbody>${rows}</tbody></table><p class="legend">UI FPS and jank come from native or Chromium frame events. JS tasks show main-thread blocking work and are not FPS. A long task is at least 50 ms. "\u2014" means the trace emitted no complete sample inside the interval.</p></main></body></html>
452
+ const worst = artifact.worstInteraction ? `${escapeHtml(artifact.worstInteraction.nodeId)}: ${artifact.worstInteraction.longestSampleMs} ms (${artifact.worstInteraction.source === "nativeUiFrame" && iosCadence ? "native UI cadence" : artifact.worstInteraction.source})` : javascriptTasksApplicable ? "No attributed UI frame or JS task samples" : "No attributed UI frame samples";
453
+ const gaps = artifact.coverageGapReasons.length ? `<ul>${artifact.coverageGapReasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}</ul>` : javascriptTasksApplicable ? "<p>Both requested sources completed.</p>" : "<p>Native UI frame coverage completed.</p>";
454
+ const javascriptSource = javascriptTasksApplicable ? `${escapeHtml(artifact.javascript.kind)} (${artifact.javascript.status})` : "Not applicable on React Native Mobile";
455
+ const traceEvents = artifact.platform === "android" ? `${artifact.trace.beginFrameCount} synthesized BeginFrame, ${artifact.trace.drawFrameCount} synthesized DrawFrame from gfxinfo` : javascriptTasksApplicable ? `${artifact.trace.beginFrameCount} BeginFrame, ${artifact.trace.drawFrameCount} DrawFrame, ${artifact.trace.runTaskCount} RunTask` : `${artifact.trace.beginFrameCount} BeginFrame, ${artifact.trace.drawFrameCount} DrawFrame, ${artifact.trace.profileChunkCount} ProfileChunk`;
456
+ const refreshRate = artifact.nativeUi.summary.refreshRateHz === void 0 ? "N/A" : `${metric(artifact.nativeUi.summary.refreshRateHz, 2)} Hz`;
457
+ const clockUncertainty = artifact.nativeUi.clockSyncUncertaintyMs === void 0 ? "" : `<br><strong>Clock mapping uncertainty:</strong> ${metric(artifact.nativeUi.clockSyncUncertaintyMs, 2)} ms`;
458
+ const cadenceGaps = !iosCadence || artifact.nativeUi.summary.cadenceGapCount === void 0 ? "" : `<br><strong>Unclassified cadence gaps:</strong> ${artifact.nativeUi.summary.cadenceGapCount} (longest ${metric(artifact.nativeUi.summary.longestCadenceGapMs, 2)} ms)`;
459
+ const sources = `<p><strong>JavaScript tasks:</strong> ${javascriptSource}<br><strong>Native UI:</strong> ${escapeHtml(artifact.nativeUi.kind)} (${artifact.nativeUi.status})<br><strong>Detected refresh rate:</strong> ${refreshRate}${clockUncertainty}${cadenceGaps}<br><strong>Trace events:</strong> ${traceEvents}</p>`;
460
+ const legend = javascriptTasksApplicable ? "Active UI FPS uses Chromium renderer vsync intervals while frames are being produced. Idle gaps are excluded. Fewer than five usable intervals reports N/A. Over-budget frames compare frame time with the detected refresh period. JS tasks are blocking work, not FPS." : iosCadence ? "Active UI FPS and percentiles use continuous native CADisplayLink cadence intervals. An unclassified long gap forces partial coverage and N/A FPS. It remains visible as a gap and can be the worst observed sample, but is not classified as an over-budget frame. React Native Mobile does not emit Chromium RunTask events; Hermes ProfileChunk is separate CPU-profile evidence." : "Active UI FPS uses native vsync intervals while frames are being produced. Idle gaps are excluded. Fewer than five usable intervals reports N/A. Android over-budget frames miss FrameDeadline when available, with the detected refresh period used only as a fallback. React Native Mobile does not emit Chromium RunTask events; Hermes ProfileChunk is separate CPU-profile evidence.";
461
+ const p95Heading = iosCadence ? "UI p95 cadence" : "UI p95 frame";
462
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>UI smoothness</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:15px system-ui,-apple-system,sans-serif;margin:0;background:#0b0d12;color:#f1f5f9}main{max-width:1320px;margin:auto;padding:32px}h1{font-size:28px;margin:0 0 6px}.lede,.action,small{color:#94a3b8}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:24px 0}.card,.coverage{padding:18px;border:1px solid #293244;border-radius:12px;background:#141821}.label{display:block;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}.value{font-size:20px;font-weight:700}.partial{color:#fbbf24}.complete{color:#6ee7b7}.unavailable{color:#f87171}.coverage{margin-top:20px}.coverage h2{font-size:16px;margin:0 0 8px}.coverage ul,.coverage p{margin:8px 0 0;color:#cbd5e1}.coverage ul{padding-left:20px}table{width:100%;border-collapse:collapse;margin-top:24px;background:#141821;border:1px solid #293244;border-radius:12px;overflow:hidden}th,td{text-align:left;padding:12px;border-bottom:1px solid #293244;vertical-align:top}th{color:#93c5fd;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.duration{height:8px;min-width:140px;background:#252c3a;border-radius:99px;overflow:hidden;margin:4px 0 5px}.duration span{display:block;height:100%;background:#7c3aed;border-radius:inherit}.legend{color:#94a3b8;font-size:13px;margin:10px 0 0}@media(max-width:760px){main{padding:20px}.cards{grid-template-columns:1fr}table{display:block;overflow-x:auto}}</style></head><body><main><h1>UI smoothness</h1><p class="lede">Native UI evidence attributed to recipe-node boundaries. Node duration includes action and wait time; it is not FPS.</p><section class="cards"><div class="card"><span class="label">Evidence status</span><span class="value ${artifact.status}">${artifact.status}</span></div><div class="card"><span class="label">Platform</span><span class="value">${artifact.platform}</span></div><div class="card"><span class="label">Worst observed sample</span><span class="value">${worst}</span></div></section><section class="coverage"><h2>Coverage</h2>${sources}${gaps}</section><table><thead><tr><th>Interaction</th><th>Node duration</th><th>UI samples</th><th>Display Hz</th><th>Active UI FPS</th><th>FPS intervals</th><th>${p95Heading}</th><th>Over-budget %</th><th>JS tasks</th><th>JS long tasks</th><th>Longest JS task</th></tr></thead><tbody>${rows}</tbody></table><p class="legend">${legend}</p></main></body></html>
352
463
  `;
353
464
  }
354
465
  async function readArtifact(artifactsDir, artifactPath) {
@@ -364,7 +475,7 @@ async function indexPerformanceArtifacts(manifestPath, paths) {
364
475
  manifestPath,
365
476
  paths.map((artifactPath) => ({
366
477
  path: artifactPath,
367
- type: artifactPath.endsWith(".html") ? "report" : "metric",
478
+ type: artifactPath.endsWith(".html") ? "report" : artifactPath.endsWith(".json.gz") ? "trace" : "metric",
368
479
  label: "UI smoothness observation",
369
480
  category: "diagnostic"
370
481
  }))
@@ -372,6 +483,7 @@ async function indexPerformanceArtifacts(manifestPath, paths) {
372
483
  }
373
484
  function unavailableCapture(session, window, reason) {
374
485
  const unavailableJavaScript = {
486
+ applicability: "applicable",
375
487
  status: "unavailable",
376
488
  kind: "unavailable",
377
489
  unavailableReasons: [reason.slice(0, 256)],
@@ -439,8 +551,9 @@ function boundedDuration(value) {
439
551
  }
440
552
  return duration;
441
553
  }
442
- function metric(value) {
443
- return value === void 0 ? "\u2014" : String(value);
554
+ function metric(value, digits) {
555
+ if (value === void 0) return "N/A";
556
+ return digits === void 0 ? String(value) : String(Number(value.toFixed(digits)));
444
557
  }
445
558
  function escapeHtml(value) {
446
559
  return value.replace(/[&<>"']/gu, (character) => {
@@ -108,6 +108,14 @@ Write evidence under `context.artifactsDir` and return relative artifact paths.
108
108
  `METAMASK_RECIPE_LIVE_ADAPTER_DIR` is the explicit task-local implementation
109
109
  root; configured libraries are the durable sharing mechanism.
110
110
 
111
+ An ordinary action performs only its declared operation and observes the
112
+ product's result. It must not restart or reload the app, reconnect providers or
113
+ streams, clear caches, reset state, or retry a failed journey. Those operations
114
+ can hide the product failure the recipe exists to catch. If lifecycle is the
115
+ subject, expose it as an explicit recipe node with separate postconditions. If
116
+ transport or lifecycle fails during another action, return the failure and keep
117
+ the evidence. Do not repair and continue.
118
+
111
119
  Add a bundled action only when it is reusable, typed, stable, reduces inference
112
120
  or risk, and has a real postcondition. Otherwise use a team library or task-local
113
121
  recipe. Follow [Recipes](RECIPES.md) and [Security](SECURITY.md).