@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.
- package/CHANGELOG.md +42 -0
- package/README.md +5 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
- package/adapters/mobile/reload-app.mjs +99 -1
- package/adapters/mobile/start-console-forwarder.sh +5 -1
- package/dist/adapters/extension/browser-cdp.js +174 -0
- package/dist/adapters/extension/network-observer.js +19 -110
- package/dist/adapters/extension/performance-observer.js +75 -0
- package/dist/adapters/mobile/frame-metrics.js +45 -0
- package/dist/adapters/mobile/performance-observer.js +43 -0
- package/dist/adapters/mobile/prepare.js +12 -0
- package/dist/adapters/performance/cdp-trace.js +342 -0
- package/dist/adapters/performance/js-task-metrics.js +35 -0
- package/dist/adapters.js +39 -5
- package/dist/artifact-files.js +92 -0
- package/dist/async.js +19 -0
- package/dist/commands/call.js +63 -4
- package/dist/commands/run-engine.js +265 -139
- package/dist/commands/run-report.js +68 -0
- package/dist/commands/run.js +67 -3
- package/dist/execution-provenance.js +342 -0
- package/dist/network-observation.js +59 -47
- package/dist/performance-observation.js +465 -0
- package/dist/run-diagnostics.js +36 -11
- package/dist/runner.js +44 -13
- package/docs/PERFORMANCE-CAPTURE.md +33 -0
- package/docs/RECIPES.md +7 -0
- package/library/actions/mobile/perps/performance-capture.mjs +570 -189
- package/library/actions/mobile/perps/perps.mjs +122 -0
- package/library/actions/mobile/platform/bridge.mjs +43 -8
- package/library/actions/mobile/platform/native-session.mjs +1 -1
- package/library/actions/mobile/wallet/lock.mjs +1 -4
- package/library/actions/mobile/wallet/select_account.mjs +129 -17
- package/library/manifests/extension.action-manifest.json +85 -0
- package/library/manifests/mobile.action-manifest.json +125 -3
- package/library/recipes/mobile/perps/performance.recipe.json +73 -47
- package/package.json +1 -1
- 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
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
+
"&": "&",
|
|
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
|
+
};
|