@deeeed/metamask-harness 0.44.2 → 0.45.0

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],
package/dist/adapters.js CHANGED
@@ -955,7 +955,7 @@ async function executeNativeProviderAction(action, node, context, createTranspor
955
955
  return executeAndroidCoordinateSwipe(node, String(device));
956
956
  }
957
957
  const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
958
- const session = `mm-harness-${process.pid}-${context.nodeId}`.replace(/[^a-zA-Z0-9._-]/gu, "-");
958
+ const session = (platform === "ios" ? `mm-harness-${process.pid}-${device}` : `mm-harness-${process.pid}-${context.nodeId}`).replace(/[^a-zA-Z0-9._-]/gu, "-");
959
959
  const stateDir = nativeAgentDeviceStateDir(context.env ?? {}, context.projectRoot, String(device));
960
960
  let transport;
961
961
  try {
@@ -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) => {
@@ -1,6 +1,6 @@
1
1
  # UI smoothness capture
2
2
 
3
- `app.performance_capture` attributes CDP trace samples to the exact Recipe Protocol v1 nodes that run inside an explicit window. Mobile and Extension use the same action contract.
3
+ `app.performance_capture` attributes native frame samples to the exact Recipe Protocol v1 nodes that run inside an explicit window. Mobile and Extension use the same action contract.
4
4
 
5
5
  ```json
6
6
  {
@@ -13,21 +13,50 @@
13
13
  }
14
14
  ```
15
15
 
16
- End the same ID with `phase: "end"`. The action writes bounded JSON and a self-contained HTML report. `app.performance_assert` can require a status, minimum frame count, and specific node intervals.
16
+ End the same ID with `phase: "end"`. The action writes a machine-readable schema-v2 summary, a self-contained HTML report, and a bounded gzip-compressed Chrome Trace Event file named `*-trace.json.gz`. Chrome DevTools and Perfetto can consume the trace format. `app.performance_assert` can require a status, minimum frame count, and specific node intervals.
17
+
18
+ ## How it works
19
+
20
+ 1. `app.performance_capture` with `phase: "start"` selects the platform source and starts a bounded capture window.
21
+ 2. The recipe runner records each node's start and end on the host clock while the interaction runs.
22
+ 3. `phase: "end"` maps native frame timestamps onto those node intervals and closes the source before building artifacts.
23
+ 4. The harness writes summary JSON for tools, self-contained HTML for people, and a gzip-compressed Chrome trace for DevTools or Perfetto.
24
+ 5. `app.performance_assert` checks source status, frame count, and required node attribution. Missing coverage fails or remains `N/A`; it never becomes zero.
25
+
26
+ ## Host prerequisites
27
+
28
+ Run `mm-harness doctor --json` from the target checkout before performance work on a new machine.
29
+
30
+ | Target | Required host tools | Notes |
31
+ | --- | --- | --- |
32
+ | Android | `adb` from Android Platform Tools | `gfxinfo framestats` is built into Android; there is no separate `gfxinfo` package to install. |
33
+ | iOS Simulator | Xcode command-line tools (`xcrun`) | Native frames require a debug client with `unstable_frameRecordingEnabled: true`. Visible UI assertions and `ui.navigate page=perps-market mode=...` also require `idb`. |
34
+ | Extension | Chrome or Chromium with an isolated CDP profile | The harness uses Chromium's Tracing domain. |
35
+ | Trace viewing | Chrome DevTools or Perfetto | Optional for recipe execution; required only to inspect the raw `*-trace.json.gz` file visually. |
36
+
37
+ `Flashlight`, Instruments, and an in-app FPS overlay are not harness dependencies. Keep the recipe HUD visible; it is separate from the performance source.
17
38
 
18
39
  ## Sources and status
19
40
 
20
- - Mobile uses React Native's `Tracing.start`, `Tracing.dataCollected`, and `Tracing.end` implementation. iOS frame timings come from its built-in `CADisplayLink` observer. Android frame timings come from `Window.FrameMetrics`. The app build must report `unstable_frameRecordingEnabled: true`; otherwise native UI coverage is partial or unavailable.
41
+ - Android polls `adb shell dumpsys gfxinfo <package> framestats` from the host. It maps Android uptime to recipe timestamps, merges frame snapshots, and measures `FrameCompleted - IntendedVsync` against the device's refresh-rate budget. It does not need a Mobile patch.
42
+ - iOS uses React Native's `Tracing.start`, `Tracing.dataCollected`, and `Tracing.end` implementation. Frame timings come from its native `CADisplayLink` observer. The debug app must report `unstable_frameRecordingEnabled: true`; otherwise native UI coverage is unavailable. Instruments `Animation Hitches` and `Core Animation FPS` do not support iOS Simulator.
43
+ - React Native rejects iOS Tracing after more than one RN host has been registered during the app process lifetime. Its counter is cumulative: removing the old host does not decrement it. Unlocking does not create or remove a host; the MetaMask unlock path only unlocks the wallet and navigates. The observed second host came from reopening an already-running Expo dev-client process against Metro.
44
+ - Establish the iOS measurement cohort explicitly: run `app.lifecycle` with `command: "restart"` outside every capture window, unlock the keyring after the process restart, prove the wallet reached Home, then start `app.performance_capture`. Unlocking does not register another host; restart → unlock → trace passed in one process. This terminates only the app process. It must not restart Metro or the simulator, and capture failure must never trigger it as automatic recovery.
21
45
  - Extension uses Chromium's implementation of the same CDP Tracing domain. Frame and JavaScript data count only after the harness proves they belong to the MetaMask renderer. Unresolved browser-wide events produce partial or unavailable coverage.
22
- - The harness records a CDP clock marker and maps trace timestamps onto recipe-node timestamps. It waits for `Tracing.tracingComplete` before writing evidence.
46
+ - CDP sources record a clock marker and map trace timestamps onto recipe-node timestamps. They wait for `Tracing.tracingComplete` before writing evidence. Android uses the uptime value emitted by `gfxinfo`; the report exposes the measured clock-mapping uncertainty because it bounds node attribution.
23
47
  - The JSON report includes bounded trace evidence counts for `BeginFrame`, `DrawFrame`, `RunTask`, and `ProfileChunk`, plus data-loss and retention-overflow flags.
24
- - UI frames and JavaScript work remain separate sources. `RunTask` events produce JavaScript task summaries. Sampling `ProfileChunk` events prove profiler data exists but are not converted to task timing or FPS. JavaScript work is never labelled as UI or JS FPS.
48
+ - UI frames and JavaScript work remain separate sources. Chromium `RunTask` events produce JavaScript task summaries on Extension. React Native Mobile does not emit `RunTask`, so Mobile reports those fields as not applicable and judges coverage from native UI frames. Sampling `ProfileChunk` events prove Hermes profiler data exists but are not converted to task timing or FPS.
49
+ - Active UI FPS uses consecutive native vsync intervals only while frames are being produced. Fewer than five usable intervals or an unclassified long cadence gap reports `N/A`. Sampling jitter can never raise active FPS above the detected refresh rate.
50
+ - The trace cadence determines the refresh rate and fallback per-frame budget. Android over-budget percentage uses `FrameDeadline` when present; frame percentiles use `FrameCompleted - IntendedVsync`. iOS percentiles describe continuous `CADisplayLink` cadence rather than render work duration. An unclassified long gap forces partial coverage and `N/A` FPS. It stays visible as a gap and can be the worst observed sample, but it is not classified as an over-budget frame.
25
51
  - `complete`, `partial`, and `unavailable` describe source coverage. Missing data is never converted to zero.
52
+ - Raw trace timestamps use host epoch microseconds. Standard complete events on the `MetaMask Harness / Recipe nodes` track preserve the node boundaries used by the HTML and JSON reports.
26
53
 
27
54
  `app.performance_assert.minimum_frame_count` applies only to native UI or Chromium renderer frames. JavaScript task count cannot satisfy it.
28
55
 
56
+ Node duration includes automation, native state checks, and wait time inside the node. It is not screen time-to-content. Use product lifecycle milestones for section visibility and data readiness.
57
+
29
58
  Capture is explicit only. Use the action for comparisons on the same runtime and build. Automatic capture remains disabled until its overhead passes a matched benchmark.
30
59
 
31
60
  ## Sentry promotion boundary
32
61
 
33
- This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99, jank percentage, and longest frame. Do not send raw frame samples, recipe parameters, wallet/account identity, URLs, or artifact contents.
62
+ This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99, over-budget percentage, and longest frame. Do not send raw frame samples, recipe parameters, wallet/account identity, URLs, or artifact contents.
@@ -0,0 +1,73 @@
1
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
2
+ import {
3
+ normalizeVisiblePerpsState,
4
+ resolveVisibleStateOptions,
5
+ } from '../../shared/perps/visible-state.mjs';
6
+
7
+ export async function readVisiblePerpsState(
8
+ input,
9
+ observe = observeNativeUi,
10
+ ) {
11
+ const options = resolveVisibleStateOptions(input.node);
12
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
13
+ let observed;
14
+ let screen;
15
+ let visible;
16
+ let lastAssertionError;
17
+ let lastObservationError;
18
+ do {
19
+ try {
20
+ observed = await observe(
21
+ { refs: ['ui.screen', 'ui.visible'], node: input.node },
22
+ input.context,
23
+ );
24
+ lastObservationError = undefined;
25
+ } catch (error) {
26
+ if (
27
+ /requires the idb client|requires adb|Android SDK Platform Tools/iu.test(
28
+ String(error?.message ?? error),
29
+ )
30
+ ) {
31
+ throw error;
32
+ }
33
+ lastObservationError = error;
34
+ observed = undefined;
35
+ }
36
+ screen = observed?.observations?.['ui.screen'];
37
+ visible = observed?.observations?.['ui.visible'];
38
+ if (screen && visible) {
39
+ try {
40
+ return {
41
+ action: input.action,
42
+ ...normalizeVisiblePerpsState({
43
+ route: screen.route,
44
+ title: screen.title,
45
+ items: visible.items,
46
+ offscreenItems: visible.hidden_or_offscreen,
47
+ truncated: visible.truncated,
48
+ }, options, 'mobile'),
49
+ proofPath: 'native-accessibility',
50
+ };
51
+ } catch (error) {
52
+ lastAssertionError = error;
53
+ }
54
+ }
55
+ if (Date.now() < deadline) {
56
+ await new Promise((resolve) => setTimeout(resolve, 250));
57
+ }
58
+ } while (Date.now() < deadline);
59
+ if (lastAssertionError) throw lastAssertionError;
60
+ const warning = observed?.warnings
61
+ ?.map((entry) => entry.message)
62
+ .filter(Boolean)
63
+ .join('; ');
64
+ const observationError = lastObservationError instanceof Error
65
+ ? lastObservationError.message
66
+ : lastObservationError === undefined
67
+ ? undefined
68
+ : String(lastObservationError);
69
+ const detail = warning ?? observationError;
70
+ throw new Error(
71
+ `Mobile visible Perps state is unavailable${detail ? `: ${detail}` : '.'}`,
72
+ );
73
+ }