@deeeed/metamask-harness 0.44.3 → 0.45.1
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 +24 -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/commands/call.js +2 -9
- package/dist/commands/run-engine.js +7 -71
- package/dist/commands/run.js +2 -7
- package/dist/performance-observation.js +145 -32
- package/docs/CONTRIBUTING.md +8 -0
- package/docs/PERFORMANCE-CAPTURE.md +35 -6
- package/docs/RECIPES.md +13 -1
- package/library/actions/extension/perps/update_position_tpsl.mjs +3 -8
- package/library/actions/mobile/perps/perps.mjs +1 -5
- 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 +1 -1
|
@@ -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/commands/call.js
CHANGED
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
executeWithHealBounds,
|
|
35
35
|
prepareHeal,
|
|
36
36
|
persistRunEffects,
|
|
37
|
-
recoverRunInfra,
|
|
38
37
|
resolveMetaMaskLibrarySources,
|
|
39
38
|
preflightRecipe,
|
|
40
39
|
runRecipe,
|
|
@@ -289,7 +288,7 @@ async function handleCall(argv) {
|
|
|
289
288
|
skipMobileSourceFreshness: authoredMobileRestart || adapter === "mobile" && readMobileReleaseArtifactState(target) !== null
|
|
290
289
|
});
|
|
291
290
|
if (typeof prepared === "number") return prepared;
|
|
292
|
-
const { state
|
|
291
|
+
const { state } = prepared;
|
|
293
292
|
preflightedExecution = await preflightRecipe(
|
|
294
293
|
adapter,
|
|
295
294
|
recipe,
|
|
@@ -335,14 +334,8 @@ async function handleCall(argv) {
|
|
|
335
334
|
state
|
|
336
335
|
);
|
|
337
336
|
},
|
|
338
|
-
adapter,
|
|
339
337
|
target,
|
|
340
|
-
|
|
341
|
-
state,
|
|
342
|
-
() => recoverRunInfra(adapter, target, json, {
|
|
343
|
-
cdpPort: optionString(options, "cdpPort"),
|
|
344
|
-
watcherPort: optionString(options, "watcherPort") ?? optionString(options, "metroPort")
|
|
345
|
-
})
|
|
338
|
+
state
|
|
346
339
|
);
|
|
347
340
|
} catch (error) {
|
|
348
341
|
await networkObservation?.finalize().catch(() => void 0);
|
|
@@ -1358,44 +1358,6 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
|
|
|
1358
1358
|
});
|
|
1359
1359
|
return null;
|
|
1360
1360
|
}
|
|
1361
|
-
const RUN_RECOVERY_CODE = {
|
|
1362
|
-
mobile: "mobile.runtime_recovered",
|
|
1363
|
-
extension: "extension.runtime_recovered",
|
|
1364
|
-
core: "run.retried"
|
|
1365
|
-
};
|
|
1366
|
-
async function recoverRunInfra(adapter, target, json, runtimeOptions = {}) {
|
|
1367
|
-
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
1368
|
-
const previousCdpPort = process.env.CDP_PORT;
|
|
1369
|
-
const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
|
|
1370
|
-
if (runtimeOptions.cdpPort) {
|
|
1371
|
-
process.env.CDP_PORT = runtimeOptions.cdpPort;
|
|
1372
|
-
process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
|
|
1373
|
-
}
|
|
1374
|
-
try {
|
|
1375
|
-
if (adapter === "extension") {
|
|
1376
|
-
const { releaseArtifactState } = await import("../adapters/extension/runtime-decision.js");
|
|
1377
|
-
const artifact = releaseArtifactState(target);
|
|
1378
|
-
if (artifact.status !== "none" && artifact.status !== "valid") {
|
|
1379
|
-
return {
|
|
1380
|
-
status: 1,
|
|
1381
|
-
output: "Release artifact recovery cannot trust the recorded runtime identity.\nNext: rerun runtime-launch with the same artifact source."
|
|
1382
|
-
};
|
|
1383
|
-
}
|
|
1384
|
-
const { launchExtension } = await import("./launch/extension.js");
|
|
1385
|
-
return launchExtension(target, "quick", false);
|
|
1386
|
-
}
|
|
1387
|
-
if (adapter === "mobile") {
|
|
1388
|
-
return {
|
|
1389
|
-
status: 1,
|
|
1390
|
-
output: "Automatic Mobile runtime recovery is disabled.\nNext: use an explicit app.lifecycle action when the recipe intends to relaunch the app."
|
|
1391
|
-
};
|
|
1392
|
-
}
|
|
1393
|
-
return { status: 0, output: "headless run retry" };
|
|
1394
|
-
} finally {
|
|
1395
|
-
restoreEnv("CDP_PORT", previousCdpPort);
|
|
1396
|
-
restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
1361
|
function readRunFailureText(result) {
|
|
1400
1362
|
try {
|
|
1401
1363
|
const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
|
|
@@ -1407,38 +1369,13 @@ function readRunFailureText(result) {
|
|
|
1407
1369
|
return "";
|
|
1408
1370
|
}
|
|
1409
1371
|
}
|
|
1410
|
-
async function executeWithHealBounds(exec,
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
state
|
|
1418
|
-
);
|
|
1419
|
-
if (violation !== null) return { result, violation };
|
|
1420
|
-
if (adapter === "mobile") return { result, violation: null };
|
|
1421
|
-
if (heal === "off") return { result, violation: null };
|
|
1422
|
-
const recoveryCode = RUN_RECOVERY_CODE[adapter];
|
|
1423
|
-
state.attemptedRecoveries.push(recoveryCode);
|
|
1424
|
-
onRecovery(recoveryCode);
|
|
1425
|
-
const recovery = await recover();
|
|
1426
|
-
if (recovery.status !== 0) {
|
|
1427
|
-
return { result, violation: null };
|
|
1428
|
-
}
|
|
1429
|
-
if (adapter === "extension") {
|
|
1430
|
-
state.mutations.push({
|
|
1431
|
-
type: "runtime",
|
|
1432
|
-
action: "reloaded",
|
|
1433
|
-
reasonCode: recoveryCode
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
result = await exec();
|
|
1437
|
-
if (result.status === "pass") {
|
|
1438
|
-
state.recovered.push(recoveryCode);
|
|
1439
|
-
return { result, violation: null };
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1372
|
+
async function executeWithHealBounds(exec, target, state) {
|
|
1373
|
+
const result = await exec();
|
|
1374
|
+
if (result.status === "pass") return { result, violation: null };
|
|
1375
|
+
return {
|
|
1376
|
+
result,
|
|
1377
|
+
violation: checkHealBounds(target, readRunFailureText(result), state)
|
|
1378
|
+
};
|
|
1442
1379
|
}
|
|
1443
1380
|
function emitHealViolation(json, command, result, violation, state, adapter) {
|
|
1444
1381
|
const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'create temp/recipe/runtime/wallet-fixture.json, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction ?? `inspect ${shellQuoteArg(result.summaryPath)} and ${shellQuoteArg(result.tracePath)}; fix the application or recipe failure before retrying`;
|
|
@@ -1499,7 +1436,6 @@ export {
|
|
|
1499
1436
|
preflightRecipe,
|
|
1500
1437
|
prepareHeal,
|
|
1501
1438
|
prepareRuntimeIfNeeded,
|
|
1502
|
-
recoverRunInfra,
|
|
1503
1439
|
resolveMetaMaskLibrarySources,
|
|
1504
1440
|
resolveRunRecipeArg,
|
|
1505
1441
|
runOneNode,
|
package/dist/commands/run.js
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
preflightRecipe,
|
|
23
23
|
prepareHeal,
|
|
24
24
|
persistRunEffects,
|
|
25
|
-
recoverRunInfra,
|
|
26
25
|
runRecipe,
|
|
27
26
|
validateRunRecipeStatic
|
|
28
27
|
} from "./run-engine.js";
|
|
@@ -304,7 +303,7 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
304
303
|
});
|
|
305
304
|
return prepared;
|
|
306
305
|
}
|
|
307
|
-
const { state
|
|
306
|
+
const { state } = prepared;
|
|
308
307
|
preflightedExecution = await preflightRecipe(
|
|
309
308
|
adapter,
|
|
310
309
|
validated.recipeFile,
|
|
@@ -353,12 +352,8 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
353
352
|
state
|
|
354
353
|
);
|
|
355
354
|
},
|
|
356
|
-
adapter,
|
|
357
355
|
target,
|
|
358
|
-
|
|
359
|
-
state,
|
|
360
|
-
() => recoverRunInfra(adapter, target, machine, runtimeOptions),
|
|
361
|
-
(code) => stream.phase("recover", { code })
|
|
356
|
+
state
|
|
362
357
|
);
|
|
363
358
|
} catch (error) {
|
|
364
359
|
await networkObservation?.finalize().catch(() => void 0);
|
|
@@ -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) => {
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -108,6 +108,14 @@ Write evidence under `context.artifactsDir` and return relative artifact paths.
|
|
|
108
108
|
`METAMASK_RECIPE_LIVE_ADAPTER_DIR` is the explicit task-local implementation
|
|
109
109
|
root; configured libraries are the durable sharing mechanism.
|
|
110
110
|
|
|
111
|
+
An ordinary action performs only its declared operation and observes the
|
|
112
|
+
product's result. It must not restart or reload the app, reconnect providers or
|
|
113
|
+
streams, clear caches, reset state, or retry a failed journey. Those operations
|
|
114
|
+
can hide the product failure the recipe exists to catch. If lifecycle is the
|
|
115
|
+
subject, expose it as an explicit recipe node with separate postconditions. If
|
|
116
|
+
transport or lifecycle fails during another action, return the failure and keep
|
|
117
|
+
the evidence. Do not repair and continue.
|
|
118
|
+
|
|
111
119
|
Add a bundled action only when it is reusable, typed, stable, reduces inference
|
|
112
120
|
or risk, and has a real postcondition. Otherwise use a team library or task-local
|
|
113
121
|
recipe. Follow [Recipes](RECIPES.md) and [Security](SECURITY.md).
|