@deeeed/metamask-harness 0.44.2 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +21 -3
- package/dist/adapters/mobile/android-gfxinfo-performance.js +368 -0
- package/dist/adapters/mobile/frame-metrics.js +88 -15
- package/dist/adapters/mobile/performance-observer.js +4 -0
- package/dist/adapters/performance/cdp-trace.js +55 -9
- package/dist/adapters.js +1 -1
- package/dist/performance-observation.js +145 -32
- package/docs/PERFORMANCE-CAPTURE.md +35 -6
- package/library/actions/mobile/perps/read-visible-state-loop.mjs +73 -0
- package/library/actions/mobile/perps/read_visible_state.mjs +2 -29
- package/library/actions/mobile/platform/bridge.mjs +1 -1
- package/library/actions/mobile/platform/observe-ui.mjs +2 -2
- package/library/actions/mobile/ui/native-navigation.mjs +213 -0
- package/library/actions/mobile/ui/navigate.mjs +37 -4
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +31 -16
- package/library/manifests/mobile.action-manifest.json +5 -0
- package/package.json +2 -2
|
@@ -5,8 +5,7 @@ import { withTimeout } from "../../async.js";
|
|
|
5
5
|
import {
|
|
6
6
|
summarizeJavaScriptTasks
|
|
7
7
|
} from "./js-task-metrics.js";
|
|
8
|
-
const
|
|
9
|
-
const MAX_TRACE_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
const MAX_TRACE_BYTES = 16 * 1024 * 1024;
|
|
10
9
|
const MAX_TRACE_EVENTS = 25e4;
|
|
11
10
|
const MOBILE_TRACE_CATEGORIES = [
|
|
12
11
|
"blink.user_timing",
|
|
@@ -141,7 +140,7 @@ function parseTraceCapture(capture) {
|
|
|
141
140
|
const trace = traceEvidence(
|
|
142
141
|
{ ...capture, events: scopedEvents },
|
|
143
142
|
scope,
|
|
144
|
-
finite(marker?.pid) ? marker.pid : void 0
|
|
143
|
+
capture.platform === "extension" && finite(marker?.pid) ? marker.pid : void 0
|
|
145
144
|
);
|
|
146
145
|
if (!finite(marker?.ts) || capture.markerHostEpochMs === void 0 || capture.markerUncertaintyMs === void 0) {
|
|
147
146
|
const reason = "CDP trace clock marker was unavailable.";
|
|
@@ -189,9 +188,15 @@ function parseTraceCapture(capture) {
|
|
|
189
188
|
traceEventNames(scopedEvents),
|
|
190
189
|
capture.markerUncertaintyMs
|
|
191
190
|
),
|
|
192
|
-
trace
|
|
191
|
+
trace,
|
|
192
|
+
rawTraceEvents: normalizeTraceTimestamps(scopedEvents, traceToEpochMs)
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
|
+
function normalizeTraceTimestamps(events, traceToEpochMs) {
|
|
196
|
+
return events.map(
|
|
197
|
+
(event) => finite(event.ts) ? { ...event, ts: traceToEpochMs(event.ts) * 1e3 } : { ...event }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
195
200
|
function traceEvidence(capture, scope, rendererProcessId) {
|
|
196
201
|
const counts = /* @__PURE__ */ new Map();
|
|
197
202
|
for (const event of capture.events) {
|
|
@@ -226,6 +231,7 @@ function nativeFrameSamples(events, platform, traceToEpochMs) {
|
|
|
226
231
|
frames.push({ beginUs: begin.ts, endUs: event.ts });
|
|
227
232
|
}
|
|
228
233
|
frames.sort((first, second) => first.beginUs - second.beginUs);
|
|
234
|
+
const frameBudgetMs = estimateFrameBudgetMs(frames);
|
|
229
235
|
if (platform === "ios") {
|
|
230
236
|
const samples = [];
|
|
231
237
|
for (let index = 1; index < frames.length; index += 1) {
|
|
@@ -235,9 +241,14 @@ function nativeFrameSamples(events, platform, traceToEpochMs) {
|
|
|
235
241
|
const durationMs = (current.beginUs - previous.beginUs) / 1e3;
|
|
236
242
|
if (durationMs <= 0) continue;
|
|
237
243
|
samples.push({
|
|
244
|
+
cadenceInterval: true,
|
|
238
245
|
completedAtEpochMs: traceToEpochMs(current.beginUs),
|
|
239
246
|
durationMs,
|
|
240
|
-
|
|
247
|
+
...frameBudgetMs ? {
|
|
248
|
+
frameBudgetMs,
|
|
249
|
+
overBudget: durationMs > frameBudgetMs * 1.01
|
|
250
|
+
} : {},
|
|
251
|
+
startedAtEpochMs: traceToEpochMs(previous.beginUs)
|
|
241
252
|
});
|
|
242
253
|
}
|
|
243
254
|
return samples;
|
|
@@ -247,10 +258,38 @@ function nativeFrameSamples(events, platform, traceToEpochMs) {
|
|
|
247
258
|
return {
|
|
248
259
|
completedAtEpochMs: traceToEpochMs(frame.endUs),
|
|
249
260
|
durationMs,
|
|
250
|
-
|
|
261
|
+
...frameBudgetMs ? {
|
|
262
|
+
frameBudgetMs,
|
|
263
|
+
overBudget: durationMs > frameBudgetMs * 1.01
|
|
264
|
+
} : {},
|
|
265
|
+
startedAtEpochMs: traceToEpochMs(frame.beginUs)
|
|
251
266
|
};
|
|
252
267
|
}).filter((sample) => sample.durationMs > 0);
|
|
253
268
|
}
|
|
269
|
+
function estimateFrameBudgetMs(frames) {
|
|
270
|
+
const cadenceMs = [];
|
|
271
|
+
for (let index = 1; index < frames.length; index += 1) {
|
|
272
|
+
const previous = frames[index - 1];
|
|
273
|
+
const current = frames[index];
|
|
274
|
+
if (!previous || !current) continue;
|
|
275
|
+
const intervalMs = (current.beginUs - previous.beginUs) / 1e3;
|
|
276
|
+
if (intervalMs >= 3.5 && intervalMs <= 40) cadenceMs.push(intervalMs);
|
|
277
|
+
}
|
|
278
|
+
if (cadenceMs.length < 5) return void 0;
|
|
279
|
+
cadenceMs.sort((first, second) => first - second);
|
|
280
|
+
const observedBudgetMs = percentile(cadenceMs, 0.1);
|
|
281
|
+
const refreshRateHz = Math.round(1e3 / observedBudgetMs);
|
|
282
|
+
return refreshRateHz >= 24 && refreshRateHz <= 240 ? 1e3 / refreshRateHz : void 0;
|
|
283
|
+
}
|
|
284
|
+
function percentile(sorted, quantile) {
|
|
285
|
+
if (sorted.length === 1) return sorted[0] ?? 0;
|
|
286
|
+
const position = (sorted.length - 1) * quantile;
|
|
287
|
+
const lower = Math.floor(position);
|
|
288
|
+
const upper = Math.ceil(position);
|
|
289
|
+
const lowerValue = sorted[lower] ?? 0;
|
|
290
|
+
const upperValue = sorted[upper] ?? lowerValue;
|
|
291
|
+
return lowerValue + (upperValue - lowerValue) * (position - lower);
|
|
292
|
+
}
|
|
254
293
|
function javascriptTaskSamples(events, traceToEpochMs) {
|
|
255
294
|
return events.filter(
|
|
256
295
|
(event) => event.name === "RunTask" && event.ph === "X" && finite(event.ts) && finite(event.dur) && event.dur > 0
|
|
@@ -272,6 +311,7 @@ function frameSequence(event) {
|
|
|
272
311
|
}
|
|
273
312
|
function javascriptSourceResult(kind, samples, status, coverageGapReasons, observedEventNames, clockSyncUncertaintyMs) {
|
|
274
313
|
return {
|
|
314
|
+
applicability: "applicable",
|
|
275
315
|
status: samples.length > 0 ? status : "partial",
|
|
276
316
|
kind,
|
|
277
317
|
unavailableReasons: [],
|
|
@@ -287,18 +327,23 @@ function javascriptSourceResult(kind, samples, status, coverageGapReasons, obser
|
|
|
287
327
|
};
|
|
288
328
|
}
|
|
289
329
|
function nativeUiSourceResult(kind, samples, status, coverageGapReasons, observedEventNames, clockSyncUncertaintyMs) {
|
|
330
|
+
const summary = summarizeFrames(samples);
|
|
331
|
+
const hasUnclassifiedCadenceGap = (summary.cadenceGapCount ?? 0) > 0;
|
|
290
332
|
return {
|
|
291
|
-
status: samples.length
|
|
333
|
+
status: samples.length === 0 ? "partial" : hasUnclassifiedCadenceGap ? "partial" : status,
|
|
292
334
|
kind,
|
|
293
335
|
unavailableReasons: [],
|
|
294
336
|
coverageGapReasons: [
|
|
295
337
|
...coverageGapReasons,
|
|
296
338
|
...samples.length > 0 ? [] : [
|
|
297
339
|
`No ${kind} samples were recorded. Observed trace events: ${observedEventNames.join(", ") || "none"}.`
|
|
298
|
-
]
|
|
340
|
+
],
|
|
341
|
+
...hasUnclassifiedCadenceGap ? [
|
|
342
|
+
"Unclassified native cadence gap prevents a complete FPS result."
|
|
343
|
+
] : []
|
|
299
344
|
],
|
|
300
345
|
samples,
|
|
301
|
-
summary
|
|
346
|
+
summary,
|
|
302
347
|
clockSyncUncertaintyMs
|
|
303
348
|
};
|
|
304
349
|
}
|
|
@@ -312,6 +357,7 @@ function traceEventNames(events) {
|
|
|
312
357
|
}
|
|
313
358
|
function unavailableJavaScriptSource(kind, reason) {
|
|
314
359
|
return {
|
|
360
|
+
applicability: "applicable",
|
|
315
361
|
status: "unavailable",
|
|
316
362
|
kind,
|
|
317
363
|
unavailableReasons: [reason],
|
package/dist/adapters.js
CHANGED
|
@@ -955,7 +955,7 @@ async function executeNativeProviderAction(action, node, context, createTranspor
|
|
|
955
955
|
return executeAndroidCoordinateSwipe(node, String(device));
|
|
956
956
|
}
|
|
957
957
|
const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
|
|
958
|
-
const session = `mm-harness-${process.pid}-${context.nodeId}
|
|
958
|
+
const session = (platform === "ios" ? `mm-harness-${process.pid}-${device}` : `mm-harness-${process.pid}-${context.nodeId}`).replace(/[^a-zA-Z0-9._-]/gu, "-");
|
|
959
959
|
const stateDir = nativeAgentDeviceStateDir(context.env ?? {}, context.projectRoot, String(device));
|
|
960
960
|
let transport;
|
|
961
961
|
try {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { gzip } from "node:zlib";
|
|
2
4
|
import {
|
|
3
5
|
createMobilePerformanceBackend
|
|
4
6
|
} from "./adapters/mobile/performance-observer.js";
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
const sessions = /* @__PURE__ */ new Map();
|
|
14
16
|
const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1e3;
|
|
15
17
|
const MAX_DURATION_MS = 60 * 60 * 1e3;
|
|
18
|
+
const gzipAsync = promisify(gzip);
|
|
16
19
|
function startRunPerformanceObservation(adapter, target, artifactsDir, env, ports = {}) {
|
|
17
20
|
if (adapter === "core") return void 0;
|
|
18
21
|
const key = path.resolve(artifactsDir);
|
|
@@ -31,8 +34,8 @@ function startRunPerformanceObservation(adapter, target, artifactsDir, env, port
|
|
|
31
34
|
session.expirySweep = setInterval(() => {
|
|
32
35
|
const now = Date.now();
|
|
33
36
|
for (const window of session.windows.values()) {
|
|
34
|
-
if (!window.
|
|
35
|
-
|
|
37
|
+
if (!window.result && now - window.startedAtEpochMs >= window.maxDurationMs) {
|
|
38
|
+
void finishCaptureWindow(session, window);
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
}, 100);
|
|
@@ -58,11 +61,17 @@ function startRunPerformanceObservation(adapter, target, artifactsDir, env, port
|
|
|
58
61
|
};
|
|
59
62
|
const jsonPath = `performance/${safeId(window.id)}-summary.json`;
|
|
60
63
|
const htmlPath = `performance/${safeId(window.id)}.html`;
|
|
61
|
-
await writeArtifacts(
|
|
64
|
+
const rawTracePath = await writeArtifacts(
|
|
65
|
+
session.artifactsDir,
|
|
66
|
+
jsonPath,
|
|
67
|
+
htmlPath,
|
|
68
|
+
artifact
|
|
69
|
+
);
|
|
62
70
|
if (artifactManifestPath) {
|
|
63
71
|
await indexPerformanceArtifacts(artifactManifestPath, [
|
|
64
72
|
jsonPath,
|
|
65
|
-
htmlPath
|
|
73
|
+
htmlPath,
|
|
74
|
+
...rawTracePath ? [rawTracePath] : []
|
|
66
75
|
]);
|
|
67
76
|
}
|
|
68
77
|
}
|
|
@@ -118,7 +127,12 @@ async function handleRunPerformanceAction(platform, action, node, context) {
|
|
|
118
127
|
const htmlPath = String(
|
|
119
128
|
node.html_path ?? `performance/${safeId(id)}.html`
|
|
120
129
|
);
|
|
121
|
-
await writeArtifacts(
|
|
130
|
+
const rawTracePath = await writeArtifacts(
|
|
131
|
+
session.artifactsDir,
|
|
132
|
+
jsonPath,
|
|
133
|
+
htmlPath,
|
|
134
|
+
artifact
|
|
135
|
+
);
|
|
122
136
|
return {
|
|
123
137
|
output: {
|
|
124
138
|
action,
|
|
@@ -126,11 +140,13 @@ async function handleRunPerformanceAction(platform, action, node, context) {
|
|
|
126
140
|
phase,
|
|
127
141
|
status: artifact.status,
|
|
128
142
|
artifactPath: jsonPath,
|
|
129
|
-
htmlPath
|
|
143
|
+
htmlPath,
|
|
144
|
+
...rawTracePath ? { rawTracePath } : {}
|
|
130
145
|
},
|
|
131
146
|
artifacts: [
|
|
132
147
|
{ path: jsonPath, type: "metric", nodeId: context.nodeId },
|
|
133
|
-
{ path: htmlPath, type: "report", nodeId: context.nodeId }
|
|
148
|
+
{ path: htmlPath, type: "report", nodeId: context.nodeId },
|
|
149
|
+
...rawTracePath ? [{ path: rawTracePath, type: "trace", nodeId: context.nodeId }] : []
|
|
134
150
|
]
|
|
135
151
|
};
|
|
136
152
|
}
|
|
@@ -159,24 +175,41 @@ async function endCapture(session, window) {
|
|
|
159
175
|
);
|
|
160
176
|
}
|
|
161
177
|
function finishCaptureWindow(session, window) {
|
|
162
|
-
|
|
178
|
+
if (!window.result) {
|
|
179
|
+
window.result = endCapture(session, window);
|
|
180
|
+
void window.result.catch(() => void 0);
|
|
181
|
+
}
|
|
182
|
+
return window.result;
|
|
163
183
|
}
|
|
164
184
|
function buildPerformanceArtifact(window, endedAtEpochMs, result, allEvents) {
|
|
185
|
+
const javascriptTasksApplicable = result.platform === "extension";
|
|
186
|
+
const javascript = javascriptTasksApplicable ? result.javascript : {
|
|
187
|
+
...result.javascript,
|
|
188
|
+
applicability: "not_applicable",
|
|
189
|
+
status: "unavailable",
|
|
190
|
+
kind: "not-applicable-mobile-js-tasks",
|
|
191
|
+
unavailableReasons: [],
|
|
192
|
+
coverageGapReasons: [],
|
|
193
|
+
samples: [],
|
|
194
|
+
summary: {}
|
|
195
|
+
};
|
|
165
196
|
const nodeEvents = allEvents.filter(
|
|
166
197
|
(event) => event.epochMs >= window.startedAtEpochMs && event.epochMs <= endedAtEpochMs
|
|
167
198
|
);
|
|
168
199
|
const nodeIntervals = buildNodeIntervals(
|
|
169
200
|
nodeEvents,
|
|
170
|
-
|
|
201
|
+
javascript,
|
|
171
202
|
result.nativeUi
|
|
172
203
|
);
|
|
173
204
|
const coverageGapReasons = [
|
|
174
|
-
...
|
|
175
|
-
(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
(
|
|
179
|
-
|
|
205
|
+
...javascriptTasksApplicable ? [
|
|
206
|
+
...javascript.unavailableReasons.map(
|
|
207
|
+
(reason) => `javascript unavailable: ${reason}`
|
|
208
|
+
),
|
|
209
|
+
...javascript.coverageGapReasons.map(
|
|
210
|
+
(reason) => `javascript: ${reason}`
|
|
211
|
+
)
|
|
212
|
+
] : [],
|
|
180
213
|
...result.nativeUi.unavailableReasons.map(
|
|
181
214
|
(reason) => `nativeUi unavailable: ${reason}`
|
|
182
215
|
),
|
|
@@ -188,19 +221,20 @@ function buildPerformanceArtifact(window, endedAtEpochMs, result, allEvents) {
|
|
|
188
221
|
if (exceededMaxDuration) {
|
|
189
222
|
coverageGapReasons.push("Performance capture exceeded max_duration_ms.");
|
|
190
223
|
}
|
|
191
|
-
const status = exceededMaxDuration ? "partial" :
|
|
224
|
+
const status = exceededMaxDuration ? "partial" : javascriptTasksApplicable ? javascript.status === "unavailable" && result.nativeUi.status === "unavailable" ? "unavailable" : javascript.status === "complete" && result.nativeUi.status === "complete" ? "complete" : "partial" : result.nativeUi.status;
|
|
192
225
|
const worst = worstInteraction(nodeIntervals);
|
|
193
226
|
return {
|
|
194
|
-
schemaVersion:
|
|
227
|
+
schemaVersion: 2,
|
|
195
228
|
id: window.id,
|
|
196
229
|
status,
|
|
197
230
|
platform: result.platform,
|
|
198
231
|
startedAtEpochMs: window.startedAtEpochMs,
|
|
199
232
|
endedAtEpochMs,
|
|
200
233
|
durationMs: endedAtEpochMs - window.startedAtEpochMs,
|
|
201
|
-
javascript: { ...
|
|
234
|
+
javascript: { ...javascript, samples: [] },
|
|
202
235
|
nativeUi: { ...result.nativeUi, samples: [] },
|
|
203
236
|
trace: result.trace,
|
|
237
|
+
...result.rawTraceEvents ? { rawTraceEvents: result.rawTraceEvents } : {},
|
|
204
238
|
nodeEvents,
|
|
205
239
|
nodeIntervals,
|
|
206
240
|
...worst ? { worstInteraction: worst } : {},
|
|
@@ -222,9 +256,12 @@ function buildNodeIntervals(events, javascript, nativeUi) {
|
|
|
222
256
|
action: event.action,
|
|
223
257
|
durationMs: event.epochMs - start.epochMs,
|
|
224
258
|
endedAtEpochMs: event.epochMs,
|
|
225
|
-
javascript:
|
|
226
|
-
|
|
227
|
-
|
|
259
|
+
javascript: javascript.applicability === "not_applicable" ? { applicability: "not_applicable" } : {
|
|
260
|
+
applicability: "applicable",
|
|
261
|
+
...summarizeJavaScriptTasks(
|
|
262
|
+
samplesWithin(javascript, start.epochMs, event.epochMs)
|
|
263
|
+
)
|
|
264
|
+
},
|
|
228
265
|
nativeUi: summarizeFrames(
|
|
229
266
|
samplesWithin(nativeUi, start.epochMs, event.epochMs)
|
|
230
267
|
),
|
|
@@ -249,7 +286,10 @@ function worstInteraction(intervals) {
|
|
|
249
286
|
source: "javascriptTask"
|
|
250
287
|
},
|
|
251
288
|
{
|
|
252
|
-
longestSampleMs:
|
|
289
|
+
longestSampleMs: Math.max(
|
|
290
|
+
interval.nativeUi.longestFrameMs ?? 0,
|
|
291
|
+
interval.nativeUi.longestCadenceGapMs ?? 0
|
|
292
|
+
) || void 0,
|
|
253
293
|
source: "nativeUiFrame"
|
|
254
294
|
}
|
|
255
295
|
];
|
|
@@ -274,7 +314,7 @@ async function assertPerformance(node, context) {
|
|
|
274
314
|
node.artifact_path ?? `performance/${safeId(id)}-summary.json`
|
|
275
315
|
);
|
|
276
316
|
const artifact = await readArtifact(context.artifactsDir, artifactPath);
|
|
277
|
-
if (artifact.schemaVersion !==
|
|
317
|
+
if (artifact.schemaVersion !== 2 || artifact.id !== id) {
|
|
278
318
|
throw new Error("app.performance_assert summary contract is invalid.");
|
|
279
319
|
}
|
|
280
320
|
const statuses = Array.isArray(node.required_status) ? node.required_status.map(String) : node.required_status ? [String(node.required_status)] : [];
|
|
@@ -320,10 +360,11 @@ async function assertPerformance(node, context) {
|
|
|
320
360
|
};
|
|
321
361
|
}
|
|
322
362
|
async function writeArtifacts(artifactsDir, jsonPath, htmlPath, artifact) {
|
|
363
|
+
const { rawTraceEvents, ...summaryArtifact } = artifact;
|
|
323
364
|
await writeContainedArtifact(
|
|
324
365
|
artifactsDir,
|
|
325
366
|
jsonPath,
|
|
326
|
-
`${JSON.stringify(
|
|
367
|
+
`${JSON.stringify(summaryArtifact, null, 2)}
|
|
327
368
|
`,
|
|
328
369
|
"Performance artifact"
|
|
329
370
|
);
|
|
@@ -333,8 +374,70 @@ async function writeArtifacts(artifactsDir, jsonPath, htmlPath, artifact) {
|
|
|
333
374
|
renderPerformanceHtml(artifact),
|
|
334
375
|
"Performance artifact"
|
|
335
376
|
);
|
|
377
|
+
if (!rawTraceEvents) return void 0;
|
|
378
|
+
const traceEvents = [
|
|
379
|
+
...rawTraceEvents,
|
|
380
|
+
...recipeNodeTraceEvents(artifact.nodeIntervals)
|
|
381
|
+
];
|
|
382
|
+
const rawTracePath = chromeTracePath(jsonPath);
|
|
383
|
+
await writeContainedArtifact(
|
|
384
|
+
artifactsDir,
|
|
385
|
+
rawTracePath,
|
|
386
|
+
await gzipAsync(
|
|
387
|
+
`${JSON.stringify({
|
|
388
|
+
traceEvents,
|
|
389
|
+
displayTimeUnit: "ms",
|
|
390
|
+
metadata: {
|
|
391
|
+
source: "@deeeed/metamask-harness",
|
|
392
|
+
captureId: artifact.id,
|
|
393
|
+
platform: artifact.platform,
|
|
394
|
+
startedAtEpochMs: artifact.startedAtEpochMs,
|
|
395
|
+
endedAtEpochMs: artifact.endedAtEpochMs
|
|
396
|
+
}
|
|
397
|
+
})}
|
|
398
|
+
`
|
|
399
|
+
),
|
|
400
|
+
"Performance trace artifact"
|
|
401
|
+
);
|
|
402
|
+
return rawTracePath;
|
|
403
|
+
}
|
|
404
|
+
function recipeNodeTraceEvents(intervals) {
|
|
405
|
+
return [
|
|
406
|
+
{
|
|
407
|
+
name: "process_name",
|
|
408
|
+
ph: "M",
|
|
409
|
+
pid: 0,
|
|
410
|
+
tid: 0,
|
|
411
|
+
args: { name: "MetaMask Harness" }
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: "thread_name",
|
|
415
|
+
ph: "M",
|
|
416
|
+
pid: 0,
|
|
417
|
+
tid: 1,
|
|
418
|
+
args: { name: "Recipe nodes" }
|
|
419
|
+
},
|
|
420
|
+
...intervals.map((interval) => ({
|
|
421
|
+
name: interval.action,
|
|
422
|
+
cat: "metamask.recipe.node",
|
|
423
|
+
ph: "X",
|
|
424
|
+
ts: interval.startedAtEpochMs * 1e3,
|
|
425
|
+
dur: interval.durationMs * 1e3,
|
|
426
|
+
pid: 0,
|
|
427
|
+
tid: 1,
|
|
428
|
+
args: {
|
|
429
|
+
nodeId: interval.nodeId,
|
|
430
|
+
outcome: interval.outcome
|
|
431
|
+
}
|
|
432
|
+
}))
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
function chromeTracePath(jsonPath) {
|
|
436
|
+
return jsonPath.endsWith(".json") ? `${jsonPath.slice(0, -".json".length)}-trace.json.gz` : `${jsonPath}-trace.json.gz`;
|
|
336
437
|
}
|
|
337
438
|
function renderPerformanceHtml(artifact) {
|
|
439
|
+
const javascriptTasksApplicable = artifact.platform === "extension";
|
|
440
|
+
const iosCadence = artifact.platform === "ios";
|
|
338
441
|
const maxNodeDurationMs = Math.max(
|
|
339
442
|
1,
|
|
340
443
|
...artifact.nodeIntervals.map((interval) => interval.durationMs)
|
|
@@ -343,12 +446,20 @@ function renderPerformanceHtml(artifact) {
|
|
|
343
446
|
const ui = interval.nativeUi;
|
|
344
447
|
const js = interval.javascript;
|
|
345
448
|
const width = Math.max(1, interval.durationMs / maxNodeDurationMs * 100);
|
|
346
|
-
|
|
449
|
+
const javascriptCells = javascriptTasksApplicable ? `<td>${js.taskCount}</td><td>${metric(js.longTaskCount)}</td><td>${metric(js.longestTaskMs)}</td>` : "<td>N/A</td><td>N/A</td><td>N/A</td>";
|
|
450
|
+
return `<tr><td><strong>${escapeHtml(interval.nodeId)}</strong><div class="action">${escapeHtml(interval.action)}</div></td><td><div class="duration"><span style="width:${width.toFixed(1)}%"></span></div><small>${interval.durationMs} ms</small></td><td>${ui.frameCount}</td><td>${metric(ui.refreshRateHz, 2)}</td><td>${metric(ui.activeFps)}</td><td>${metric(ui.fpsIntervalCount)}</td><td>${metric(ui.p95FrameMs)}</td><td>${metric(ui.overBudgetFramePercent)}</td>${javascriptCells}</tr>`;
|
|
347
451
|
}).join("");
|
|
348
|
-
const worst = artifact.worstInteraction ? `${escapeHtml(artifact.worstInteraction.nodeId)}: ${artifact.worstInteraction.longestSampleMs} ms (${artifact.worstInteraction.source})` : "No attributed UI frame or JS task samples";
|
|
349
|
-
const gaps = artifact.coverageGapReasons.length ? `<ul>${artifact.coverageGapReasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}</ul>` : "<p>Both requested sources completed.</p>";
|
|
350
|
-
const
|
|
351
|
-
|
|
452
|
+
const worst = artifact.worstInteraction ? `${escapeHtml(artifact.worstInteraction.nodeId)}: ${artifact.worstInteraction.longestSampleMs} ms (${artifact.worstInteraction.source === "nativeUiFrame" && iosCadence ? "native UI cadence" : artifact.worstInteraction.source})` : javascriptTasksApplicable ? "No attributed UI frame or JS task samples" : "No attributed UI frame samples";
|
|
453
|
+
const gaps = artifact.coverageGapReasons.length ? `<ul>${artifact.coverageGapReasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}</ul>` : javascriptTasksApplicable ? "<p>Both requested sources completed.</p>" : "<p>Native UI frame coverage completed.</p>";
|
|
454
|
+
const javascriptSource = javascriptTasksApplicable ? `${escapeHtml(artifact.javascript.kind)} (${artifact.javascript.status})` : "Not applicable on React Native Mobile";
|
|
455
|
+
const traceEvents = artifact.platform === "android" ? `${artifact.trace.beginFrameCount} synthesized BeginFrame, ${artifact.trace.drawFrameCount} synthesized DrawFrame from gfxinfo` : javascriptTasksApplicable ? `${artifact.trace.beginFrameCount} BeginFrame, ${artifact.trace.drawFrameCount} DrawFrame, ${artifact.trace.runTaskCount} RunTask` : `${artifact.trace.beginFrameCount} BeginFrame, ${artifact.trace.drawFrameCount} DrawFrame, ${artifact.trace.profileChunkCount} ProfileChunk`;
|
|
456
|
+
const refreshRate = artifact.nativeUi.summary.refreshRateHz === void 0 ? "N/A" : `${metric(artifact.nativeUi.summary.refreshRateHz, 2)} Hz`;
|
|
457
|
+
const clockUncertainty = artifact.nativeUi.clockSyncUncertaintyMs === void 0 ? "" : `<br><strong>Clock mapping uncertainty:</strong> ${metric(artifact.nativeUi.clockSyncUncertaintyMs, 2)} ms`;
|
|
458
|
+
const cadenceGaps = !iosCadence || artifact.nativeUi.summary.cadenceGapCount === void 0 ? "" : `<br><strong>Unclassified cadence gaps:</strong> ${artifact.nativeUi.summary.cadenceGapCount} (longest ${metric(artifact.nativeUi.summary.longestCadenceGapMs, 2)} ms)`;
|
|
459
|
+
const sources = `<p><strong>JavaScript tasks:</strong> ${javascriptSource}<br><strong>Native UI:</strong> ${escapeHtml(artifact.nativeUi.kind)} (${artifact.nativeUi.status})<br><strong>Detected refresh rate:</strong> ${refreshRate}${clockUncertainty}${cadenceGaps}<br><strong>Trace events:</strong> ${traceEvents}</p>`;
|
|
460
|
+
const legend = javascriptTasksApplicable ? "Active UI FPS uses Chromium renderer vsync intervals while frames are being produced. Idle gaps are excluded. Fewer than five usable intervals reports N/A. Over-budget frames compare frame time with the detected refresh period. JS tasks are blocking work, not FPS." : iosCadence ? "Active UI FPS and percentiles use continuous native CADisplayLink cadence intervals. An unclassified long gap forces partial coverage and N/A FPS. It remains visible as a gap and can be the worst observed sample, but is not classified as an over-budget frame. React Native Mobile does not emit Chromium RunTask events; Hermes ProfileChunk is separate CPU-profile evidence." : "Active UI FPS uses native vsync intervals while frames are being produced. Idle gaps are excluded. Fewer than five usable intervals reports N/A. Android over-budget frames miss FrameDeadline when available, with the detected refresh period used only as a fallback. React Native Mobile does not emit Chromium RunTask events; Hermes ProfileChunk is separate CPU-profile evidence.";
|
|
461
|
+
const p95Heading = iosCadence ? "UI p95 cadence" : "UI p95 frame";
|
|
462
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>UI smoothness</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:15px system-ui,-apple-system,sans-serif;margin:0;background:#0b0d12;color:#f1f5f9}main{max-width:1320px;margin:auto;padding:32px}h1{font-size:28px;margin:0 0 6px}.lede,.action,small{color:#94a3b8}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:24px 0}.card,.coverage{padding:18px;border:1px solid #293244;border-radius:12px;background:#141821}.label{display:block;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}.value{font-size:20px;font-weight:700}.partial{color:#fbbf24}.complete{color:#6ee7b7}.unavailable{color:#f87171}.coverage{margin-top:20px}.coverage h2{font-size:16px;margin:0 0 8px}.coverage ul,.coverage p{margin:8px 0 0;color:#cbd5e1}.coverage ul{padding-left:20px}table{width:100%;border-collapse:collapse;margin-top:24px;background:#141821;border:1px solid #293244;border-radius:12px;overflow:hidden}th,td{text-align:left;padding:12px;border-bottom:1px solid #293244;vertical-align:top}th{color:#93c5fd;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.duration{height:8px;min-width:140px;background:#252c3a;border-radius:99px;overflow:hidden;margin:4px 0 5px}.duration span{display:block;height:100%;background:#7c3aed;border-radius:inherit}.legend{color:#94a3b8;font-size:13px;margin:10px 0 0}@media(max-width:760px){main{padding:20px}.cards{grid-template-columns:1fr}table{display:block;overflow-x:auto}}</style></head><body><main><h1>UI smoothness</h1><p class="lede">Native UI evidence attributed to recipe-node boundaries. Node duration includes action and wait time; it is not FPS.</p><section class="cards"><div class="card"><span class="label">Evidence status</span><span class="value ${artifact.status}">${artifact.status}</span></div><div class="card"><span class="label">Platform</span><span class="value">${artifact.platform}</span></div><div class="card"><span class="label">Worst observed sample</span><span class="value">${worst}</span></div></section><section class="coverage"><h2>Coverage</h2>${sources}${gaps}</section><table><thead><tr><th>Interaction</th><th>Node duration</th><th>UI samples</th><th>Display Hz</th><th>Active UI FPS</th><th>FPS intervals</th><th>${p95Heading}</th><th>Over-budget %</th><th>JS tasks</th><th>JS long tasks</th><th>Longest JS task</th></tr></thead><tbody>${rows}</tbody></table><p class="legend">${legend}</p></main></body></html>
|
|
352
463
|
`;
|
|
353
464
|
}
|
|
354
465
|
async function readArtifact(artifactsDir, artifactPath) {
|
|
@@ -364,7 +475,7 @@ async function indexPerformanceArtifacts(manifestPath, paths) {
|
|
|
364
475
|
manifestPath,
|
|
365
476
|
paths.map((artifactPath) => ({
|
|
366
477
|
path: artifactPath,
|
|
367
|
-
type: artifactPath.endsWith(".html") ? "report" : "metric",
|
|
478
|
+
type: artifactPath.endsWith(".html") ? "report" : artifactPath.endsWith(".json.gz") ? "trace" : "metric",
|
|
368
479
|
label: "UI smoothness observation",
|
|
369
480
|
category: "diagnostic"
|
|
370
481
|
}))
|
|
@@ -372,6 +483,7 @@ async function indexPerformanceArtifacts(manifestPath, paths) {
|
|
|
372
483
|
}
|
|
373
484
|
function unavailableCapture(session, window, reason) {
|
|
374
485
|
const unavailableJavaScript = {
|
|
486
|
+
applicability: "applicable",
|
|
375
487
|
status: "unavailable",
|
|
376
488
|
kind: "unavailable",
|
|
377
489
|
unavailableReasons: [reason.slice(0, 256)],
|
|
@@ -439,8 +551,9 @@ function boundedDuration(value) {
|
|
|
439
551
|
}
|
|
440
552
|
return duration;
|
|
441
553
|
}
|
|
442
|
-
function metric(value) {
|
|
443
|
-
|
|
554
|
+
function metric(value, digits) {
|
|
555
|
+
if (value === void 0) return "N/A";
|
|
556
|
+
return digits === void 0 ? String(value) : String(Number(value.toFixed(digits)));
|
|
444
557
|
}
|
|
445
558
|
function escapeHtml(value) {
|
|
446
559
|
return value.replace(/[&<>"']/gu, (character) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# UI smoothness capture
|
|
2
2
|
|
|
3
|
-
`app.performance_capture` attributes
|
|
3
|
+
`app.performance_capture` attributes native frame samples to the exact Recipe Protocol v1 nodes that run inside an explicit window. Mobile and Extension use the same action contract.
|
|
4
4
|
|
|
5
5
|
```json
|
|
6
6
|
{
|
|
@@ -13,21 +13,50 @@
|
|
|
13
13
|
}
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
End the same ID with `phase: "end"`. The action writes
|
|
16
|
+
End the same ID with `phase: "end"`. The action writes a machine-readable schema-v2 summary, a self-contained HTML report, and a bounded gzip-compressed Chrome Trace Event file named `*-trace.json.gz`. Chrome DevTools and Perfetto can consume the trace format. `app.performance_assert` can require a status, minimum frame count, and specific node intervals.
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
1. `app.performance_capture` with `phase: "start"` selects the platform source and starts a bounded capture window.
|
|
21
|
+
2. The recipe runner records each node's start and end on the host clock while the interaction runs.
|
|
22
|
+
3. `phase: "end"` maps native frame timestamps onto those node intervals and closes the source before building artifacts.
|
|
23
|
+
4. The harness writes summary JSON for tools, self-contained HTML for people, and a gzip-compressed Chrome trace for DevTools or Perfetto.
|
|
24
|
+
5. `app.performance_assert` checks source status, frame count, and required node attribution. Missing coverage fails or remains `N/A`; it never becomes zero.
|
|
25
|
+
|
|
26
|
+
## Host prerequisites
|
|
27
|
+
|
|
28
|
+
Run `mm-harness doctor --json` from the target checkout before performance work on a new machine.
|
|
29
|
+
|
|
30
|
+
| Target | Required host tools | Notes |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Android | `adb` from Android Platform Tools | `gfxinfo framestats` is built into Android; there is no separate `gfxinfo` package to install. |
|
|
33
|
+
| iOS Simulator | Xcode command-line tools (`xcrun`) | Native frames require a debug client with `unstable_frameRecordingEnabled: true`. Visible UI assertions and `ui.navigate page=perps-market mode=...` also require `idb`. |
|
|
34
|
+
| Extension | Chrome or Chromium with an isolated CDP profile | The harness uses Chromium's Tracing domain. |
|
|
35
|
+
| Trace viewing | Chrome DevTools or Perfetto | Optional for recipe execution; required only to inspect the raw `*-trace.json.gz` file visually. |
|
|
36
|
+
|
|
37
|
+
`Flashlight`, Instruments, and an in-app FPS overlay are not harness dependencies. Keep the recipe HUD visible; it is separate from the performance source.
|
|
17
38
|
|
|
18
39
|
## Sources and status
|
|
19
40
|
|
|
20
|
-
-
|
|
41
|
+
- Android polls `adb shell dumpsys gfxinfo <package> framestats` from the host. It maps Android uptime to recipe timestamps, merges frame snapshots, and measures `FrameCompleted - IntendedVsync` against the device's refresh-rate budget. It does not need a Mobile patch.
|
|
42
|
+
- iOS uses React Native's `Tracing.start`, `Tracing.dataCollected`, and `Tracing.end` implementation. Frame timings come from its native `CADisplayLink` observer. The debug app must report `unstable_frameRecordingEnabled: true`; otherwise native UI coverage is unavailable. Instruments `Animation Hitches` and `Core Animation FPS` do not support iOS Simulator.
|
|
43
|
+
- React Native rejects iOS Tracing after more than one RN host has been registered during the app process lifetime. Its counter is cumulative: removing the old host does not decrement it. Unlocking does not create or remove a host; the MetaMask unlock path only unlocks the wallet and navigates. The observed second host came from reopening an already-running Expo dev-client process against Metro.
|
|
44
|
+
- Establish the iOS measurement cohort explicitly: run `app.lifecycle` with `command: "restart"` outside every capture window, unlock the keyring after the process restart, prove the wallet reached Home, then start `app.performance_capture`. Unlocking does not register another host; restart → unlock → trace passed in one process. This terminates only the app process. It must not restart Metro or the simulator, and capture failure must never trigger it as automatic recovery.
|
|
21
45
|
- Extension uses Chromium's implementation of the same CDP Tracing domain. Frame and JavaScript data count only after the harness proves they belong to the MetaMask renderer. Unresolved browser-wide events produce partial or unavailable coverage.
|
|
22
|
-
-
|
|
46
|
+
- CDP sources record a clock marker and map trace timestamps onto recipe-node timestamps. They wait for `Tracing.tracingComplete` before writing evidence. Android uses the uptime value emitted by `gfxinfo`; the report exposes the measured clock-mapping uncertainty because it bounds node attribution.
|
|
23
47
|
- The JSON report includes bounded trace evidence counts for `BeginFrame`, `DrawFrame`, `RunTask`, and `ProfileChunk`, plus data-loss and retention-overflow flags.
|
|
24
|
-
- UI frames and JavaScript work remain separate sources. `RunTask` events produce JavaScript task summaries. Sampling `ProfileChunk` events prove profiler data exists but are not converted to task timing or FPS.
|
|
48
|
+
- UI frames and JavaScript work remain separate sources. Chromium `RunTask` events produce JavaScript task summaries on Extension. React Native Mobile does not emit `RunTask`, so Mobile reports those fields as not applicable and judges coverage from native UI frames. Sampling `ProfileChunk` events prove Hermes profiler data exists but are not converted to task timing or FPS.
|
|
49
|
+
- Active UI FPS uses consecutive native vsync intervals only while frames are being produced. Fewer than five usable intervals or an unclassified long cadence gap reports `N/A`. Sampling jitter can never raise active FPS above the detected refresh rate.
|
|
50
|
+
- The trace cadence determines the refresh rate and fallback per-frame budget. Android over-budget percentage uses `FrameDeadline` when present; frame percentiles use `FrameCompleted - IntendedVsync`. iOS percentiles describe continuous `CADisplayLink` cadence rather than render work duration. An unclassified long gap forces partial coverage and `N/A` FPS. It stays visible as a gap and can be the worst observed sample, but it is not classified as an over-budget frame.
|
|
25
51
|
- `complete`, `partial`, and `unavailable` describe source coverage. Missing data is never converted to zero.
|
|
52
|
+
- Raw trace timestamps use host epoch microseconds. Standard complete events on the `MetaMask Harness / Recipe nodes` track preserve the node boundaries used by the HTML and JSON reports.
|
|
26
53
|
|
|
27
54
|
`app.performance_assert.minimum_frame_count` applies only to native UI or Chromium renderer frames. JavaScript task count cannot satisfy it.
|
|
28
55
|
|
|
56
|
+
Node duration includes automation, native state checks, and wait time inside the node. It is not screen time-to-content. Use product lifecycle milestones for section visibility and data readiness.
|
|
57
|
+
|
|
29
58
|
Capture is explicit only. Use the action for comparisons on the same runtime and build. Automatic capture remains disabled until its overhead passes a matched benchmark.
|
|
30
59
|
|
|
31
60
|
## Sentry promotion boundary
|
|
32
61
|
|
|
33
|
-
This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99,
|
|
62
|
+
This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99, over-budget percentage, and longest frame. Do not send raw frame samples, recipe parameters, wallet/account identity, URLs, or artifact contents.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { observeNativeUi } from '../platform/observe-ui.mjs';
|
|
2
|
+
import {
|
|
3
|
+
normalizeVisiblePerpsState,
|
|
4
|
+
resolveVisibleStateOptions,
|
|
5
|
+
} from '../../shared/perps/visible-state.mjs';
|
|
6
|
+
|
|
7
|
+
export async function readVisiblePerpsState(
|
|
8
|
+
input,
|
|
9
|
+
observe = observeNativeUi,
|
|
10
|
+
) {
|
|
11
|
+
const options = resolveVisibleStateOptions(input.node);
|
|
12
|
+
const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
|
|
13
|
+
let observed;
|
|
14
|
+
let screen;
|
|
15
|
+
let visible;
|
|
16
|
+
let lastAssertionError;
|
|
17
|
+
let lastObservationError;
|
|
18
|
+
do {
|
|
19
|
+
try {
|
|
20
|
+
observed = await observe(
|
|
21
|
+
{ refs: ['ui.screen', 'ui.visible'], node: input.node },
|
|
22
|
+
input.context,
|
|
23
|
+
);
|
|
24
|
+
lastObservationError = undefined;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (
|
|
27
|
+
/requires the idb client|requires adb|Android SDK Platform Tools/iu.test(
|
|
28
|
+
String(error?.message ?? error),
|
|
29
|
+
)
|
|
30
|
+
) {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
lastObservationError = error;
|
|
34
|
+
observed = undefined;
|
|
35
|
+
}
|
|
36
|
+
screen = observed?.observations?.['ui.screen'];
|
|
37
|
+
visible = observed?.observations?.['ui.visible'];
|
|
38
|
+
if (screen && visible) {
|
|
39
|
+
try {
|
|
40
|
+
return {
|
|
41
|
+
action: input.action,
|
|
42
|
+
...normalizeVisiblePerpsState({
|
|
43
|
+
route: screen.route,
|
|
44
|
+
title: screen.title,
|
|
45
|
+
items: visible.items,
|
|
46
|
+
offscreenItems: visible.hidden_or_offscreen,
|
|
47
|
+
truncated: visible.truncated,
|
|
48
|
+
}, options, 'mobile'),
|
|
49
|
+
proofPath: 'native-accessibility',
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
lastAssertionError = error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() < deadline) {
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
57
|
+
}
|
|
58
|
+
} while (Date.now() < deadline);
|
|
59
|
+
if (lastAssertionError) throw lastAssertionError;
|
|
60
|
+
const warning = observed?.warnings
|
|
61
|
+
?.map((entry) => entry.message)
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join('; ');
|
|
64
|
+
const observationError = lastObservationError instanceof Error
|
|
65
|
+
? lastObservationError.message
|
|
66
|
+
: lastObservationError === undefined
|
|
67
|
+
? undefined
|
|
68
|
+
: String(lastObservationError);
|
|
69
|
+
const detail = warning ?? observationError;
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Mobile visible Perps state is unavailable${detail ? `: ${detail}` : '.'}`,
|
|
72
|
+
);
|
|
73
|
+
}
|