@deeeed/metamask-harness 0.41.1 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +12 -0
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +108 -10
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +117 -16
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +921 -0
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
- package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
- package/adapters/mobile/reload-app.mjs +67 -0
- package/adapters/mobile/start-console-forwarder.sh +22 -3
- package/adapters/mobile/start-metro.sh +6 -11
- package/adapters/mobile/stop-metro.sh +10 -1
- package/adapters/shared/open-debug.mjs +172 -2
- package/dist/adapters/extension/browser-cdp.js +174 -0
- package/dist/adapters/extension/network-observer.js +209 -0
- package/dist/adapters/extension/performance-observer.js +75 -0
- package/dist/adapters/mobile/frame-metrics.js +45 -0
- package/dist/adapters/mobile/metro-env.js +0 -5
- package/dist/adapters/mobile/performance-observer.js +43 -0
- package/dist/adapters/mobile/prepare.js +1 -3
- package/dist/adapters/mobile/runtime-decision.js +6 -9
- package/dist/adapters/performance/cdp-trace.js +342 -0
- package/dist/adapters/performance/js-task-metrics.js +35 -0
- package/dist/adapters.js +29 -1
- package/dist/artifact-files.js +92 -0
- package/dist/async.js +19 -0
- package/dist/cli-commands.js +6 -3
- package/dist/cli.js +4 -0
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +66 -20
- package/dist/commands/reload.js +80 -0
- package/dist/commands/run-engine.js +18 -0
- package/dist/commands/run.js +68 -22
- package/dist/mm-harness-cli.js +17 -1
- package/dist/network-observation.js +283 -0
- package/dist/performance-observation.js +465 -0
- package/docs/NETWORK-CAPTURE.md +98 -0
- package/docs/PERFORMANCE-CAPTURE.md +33 -0
- package/docs/RECIPES.md +17 -0
- package/library/actions/mobile/app/network_assert.mjs +14 -0
- package/library/actions/mobile/app/network_capture.mjs +72 -0
- package/library/actions/mobile/platform/bridge.mjs +10 -2
- package/library/actions/shared/app/network-artifact.mjs +10 -0
- package/library/actions/shared/app/network-assert.mjs +154 -0
- package/library/manifests/extension.action-manifest.json +173 -0
- package/library/manifests/mobile.action-manifest.json +204 -0
- package/library/recipes/mobile/perps/performance.recipe.json +11 -11
- package/package.json +1 -1
- package/scripts/completions.sh +2 -1
- package/scripts/site-contrast.mjs +43 -27
|
@@ -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
|
+
"&": "&",
|
|
449
|
+
"<": "<",
|
|
450
|
+
">": ">",
|
|
451
|
+
'"': """,
|
|
452
|
+
"'": "'"
|
|
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,98 @@
|
|
|
1
|
+
# Recipe-scoped network capture
|
|
2
|
+
|
|
3
|
+
`app.network_capture` records HTTP requests between two Recipe Protocol v1 nodes without product instrumentation. Mobile uses its shared Hermes CDP broker; Extension uses one browser-level CDP observer across the extension page, service worker, and offscreen targets.
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"action": "app.network_capture",
|
|
8
|
+
"phase": "start",
|
|
9
|
+
"id": "perps-home",
|
|
10
|
+
"url_includes": ["api.hyperliquid.xyz/info"],
|
|
11
|
+
"methods": ["POST"],
|
|
12
|
+
"body_json_fields": ["type", "req.coin", "dex"],
|
|
13
|
+
"next": "exercise-flow"
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"action": "app.network_capture",
|
|
20
|
+
"phase": "end",
|
|
21
|
+
"id": "perps-home",
|
|
22
|
+
"artifact_path": "network/perps-home.json",
|
|
23
|
+
"next": "assert-network"
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"action": "app.network_assert",
|
|
30
|
+
"id": "perps-home",
|
|
31
|
+
"artifact_path": "network/perps-home.json",
|
|
32
|
+
"required_status": "complete",
|
|
33
|
+
"next": "done"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The end node writes and indexes a JSON summary containing request totals, counts by method/host/retained body type, relative timestamps, reconnects, and dropped events.
|
|
38
|
+
|
|
39
|
+
## Automatic run evidence
|
|
40
|
+
|
|
41
|
+
Every live Mobile or Extension recipe run also writes `network/run-summary.json`. The automatic artifact is metadata-only and records:
|
|
42
|
+
|
|
43
|
+
- method, host, sanitized path, elapsed time, and the safe top-level request `type` when present;
|
|
44
|
+
- `running`, `passed`, and `failed` node-boundary events on the same elapsed-time axis;
|
|
45
|
+
- completeness, reconnect, drop, and coverage-gap status.
|
|
46
|
+
|
|
47
|
+
This makes requests between node boundaries inspectable without adding capture nodes to every recipe. Set `MM_HARNESS_AUTO_NETWORK_CAPTURE=0` only for observer-overhead comparisons or a runtime that intentionally forbids network inspection. Explicit `app.network_capture` windows remain the source for focused filters, assertions, and additional allowlisted body fields.
|
|
48
|
+
|
|
49
|
+
## Status semantics
|
|
50
|
+
|
|
51
|
+
- `complete`: the debugger target stayed attached and every retained event fit inside the configured cap.
|
|
52
|
+
- `partial`: the target rotated/disconnected, the window exceeded `max_duration_ms`, events exceeded a retention bound, or a requested body field could not be inspected safely. Never interpret zero requests from a partial capture as proof of absence.
|
|
53
|
+
- `unavailable`: the selected runtime rejected Network capture or no observer-owned target became available.
|
|
54
|
+
|
|
55
|
+
Use `required_status`, `required_min_requests`, `required_max_requests`,
|
|
56
|
+
`required_types`, and `forbidden_types` on a separate `app.network_assert`
|
|
57
|
+
node. The end node is therefore recorded and its JSON artifact indexed before
|
|
58
|
+
an assertion can fail. Absence assertions (`required_max_requests` and
|
|
59
|
+
`forbidden_types`) require `required_status: "complete"`; partial or unavailable
|
|
60
|
+
coverage cannot prove absence. Type assertions also require `type` in the start
|
|
61
|
+
node's `body_json_fields`, so an unobserved discriminator cannot prove presence
|
|
62
|
+
or absence.
|
|
63
|
+
|
|
64
|
+
## Redaction
|
|
65
|
+
|
|
66
|
+
- Query strings, request headers, response bodies, cookies, and authorization are never stored.
|
|
67
|
+
- Request bodies are omitted unless `body_json_fields` explicitly allowlists bounded, non-sensitive primitive fields.
|
|
68
|
+
- Field names containing address, account, user, token, secret, password, key, cookie, or authorization are rejected.
|
|
69
|
+
- Each capture is capped at 4 MiB in addition to `max_requests`, which defaults
|
|
70
|
+
to 1,000 and is capped at 10,000.
|
|
71
|
+
- `max_duration_ms` defaults to five minutes and is capped at one hour; at
|
|
72
|
+
most 16 windows may be active for one device.
|
|
73
|
+
- The local broker socket is mode `0600`.
|
|
74
|
+
|
|
75
|
+
## Runtime design
|
|
76
|
+
|
|
77
|
+
Mobile uses one per-slot broker for console events, bridge commands, HUD actions, and Network events. The broker reconnects when Hermes rotates and marks active captures partial because events emitted while no target exists cannot be proven complete.
|
|
78
|
+
|
|
79
|
+
Extension uses the browser CDP target to attach to every target owned by the loaded MetaMask extension. Target creation or removal re-enables Network collection and marks active captures partial when continuity cannot be proven.
|
|
80
|
+
|
|
81
|
+
| Adapter | Automatic run artifact | Explicit window/assert | Raw interactive view |
|
|
82
|
+
| --- | --- | --- | --- |
|
|
83
|
+
| Mobile | Supported | Supported | React Native DevTools |
|
|
84
|
+
| Extension | Supported | Supported | Chrome DevTools |
|
|
85
|
+
| Core | Not applicable | Not declared | Not applicable |
|
|
86
|
+
|
|
87
|
+
## Recipe v1 observation-window convention
|
|
88
|
+
|
|
89
|
+
Observation capabilities use one lifecycle:
|
|
90
|
+
|
|
91
|
+
1. start a bounded observer;
|
|
92
|
+
2. execute ordinary recipe nodes;
|
|
93
|
+
3. end the observer and index its artifact;
|
|
94
|
+
4. assert the indexed artifact separately.
|
|
95
|
+
|
|
96
|
+
Every observer reports `complete`, `partial`, or `unavailable`; a partial or unavailable window cannot prove absence. A future Mobile FPS/jank observer should reuse this lifecycle and node-boundary timeline, but no FPS action is advertised until its device overhead and metrics are validated.
|
|
97
|
+
|
|
98
|
+
Local validation of the shared request processor measured about 1.6 microseconds of median CPU per retained request. Five real observer lifecycle samples measured 19.9 ms median on Mobile and 9.4 ms median on Extension; observed maxima were 117 ms and 192 ms. These are harness-overhead measurements, not product-network or UI latency claims.
|
|
@@ -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
|
@@ -259,6 +259,23 @@ when repeated direct access has a stable cross-task contract.
|
|
|
259
259
|
The protocol is authoritative:
|
|
260
260
|
<https://farmslot.io/docs/reference/recipe-protocol-v1>.
|
|
261
261
|
|
|
262
|
+
## Capture network requests
|
|
263
|
+
|
|
264
|
+
Use `app.network_capture` start/end nodes to record redacted HTTP requests made
|
|
265
|
+
inside one Mobile or Extension recipe window, then a separate
|
|
266
|
+
`app.network_assert` node when the recipe needs self-checking evidence. Every
|
|
267
|
+
live recipe also indexes a bounded metadata-only run summary with node-boundary
|
|
268
|
+
events. The result distinguishes complete, partial, and unavailable evidence
|
|
269
|
+
so a target rotation cannot be misreported as zero requests. See
|
|
270
|
+
[Recipe-scoped network capture](NETWORK-CAPTURE.md).
|
|
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
|
+
|
|
262
279
|
## Share a library
|
|
263
280
|
|
|
264
281
|
```text
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { assertNetwork } from '../../shared/app/network-assert.mjs';
|
|
5
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
6
|
+
|
|
7
|
+
export { assertNetwork };
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
process.argv[1] &&
|
|
11
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
12
|
+
) {
|
|
13
|
+
runAdapter(assertNetwork);
|
|
14
|
+
}
|