@deeeed/metamask-harness 0.42.0 → 0.43.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +59 -21
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +3 -2
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +194 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/start-console-forwarder.sh +5 -1
  9. package/dist/adapters/extension/browser-cdp.js +174 -0
  10. package/dist/adapters/extension/network-observer.js +19 -110
  11. package/dist/adapters/extension/performance-observer.js +75 -0
  12. package/dist/adapters/mobile/frame-metrics.js +45 -0
  13. package/dist/adapters/mobile/performance-observer.js +43 -0
  14. package/dist/adapters/performance/cdp-trace.js +342 -0
  15. package/dist/adapters/performance/js-task-metrics.js +35 -0
  16. package/dist/adapters.js +17 -2
  17. package/dist/artifact-files.js +92 -0
  18. package/dist/async.js +19 -0
  19. package/dist/commands/call.js +23 -2
  20. package/dist/commands/run-engine.js +18 -0
  21. package/dist/commands/run.js +20 -1
  22. package/dist/network-observation.js +59 -47
  23. package/dist/performance-observation.js +465 -0
  24. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  25. package/docs/RECIPES.md +7 -0
  26. package/library/actions/mobile/platform/bridge.mjs +3 -0
  27. package/library/manifests/extension.action-manifest.json +85 -0
  28. package/library/manifests/mobile.action-manifest.json +97 -0
  29. package/package.json +1 -1
  30. package/scripts/site-contrast.mjs +43 -27
@@ -1,12 +1,17 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
  import brokerModule from "../adapters/mobile/bridge-runtime/lib/cdp-broker.cjs";
3
+ import configModule from "../adapters/mobile/bridge-runtime/lib/config.cjs";
4
4
  import {
5
5
  createExtensionNetworkObserver
6
6
  } from "./adapters/extension/network-observer.js";
7
+ import {
8
+ indexArtifactManifest,
9
+ writeContainedArtifact
10
+ } from "./artifact-files.js";
7
11
  const AUTO_CAPTURE_ID = "run-network";
8
12
  const AUTO_ARTIFACT_PATH = "network/run-summary.json";
9
13
  const { brokerSocketPath, createBrokerClient } = brokerModule;
14
+ const { resolvePort } = configModule;
10
15
  const sessions = /* @__PURE__ */ new Map();
