@deeeed/metamask-harness 0.42.0 → 0.44.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 (43) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
  9. package/adapters/mobile/reload-app.mjs +99 -1
  10. package/adapters/mobile/start-console-forwarder.sh +5 -1
  11. package/dist/adapters/extension/browser-cdp.js +174 -0
  12. package/dist/adapters/extension/network-observer.js +19 -110
  13. package/dist/adapters/extension/performance-observer.js +75 -0
  14. package/dist/adapters/mobile/frame-metrics.js +45 -0
  15. package/dist/adapters/mobile/performance-observer.js +43 -0
  16. package/dist/adapters/mobile/prepare.js +12 -0
  17. package/dist/adapters/performance/cdp-trace.js +342 -0
  18. package/dist/adapters/performance/js-task-metrics.js +35 -0
  19. package/dist/adapters.js +39 -5
  20. package/dist/artifact-files.js +92 -0
  21. package/dist/async.js +19 -0
  22. package/dist/commands/call.js +63 -4
  23. package/dist/commands/run-engine.js +265 -139
  24. package/dist/commands/run-report.js +68 -0
  25. package/dist/commands/run.js +67 -3
  26. package/dist/execution-provenance.js +342 -0
  27. package/dist/network-observation.js +59 -47
  28. package/dist/performance-observation.js +465 -0
  29. package/dist/run-diagnostics.js +36 -11
  30. package/dist/runner.js +44 -13
  31. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  32. package/docs/RECIPES.md +7 -0
  33. package/library/actions/mobile/perps/performance-capture.mjs +570 -189
  34. package/library/actions/mobile/perps/perps.mjs +122 -0
  35. package/library/actions/mobile/platform/bridge.mjs +43 -8
  36. package/library/actions/mobile/platform/native-session.mjs +1 -1
  37. package/library/actions/mobile/wallet/lock.mjs +1 -4
  38. package/library/actions/mobile/wallet/select_account.mjs +129 -17
  39. package/library/manifests/extension.action-manifest.json +85 -0
  40. package/library/manifests/mobile.action-manifest.json +125 -3
  41. package/library/recipes/mobile/perps/performance.recipe.json +73 -47
  42. package/package.json +1 -1
  43. 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
+ };