11
16
  async function startRunNetworkObservation(adapter, target, artifactsDir, env, ports = {}) {
12
17
  if (adapter === "core") return void 0;
@@ -54,9 +59,9 @@ async function startRunNetworkObservation(adapter, target, artifactsDir, env, po
54
59
  try {
55
60
  if (runtimeEnv.MM_HARNESS_AUTO_NETWORK_CAPTURE !== "0") {
56
61
  const summary = await automaticSummary(session);
57
- writeSummary(key, AUTO_ARTIFACT_PATH, summary);
62
+ await writeSummary(key, AUTO_ARTIFACT_PATH, summary);
58
63
  if (artifactManifestPath) {
59
- indexArtifact(
64
+ await indexArtifact(
60
65
  artifactManifestPath,
61
66
  AUTO_ARTIFACT_PATH,
62
67
  "Automatic network observation"
@@ -106,7 +111,7 @@ async function handleRunNetworkAction(platform, action, node, context) {
106
111
  node.artifact_path ?? `network/${id}-summary.json`
107
112
  );
108
113
  const summary = await session.backend.end(id);
109
- writeSummary(context.artifactsDir, artifactPath, summary);
114
+ await writeSummary(context.artifactsDir, artifactPath, summary);
110
115
  return {
111
116
  output: {
112
117
  action,
@@ -123,13 +128,7 @@ async function handleRunNetworkAction(platform, action, node, context) {
123
128
  };
124
129
  }
125
130
  async function createMobileNetworkBackend(target, env) {
126
- const runtimeDir = env.RECIPE_RUNTIME_DIR ? path.resolve(target, env.RECIPE_RUNTIME_DIR) : path.join(target, "temp", "recipe", "runtime");
127
- const socketPath = brokerSocketPath(runtimeDir);
128
- const discoveryClient = await createBrokerClient(socketPath, "", 1e4);
129
- const targets = await discoveryClient.control("list-targets", {}, 1e4);
130
- discoveryClient.close();
131
- const deviceId = selectMobileBrokerTarget(targets, env);
132
- const client = await createBrokerClient(socketPath, deviceId, 1e4);
131
+ const { client } = await connectMobileBroker(target, env);
133
132
  return {
134
133
  start(params) {
135
134
  return client.control("capture-start", params, 1e4);
@@ -142,14 +141,30 @@ async function createMobileNetworkBackend(target, env) {
142
141
  }
143
142
  };
144
143
  }
144
+ async function connectMobileBroker(target, env) {
145
+ const runtimeDir = env.RECIPE_RUNTIME_DIR ? path.resolve(target, env.RECIPE_RUNTIME_DIR) : path.join(target, "temp", "recipe", "runtime");
146
+ const socketPath = brokerSocketPath(
147
+ runtimeDir,
148
+ resolvePort(env, target)
149
+ );
150
+ const discoveryClient = await createBrokerClient(socketPath, "", 1e4);
151
+ let targets;
152
+ try {
153
+ targets = await discoveryClient.control(
154
+ "resolve-targets",
155
+ { nameIncludes: mobileTargetPin(env) },
156
+ 1e4
157
+ );
158
+ } finally {
159
+ discoveryClient.close();
160
+ }
161
+ const deviceId = selectMobileBrokerTarget(targets, env);
162
+ const client = await createBrokerClient(socketPath, deviceId, 1e4);
163
+ return { client, deviceId };
164
+ }
145
165
  function selectMobileBrokerTarget(value, env) {
146
166
  const targets = Array.isArray(value) ? value.map(asRecord).filter((target) => target.deviceId) : [];
147
- const platform = String(
148
- env.MM_HARNESS_EXPLICIT_PLATFORM ?? env.RECIPE_HARNESS_PLATFORM ?? ""
149
- ).toLowerCase();
150
- const pin = String(
151
- platform === "android" ? env.ANDROID_TARGET_DEVICE_NAME ?? env.ANDROID_DEVICE ?? "" : platform === "ios" ? env.IOS_SIMULATOR ?? "" : env.IOS_SIMULATOR ?? env.ANDROID_TARGET_DEVICE_NAME ?? env.ANDROID_DEVICE ?? ""
152
- ).trim();
167
+ const pin = mobileTargetPin(env);
153
168
  const candidates = pin ? targets.filter(
154
169
  (target) => String(target.name ?? "").toLowerCase().includes(pin.toLowerCase())
155
170
  ) : targets;
@@ -160,6 +175,14 @@ function selectMobileBrokerTarget(value, env) {
160
175
  }
161
176
  return String(candidates[0].deviceId);
162
177
  }
178
+ function mobileTargetPin(env) {
179
+ const platform = String(
180
+ env.MM_HARNESS_EXPLICIT_PLATFORM ?? env.RECIPE_HARNESS_PLATFORM ?? ""
181
+ ).toLowerCase();
182
+ return String(
183
+ platform === "android" ? env.ANDROID_TARGET_DEVICE_NAME ?? env.ANDROID_DEVICE ?? "" : platform === "ios" ? env.IOS_SIMULATOR ?? "" : env.IOS_SIMULATOR ?? env.ANDROID_TARGET_DEVICE_NAME ?? env.ANDROID_DEVICE ?? ""
184
+ ).trim();
185
+ }
163
186
  async function automaticSummary(session) {
164
187
  let summary;
165
188
  if (session.autoStarted && session.backend) {
@@ -216,38 +239,26 @@ function captureParams(id, node) {
216
239
  maxDurationMs: node.max_duration_ms
217
240
  };
218
241
  }
219
- function writeSummary(artifactsDir, relativePath, summary) {
220
- const destination = resolveArtifact(artifactsDir, relativePath);
221
- fs.mkdirSync(path.dirname(destination), { recursive: true });
222
- fs.writeFileSync(destination, `${JSON.stringify(summary, null, 2)}
223
- `);
224
- }
225
- function resolveArtifact(artifactsDir, relativePath) {
226
- const root = path.resolve(artifactsDir);
227
- const resolved = path.resolve(root, relativePath);
228
- if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
229
- throw new Error("Network artifact_path escapes artifactsDir.");
230
- }
231
- return resolved;
232
- }
233
- function indexArtifact(artifactManifestPath, relativePath, label) {
234
- const manifest = asRecord(
235
- JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"))
242
+ async function writeSummary(artifactsDir, relativePath, summary) {
243
+ await writeContainedArtifact(
244
+ artifactsDir,
245
+ relativePath,
246
+ `${JSON.stringify(summary, null, 2)}
247
+ `,
248
+ "Network artifact"
236
249
  );
237
- const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts.filter(
238
- (artifact) => asRecord(artifact).path !== relativePath
239
- ) : [];
240
- artifacts.push({
241
- path: relativePath,
242
- type: "report",
243
- label,
244
- category: "diagnostic"
245
- });
246
- manifest.artifacts = artifacts;
247
- fs.writeFileSync(
250
+ }
251
+ async function indexArtifact(artifactManifestPath, relativePath, label) {
252
+ await indexArtifactManifest(
248
253
  artifactManifestPath,
249
- `${JSON.stringify(manifest, null, 2)}
250
- `
254
+ [
255
+ {
256
+ path: relativePath,
257
+ type: "report",
258
+ label,
259
+ category: "diagnostic"
260
+ }
261
+ ]
251
262
  );
252
263
  }
253
264
  function extensionCdpPort(env) {
@@ -266,6 +277,7 @@ function asRecord(value) {
266
277
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
267
278
  }
268
279
  export {
280
+ connectMobileBroker,
269
281
  handleRunNetworkAction,
270
282
  startRunNetworkObservation
271
283
  };
@@ -0,0 +1,465 @@
1
+ import path from "node:path";
2
+ import {
3
+ createMobilePerformanceBackend
4
+ } from "./adapters/mobile/performance-observer.js";
5
+ import { createExtensionPerformanceBackend } from "./adapters/extension/performance-observer.js";
6
+ import { summarizeFrames } from "./adapters/mobile/frame-metrics.js";
7
+ import { summarizeJavaScriptTasks } from "./adapters/performance/js-task-metrics.js";
8
+ import {
9
+ indexArtifactManifest,
10
+ readContainedJsonArtifact,
11
+ writeContainedArtifact
12
+ } from "./artifact-files.js";
13
+ const sessions = /* @__PURE__ */ new Map();
14
+ const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1e3;
15
+ const MAX_DURATION_MS = 60 * 60 * 1e3;
16
+ function startRunPerformanceObservation(adapter, target, artifactsDir, env, ports = {}) {
17
+ if (adapter === "core") return void 0;
18
+ const key = path.resolve(artifactsDir);
19
+ const session = {
20
+ adapter,
21
+ artifactsDir: key,
22
+ env: {
23
+ ...env,
24
+ ...ports.watcherPort ? { WATCHER_PORT: ports.watcherPort } : {}
25
+ },
26
+ nodeEvents: [],
27
+ ports,
28
+ target,
29
+ windows: /* @__PURE__ */ new Map()
30
+ };
31
+ session.expirySweep = setInterval(() => {
32
+ const now = Date.now();
33
+ for (const window of session.windows.values()) {
34
+ if (!window.expiredResult && now - window.startedAtEpochMs >= window.maxDurationMs) {
35
+ window.expiredResult = endCapture(session, window);
36
+ }
37
+ }
38
+ }, 100);
39
+ sessions.set(key, session);
40
+ return {
41
+ onActionEvent(event) {
42
+ session.nodeEvents.push({ ...event, epochMs: Date.now() });
43
+ },
44
+ async finalize(artifactManifestPath) {
45
+ if (session.expirySweep) clearInterval(session.expirySweep);
46
+ try {
47
+ for (const window of [...session.windows.values()]) {
48
+ const result = await finishCaptureWindow(session, window).catch(
49
+ (error) => unavailableCapture(session, window, errorMessage(error))
50
+ );
51
+ const artifact = {
52
+ ...result,
53
+ status: "partial",
54
+ coverageGapReasons: [
55
+ ...result.coverageGapReasons,
56
+ "Recipe run ended before app.performance_capture phase=end."
57
+ ]
58
+ };
59
+ const jsonPath = `performance/${safeId(window.id)}-summary.json`;
60
+ const htmlPath = `performance/${safeId(window.id)}.html`;
61
+ await writeArtifacts(session.artifactsDir, jsonPath, htmlPath, artifact);
62
+ if (artifactManifestPath) {
63
+ await indexPerformanceArtifacts(artifactManifestPath, [
64
+ jsonPath,
65
+ htmlPath
66
+ ]);
67
+ }
68
+ }
69
+ } finally {
70
+ session.windows.clear();
71
+ await session.backend?.close();
72
+ sessions.delete(key);
73
+ }
74
+ }
75
+ };
76
+ }
77
+ async function handleRunPerformanceAction(platform, action, node, context) {
78
+ if (platform === "core") return null;
79
+ if (action === "app.performance_assert") {
80
+ return { output: await assertPerformance(node, context) };
81
+ }
82
+ if (action !== "app.performance_capture") return null;
83
+ const session = sessions.get(path.resolve(context.artifactsDir));
84
+ if (!session) {
85
+ throw new Error("UI performance observation session was not started.");
86
+ }
87
+ const phase = String(node.phase ?? "").toLowerCase();
88
+ const id = String(node.id ?? "").trim();
89
+ if (!["start", "end"].includes(phase) || !id) {
90
+ throw new Error(
91
+ "app.performance_capture requires phase=start|end and a non-empty id."
92
+ );
93
+ }
94
+ if (phase === "start") {
95
+ if (session.windows.has(id)) {
96
+ await finishCaptureWindow(session, session.windows.get(id)).catch(
97
+ () => void 0
98
+ );
99
+ session.windows.delete(id);
100
+ }
101
+ const backend = await performanceBackend(session);
102
+ await backend.start(id);
103
+ const maxDurationMs = boundedDuration(node.max_duration_ms);
104
+ session.windows.set(id, {
105
+ id,
106
+ maxDurationMs,
107
+ startedAtEpochMs: Date.now()
108
+ });
109
+ return { output: { action, id, phase, status: "started" } };
110
+ }
111
+ const window = session.windows.get(id);
112
+ if (!window) throw new Error(`Performance capture is not active: ${id}`);
113
+ const artifact = await finishCaptureWindow(session, window);
114
+ session.windows.delete(id);
115
+ const jsonPath = String(
116
+ node.artifact_path ?? `performance/${safeId(id)}-summary.json`
117
+ );
118
+ const htmlPath = String(
119
+ node.html_path ?? `performance/${safeId(id)}.html`
120
+ );
121
+ await writeArtifacts(session.artifactsDir, jsonPath, htmlPath, artifact);
122
+ return {
123
+ output: {
124
+ action,
125
+ id,
126
+ phase,
127
+ status: artifact.status,
128
+ artifactPath: jsonPath,
129
+ htmlPath
130
+ },
131
+ artifacts: [
132
+ { path: jsonPath, type: "metric", nodeId: context.nodeId },
133
+ { path: htmlPath, type: "report", nodeId: context.nodeId }
134
+ ]
135
+ };
136
+ }
137
+ async function performanceBackend(session) {
138
+ if (session.backend) return session.backend;
139
+ if (session.backendError) throw new Error(session.backendError);
140
+ try {
141
+ session.backend = session.adapter === "mobile" ? await createMobilePerformanceBackend(session.target, session.env) : await createExtensionPerformanceBackend(
142
+ extensionCdpPort(session),
143
+ session.target
144
+ );
145
+ return session.backend;
146
+ } catch (error) {
147
+ session.backendError = errorMessage(error);
148
+ throw error;
149
+ }
150
+ }
151
+ async function endCapture(session, window) {
152
+ const sourceResult = await (await performanceBackend(session)).end(window.id);
153
+ const endedAtEpochMs = Date.now();
154
+ return buildPerformanceArtifact(
155
+ window,
156
+ endedAtEpochMs,
157
+ sourceResult,
158
+ session.nodeEvents
159
+ );
160
+ }
161
+ function finishCaptureWindow(session, window) {
162
+ return window.expiredResult ?? endCapture(session, window);
163
+ }
164
+ function buildPerformanceArtifact(window, endedAtEpochMs, result, allEvents) {
165
+ const nodeEvents = allEvents.filter(
166
+ (event) => event.epochMs >= window.startedAtEpochMs && event.epochMs <= endedAtEpochMs
167
+ );
168
+ const nodeIntervals = buildNodeIntervals(
169
+ nodeEvents,
170
+ result.javascript,
171
+ result.nativeUi
172
+ );
173
+ const coverageGapReasons = [
174
+ ...result.javascript.unavailableReasons.map(
175
+ (reason) => `javascript unavailable: ${reason}`
176
+ ),
177
+ ...result.javascript.coverageGapReasons.map(
178
+ (reason) => `javascript: ${reason}`
179
+ ),
180
+ ...result.nativeUi.unavailableReasons.map(
181
+ (reason) => `nativeUi unavailable: ${reason}`
182
+ ),
183
+ ...result.nativeUi.coverageGapReasons.map(
184
+ (reason) => `nativeUi: ${reason}`
185
+ )
186
+ ];
187
+ const exceededMaxDuration = endedAtEpochMs - window.startedAtEpochMs >= window.maxDurationMs;
188
+ if (exceededMaxDuration) {
189
+ coverageGapReasons.push("Performance capture exceeded max_duration_ms.");
190
+ }
191
+ const status = exceededMaxDuration ? "partial" : result.javascript.status === "unavailable" && result.nativeUi.status === "unavailable" ? "unavailable" : result.javascript.status === "complete" && result.nativeUi.status === "complete" ? "complete" : "partial";
192
+ const worst = worstInteraction(nodeIntervals);
193
+ return {
194
+ schemaVersion: 1,
195
+ id: window.id,
196
+ status,
197
+ platform: result.platform,
198
+ startedAtEpochMs: window.startedAtEpochMs,
199
+ endedAtEpochMs,
200
+ durationMs: endedAtEpochMs - window.startedAtEpochMs,
201
+ javascript: { ...result.javascript, samples: [] },
202
+ nativeUi: { ...result.nativeUi, samples: [] },
203
+ trace: result.trace,
204
+ nodeEvents,
205
+ nodeIntervals,
206
+ ...worst ? { worstInteraction: worst } : {},
207
+ coverageGapReasons
208
+ };
209
+ }
210
+ function buildNodeIntervals(events, javascript, nativeUi) {
211
+ const active = /* @__PURE__ */ new Map();
212
+ const intervals = [];
213
+ for (const event of events) {
214
+ if (event.status === "running") {
215
+ active.set(event.nodeId, event);
216
+ continue;
217
+ }
218
+ const start = active.get(event.nodeId);
219
+ if (!start) continue;
220
+ active.delete(event.nodeId);
221
+ intervals.push({
222
+ action: event.action,
223
+ durationMs: event.epochMs - start.epochMs,
224
+ endedAtEpochMs: event.epochMs,
225
+ javascript: summarizeJavaScriptTasks(
226
+ samplesWithin(javascript, start.epochMs, event.epochMs)
227
+ ),
228
+ nativeUi: summarizeFrames(
229
+ samplesWithin(nativeUi, start.epochMs, event.epochMs)
230
+ ),
231
+ nodeId: event.nodeId,
232
+ outcome: event.status,
233
+ startedAtEpochMs: start.epochMs
234
+ });
235
+ }
236
+ return intervals;
237
+ }
238
+ function samplesWithin(source, startEpochMs, endEpochMs) {
239
+ return source.samples.filter(
240
+ (sample) => sample.completedAtEpochMs - sample.durationMs >= startEpochMs && sample.completedAtEpochMs <= endEpochMs
241
+ );
242
+ }
243
+ function worstInteraction(intervals) {
244
+ let worst;
245
+ for (const interval of intervals) {
246
+ const candidates = [
247
+ {
248
+ longestSampleMs: interval.javascript.longestTaskMs,
249
+ source: "javascriptTask"
250
+ },
251
+ {
252
+ longestSampleMs: interval.nativeUi.longestFrameMs,
253
+ source: "nativeUiFrame"
254
+ }
255
+ ];
256
+ for (const candidate of candidates) {
257
+ const { longestSampleMs } = candidate;
258
+ if (longestSampleMs !== void 0 && (!worst || longestSampleMs > worst.longestSampleMs)) {
259
+ worst = {
260
+ action: interval.action,
261
+ longestSampleMs,
262
+ nodeId: interval.nodeId,
263
+ source: candidate.source
264
+ };
265
+ }
266
+ }
267
+ }
268
+ return worst;
269
+ }
270
+ async function assertPerformance(node, context) {
271
+ const id = String(node.id ?? "").trim();
272
+ if (!id) throw new Error("app.performance_assert requires id.");
273
+ const artifactPath = String(
274
+ node.artifact_path ?? `performance/${safeId(id)}-summary.json`
275
+ );
276
+ const artifact = await readArtifact(context.artifactsDir, artifactPath);
277
+ if (artifact.schemaVersion !== 1 || artifact.id !== id) {
278
+ throw new Error("app.performance_assert summary contract is invalid.");
279
+ }
280
+ const statuses = Array.isArray(node.required_status) ? node.required_status.map(String) : node.required_status ? [String(node.required_status)] : [];
281
+ if (statuses.length > 0 && !statuses.includes(artifact.status)) {
282
+ throw new Error(
283
+ `app.performance_assert expected ${statuses.join("|")}, got ${artifact.status}.`
284
+ );
285
+ }
286
+ const requiredNodeIds = Array.isArray(node.required_node_ids) ? node.required_node_ids.map(String) : [];
287
+ const observedNodeIds = new Set(
288
+ Array.isArray(artifact.nodeIntervals) ? artifact.nodeIntervals.map((interval) => String(interval.nodeId)) : []
289
+ );
290
+ const missingNodeIds = requiredNodeIds.filter(
291
+ (nodeId) => !observedNodeIds.has(nodeId)
292
+ );
293
+ if (missingNodeIds.length > 0) {
294
+ throw new Error(
295
+ `app.performance_assert missing node interval(s): ${missingNodeIds.join(", ")}.`
296
+ );
297
+ }
298
+ const minimumFrameCount = Number(node.minimum_frame_count ?? 0);
299
+ if (!Number.isInteger(minimumFrameCount) || minimumFrameCount < 0) {
300
+ throw new Error(
301
+ "app.performance_assert minimum_frame_count must be a non-negative integer."
302
+ );
303
+ }
304
+ const nativeFrames = Number(artifact.nativeUi?.summary?.frameCount ?? 0);
305
+ if (nativeFrames < minimumFrameCount) {
306
+ throw new Error(
307
+ `app.performance_assert expected at least ${minimumFrameCount} frame(s).`
308
+ );
309
+ }
310
+ if (node.require_native_ui_when_supported === true && artifact.nativeUi?.status !== "complete") {
311
+ throw new Error(
312
+ "app.performance_assert requires complete native UI data."
313
+ );
314
+ }
315
+ return {
316
+ action: "app.performance_assert",
317
+ artifactPath,
318
+ id,
319
+ status: artifact.status
320
+ };
321
+ }
322
+ async function writeArtifacts(artifactsDir, jsonPath, htmlPath, artifact) {
323
+ await writeContainedArtifact(
324
+ artifactsDir,
325
+ jsonPath,
326
+ `${JSON.stringify(artifact, null, 2)}
327
+ `,
328
+ "Performance artifact"
329
+ );
330
+ await writeContainedArtifact(
331
+ artifactsDir,
332
+ htmlPath,
333
+ renderPerformanceHtml(artifact),
334
+ "Performance artifact"
335
+ );
336
+ }
337
+ function renderPerformanceHtml(artifact) {
338
+ const maxNodeDurationMs = Math.max(
339
+ 1,
340
+ ...artifact.nodeIntervals.map((interval) => interval.durationMs)
341
+ );
342
+ const rows = artifact.nodeIntervals.map((interval) => {
343
+ const ui = interval.nativeUi;
344
+ const js = interval.javascript;
345
+ 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>`;
347
+ }).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>
352
+ `;
353
+ }
354
+ async function readArtifact(artifactsDir, artifactPath) {
355
+ return readContainedJsonArtifact(
356
+ artifactsDir,
357
+ artifactPath,
358
+ 5 * 1024 * 1024,
359
+ "Performance artifact"
360
+ );
361
+ }
362
+ async function indexPerformanceArtifacts(manifestPath, paths) {
363
+ await indexArtifactManifest(
364
+ manifestPath,
365
+ paths.map((artifactPath) => ({
366
+ path: artifactPath,
367
+ type: artifactPath.endsWith(".html") ? "report" : "metric",
368
+ label: "UI smoothness observation",
369
+ category: "diagnostic"
370
+ }))
371
+ );
372
+ }
373
+ function unavailableCapture(session, window, reason) {
374
+ const unavailableJavaScript = {
375
+ status: "unavailable",
376
+ kind: "unavailable",
377
+ unavailableReasons: [reason.slice(0, 256)],
378
+ coverageGapReasons: [],
379
+ samples: [],
380
+ summary: { taskCount: 0 }
381
+ };
382
+ const unavailableNativeUi = {
383
+ status: "unavailable",
384
+ kind: "unavailable",
385
+ unavailableReasons: [reason.slice(0, 256)],
386
+ coverageGapReasons: [],
387
+ samples: [],
388
+ summary: { frameCount: 0 }
389
+ };
390
+ return buildPerformanceArtifact(
391
+ window,
392
+ Date.now(),
393
+ {
394
+ platform: resolveSessionPlatform(session),
395
+ javascript: unavailableJavaScript,
396
+ nativeUi: unavailableNativeUi,
397
+ trace: {
398
+ beginFrameCount: 0,
399
+ dataLossOccurred: false,
400
+ drawFrameCount: 0,
401
+ overflow: false,
402
+ profileChunkCount: 0,
403
+ runTaskCount: 0,
404
+ scope: "unresolved",
405
+ totalEventCount: 0
406
+ }
407
+ },
408
+ session.nodeEvents
409
+ );
410
+ }
411
+ function resolveSessionPlatform(session) {
412
+ if (session.adapter === "extension") return "extension";
413
+ const explicit = String(
414
+ session.env.MM_HARNESS_EXPLICIT_PLATFORM ?? session.env.RECIPE_HARNESS_PLATFORM ?? ""
415
+ ).toLowerCase();
416
+ if (explicit === "android" || explicit === "ios") return explicit;
417
+ return session.env.ADB_SERIAL || session.env.ANDROID_SERIAL || session.env.ANDROID_DEVICE ? "android" : "ios";
418
+ }
419
+ function extensionCdpPort(session) {
420
+ const port = Number(
421
+ session.ports.cdpPort ?? session.env.CDP_PORT ?? session.env.RECIPE_CDP_PORT
422
+ );
423
+ if (!Number.isInteger(port) || port <= 0) {
424
+ throw new Error("Extension performance observation requires CDP_PORT.");
425
+ }
426
+ return port;
427
+ }
428
+ function safeId(value) {
429
+ const result = value.replace(/[^a-zA-Z0-9_-]/gu, "-").slice(0, 80);
430
+ if (!result) throw new Error("Performance capture id is invalid.");
431
+ return result;
432
+ }
433
+ function boundedDuration(value) {
434
+ const duration = Number(value ?? DEFAULT_MAX_DURATION_MS);
435
+ if (!Number.isInteger(duration) || duration < 1 || duration > MAX_DURATION_MS) {
436
+ throw new Error(
437
+ `app.performance_capture max_duration_ms must be an integer from 1 to ${MAX_DURATION_MS}.`
438
+ );
439
+ }
440
+ return duration;
441
+ }
442
+ function metric(value) {
443
+ return value === void 0 ? "\u2014" : String(value);
444
+ }
445
+ function escapeHtml(value) {
446
+ return value.replace(/[&<>"']/gu, (character) => {
447
+ const entities = {
448
+ "&": "&amp;",
449
+ "<": "&lt;",
450
+ ">": "&gt;",
451
+ '"': "&quot;",
452
+ "'": "&#39;"
453
+ };
454
+ return entities[character] ?? character;
455
+ });
456
+ }
457
+ function errorMessage(error) {
458
+ return error instanceof Error ? error.message : String(error);
459
+ }
460
+ export {
461
+ buildPerformanceArtifact,
462
+ handleRunPerformanceAction,
463
+ renderPerformanceHtml,
464
+ startRunPerformanceObservation
465
+ };
@@ -0,0 +1,33 @@
1
+ # UI smoothness capture
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.
4
+
5
+ ```json
6
+ {
7
+ "action": "app.performance_capture",
8
+ "phase": "start",
9
+ "id": "market-scroll",
10
+ "max_duration_ms": 60000,
11
+ "intent": "Start UI smoothness capture",
12
+ "next": "scroll"
13
+ }
14
+ ```
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.
17
+
18
+ ## Sources and status
19
+
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.
21
+ - 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.
23
+ - 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.
25
+ - `complete`, `partial`, and `unavailable` describe source coverage. Missing data is never converted to zero.
26
+
27
+ `app.performance_assert.minimum_frame_count` applies only to native UI or Chromium renderer frames. JavaScript task count cannot satisfy it.
28
+
29
+ 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
+
31
+ ## Sentry promotion boundary
32
+
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.
package/docs/RECIPES.md CHANGED
@@ -269,6 +269,13 @@ events. The result distinguishes complete, partial, and unavailable evidence
269
269
  so a target rotation cannot be misreported as zero requests. See
270
270
  [Recipe-scoped network capture](NETWORK-CAPTURE.md).
271
271
 
272
+ ## Capture UI smoothness
273
+
274
+ Use explicit `app.performance_capture` start/end nodes to correlate Mobile or
275
+ Extension CDP trace samples with recipe-node boundaries.
276
+ `app.performance_assert` checks the resulting evidence contract. Capture is
277
+ not automatic; see [UI smoothness capture](PERFORMANCE-CAPTURE.md).
278
+
272
279
  ## Share a library
273
280
 
274
281
  ```text