@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.45.1 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Stop Mobile Perps order actions from forcing a connection reset after submission; completion now relies on observed position or open-order state.
|
|
10
|
+
- Preserve the first recipe failure on every adapter instead of reloading or retrying the product runtime behind the proof.
|
|
11
|
+
- Keep Extension TP/SL updates on the current page and require persistence reloads to be explicit recipe lifecycle steps.
|
|
12
|
+
- Document and enforce the rule that ordinary proof actions cannot hide lifecycle recovery, cache clearing, state resets, or journey retries.
|
|
13
|
+
|
|
14
|
+
## 0.45.0 - 2026-08-28
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Read Android UI frames from host-side `gfxinfo framestats`, keep iOS on React Native CDP frame events, and emit bounded Chrome Trace Event artifacts beside the JSON and HTML reports.
|
|
19
|
+
- Report refresh-rate-aware active FPS only while frames are produced, cap it at the detected display rate, mark sparse windows `N/A`, and describe Mobile JavaScript tasks as not applicable.
|
|
20
|
+
- Publish the breaking UI smoothness summary schema v2 for the active-FPS field set, report unclassified iOS cadence gaps as partial with FPS unavailable, classify Android deadline misses from `FrameDeadline`, and bound retained CDP trace data to 16 MiB.
|
|
21
|
+
- Keep Mobile trace counts host-wide because React Native native frame events can use a different process from the Hermes clock marker; scope Extension events to its proven renderer process.
|
|
22
|
+
- Put recipe-node intervals on the raw Chrome trace host clock and keep Android deadline or refresh-budget classifications consistent with the summary.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Follow the ready Hermes generation for a pinned device after target rotation and wait for unlock state to settle without restarting Metro, the app, or the simulator.
|
|
27
|
+
- Preserve visible iOS testID nodes with generic accessibility roles, make Perps Lite/Pro navigation persistent and stale-snapshot-safe on both platforms, retry native state assertions until their deadline, and fall back to Agent Device when Android returns an empty UIAutomator hierarchy.
|
|
28
|
+
|
|
5
29
|
## 0.44.3 - 2026-08-28
|
|
6
30
|
|
|
7
31
|
### Fixed
|
|
@@ -414,7 +414,10 @@ function createCdpBroker({
|
|
|
414
414
|
|
|
415
415
|
function settleTargetWaiters() {
|
|
416
416
|
for (const waiter of [...targetWaiters]) {
|
|
417
|
-
const targets = targetList({
|
|
417
|
+
const targets = targetList({
|
|
418
|
+
nameIncludes: waiter.nameIncludes,
|
|
419
|
+
readyOnly: true,
|
|
420
|
+
});
|
|
418
421
|
if (targets.length === 0) continue;
|
|
419
422
|
targetWaiters.delete(waiter);
|
|
420
423
|
clearTimeout(waiter.timer);
|
|
@@ -423,7 +426,10 @@ function createCdpBroker({
|
|
|
423
426
|
}
|
|
424
427
|
|
|
425
428
|
function resolveTargets(params, timeoutMs, socket) {
|
|
426
|
-
const existing = targetList({
|
|
429
|
+
const existing = targetList({
|
|
430
|
+
nameIncludes: params.nameIncludes,
|
|
431
|
+
readyOnly: true,
|
|
432
|
+
});
|
|
427
433
|
if (existing.length > 0) return Promise.resolve(existing);
|
|
428
434
|
requestDiscovery?.('');
|
|
429
435
|
return new Promise((resolve, reject) => {
|
|
@@ -807,6 +813,18 @@ function createCdpBroker({
|
|
|
807
813
|
const session = sessions.get(deviceId);
|
|
808
814
|
if (!session?.brokerReady) return;
|
|
809
815
|
const previous = knownTargets.get(deviceId);
|
|
816
|
+
const name = String(session.name || previous?.name || '');
|
|
817
|
+
for (const [otherDeviceId, target] of knownTargets) {
|
|
818
|
+
if (
|
|
819
|
+
otherDeviceId !== deviceId &&
|
|
820
|
+
name &&
|
|
821
|
+
target.name === name &&
|
|
822
|
+
target.ready &&
|
|
823
|
+
!sessions.get(otherDeviceId)?.brokerReady
|
|
824
|
+
) {
|
|
825
|
+
knownTargets.set(otherDeviceId, { ...target, ready: false });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
810
828
|
const sessionChanged = observedSessions.get(deviceId) !== session;
|
|
811
829
|
knownTargets.set(deviceId, {
|
|
812
830
|
deviceId,
|
|
@@ -814,7 +832,7 @@ function createCdpBroker({
|
|
|
814
832
|
previous?.generation === undefined
|
|
815
833
|
? 1
|
|
816
834
|
: previous.generation + (!previous.ready || sessionChanged ? 1 : 0),
|
|
817
|
-
name
|
|
835
|
+
name,
|
|
818
836
|
ready: true,
|
|
819
837
|
});
|
|
820
838
|
observedSessions.set(deviceId, session);
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import {
|
|
4
|
+
summarizeFrames
|
|
5
|
+
} from "./frame-metrics.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const POLL_INTERVAL_MS = 750;
|
|
8
|
+
const MAX_FRAMES = 1e5;
|
|
9
|
+
const GFXINFO_FRAME_BUFFER_SIZE = 120;
|
|
10
|
+
const GFXINFO_SAFE_NEW_FRAME_LIMIT = Math.floor(
|
|
11
|
+
GFXINFO_FRAME_BUFFER_SIZE * 0.9
|
|
12
|
+
);
|
|
13
|
+
function createAndroidGfxinfoPerformanceBackend(env) {
|
|
14
|
+
const serial = String(
|
|
15
|
+
env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? env.ANDROID_DEVICE ?? ""
|
|
16
|
+
).trim();
|
|
17
|
+
if (!serial) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"Android performance capture requires ADB_SERIAL, ANDROID_SERIAL, or a serial-valued ANDROID_DEVICE from --device."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const packageId = String(env.ANDROID_PACKAGE_ID ?? "io.metamask").trim();
|
|
23
|
+
const adb = String(env.ADB_PATH ?? "adb");
|
|
24
|
+
let active;
|
|
25
|
+
return {
|
|
26
|
+
async start(id) {
|
|
27
|
+
if (active) {
|
|
28
|
+
throw new Error("Only one Android performance trace may be active.");
|
|
29
|
+
}
|
|
30
|
+
const clock = await synchronizeAndroidClock(adb, serial, packageId);
|
|
31
|
+
await adbCommand(adb, serial, [
|
|
32
|
+
"shell",
|
|
33
|
+
"dumpsys",
|
|
34
|
+
"gfxinfo",
|
|
35
|
+
packageId,
|
|
36
|
+
"reset"
|
|
37
|
+
]);
|
|
38
|
+
const capture = {
|
|
39
|
+
clock,
|
|
40
|
+
coverageGapReasons: [],
|
|
41
|
+
frames: /* @__PURE__ */ new Map(),
|
|
42
|
+
id,
|
|
43
|
+
poll: Promise.resolve(),
|
|
44
|
+
polling: false
|
|
45
|
+
};
|
|
46
|
+
active = capture;
|
|
47
|
+
capture.timer = setInterval(() => queuePoll(capture), POLL_INTERVAL_MS);
|
|
48
|
+
},
|
|
49
|
+
async end(id) {
|
|
50
|
+
const capture = active;
|
|
51
|
+
if (!capture || capture.id !== id) {
|
|
52
|
+
throw new Error(`Performance capture is not active: ${id}`);
|
|
53
|
+
}
|
|
54
|
+
if (capture.timer) clearInterval(capture.timer);
|
|
55
|
+
await capture.poll;
|
|
56
|
+
await queuePoll(capture);
|
|
57
|
+
active = void 0;
|
|
58
|
+
return captureResult(capture);
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
const capture = active;
|
|
62
|
+
if (!capture) return;
|
|
63
|
+
if (capture.timer) clearInterval(capture.timer);
|
|
64
|
+
await capture.poll;
|
|
65
|
+
await queuePoll(capture);
|
|
66
|
+
active = void 0;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
function queuePoll(capture) {
|
|
70
|
+
if (capture.polling) return capture.poll;
|
|
71
|
+
capture.polling = true;
|
|
72
|
+
capture.poll = (async () => {
|
|
73
|
+
try {
|
|
74
|
+
const output = await adbCommand(adb, serial, [
|
|
75
|
+
"shell",
|
|
76
|
+
"dumpsys",
|
|
77
|
+
"gfxinfo",
|
|
78
|
+
packageId,
|
|
79
|
+
"framestats"
|
|
80
|
+
]);
|
|
81
|
+
const parsed = parseFrameStats(output);
|
|
82
|
+
let newFrameCount = 0;
|
|
83
|
+
for (const frame of parsed.frames) {
|
|
84
|
+
if (capture.frames.size >= MAX_FRAMES) {
|
|
85
|
+
addGap(capture, "Android frame retention limit was reached.");
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const key = `${frame.vsyncId}:${frame.vsyncNs}`;
|
|
89
|
+
if (!capture.frames.has(key)) newFrameCount += 1;
|
|
90
|
+
capture.frames.set(key, frame);
|
|
91
|
+
}
|
|
92
|
+
if (parsed.bufferCapacityReached && newFrameCount >= GFXINFO_SAFE_NEW_FRAME_LIMIT) {
|
|
93
|
+
addGap(
|
|
94
|
+
capture,
|
|
95
|
+
"Android gfxinfo may have overwritten frames between polls."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
addGap(
|
|
100
|
+
capture,
|
|
101
|
+
`Android gfxinfo poll failed: ${errorMessage(error).slice(0, 180)}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
})().finally(() => {
|
|
105
|
+
capture.polling = false;
|
|
106
|
+
});
|
|
107
|
+
return capture.poll;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function captureResult(capture) {
|
|
111
|
+
const rows = [...capture.frames.values()].sort(
|
|
112
|
+
(first, second) => first.intendedVsyncNs - second.intendedVsyncNs
|
|
113
|
+
);
|
|
114
|
+
const fallbackBudgetMs = estimateFrameBudgetMs(rows);
|
|
115
|
+
const samples = rows.map((row) => {
|
|
116
|
+
const measurement = frameMeasurement(row, fallbackBudgetMs);
|
|
117
|
+
const startedAtEpochMs = capture.clock.hostEpochAtDeviceUptimeMs + row.intendedVsyncNs / 1e6;
|
|
118
|
+
return {
|
|
119
|
+
completedAtEpochMs: startedAtEpochMs + measurement.durationMs,
|
|
120
|
+
durationMs: measurement.durationMs,
|
|
121
|
+
...measurement.frameBudgetMs ? {
|
|
122
|
+
frameBudgetMs: measurement.frameBudgetMs
|
|
123
|
+
} : {},
|
|
124
|
+
...measurement.overBudget === void 0 ? {} : { overBudget: measurement.overBudget },
|
|
125
|
+
startedAtEpochMs
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const noFrames = samples.length === 0;
|
|
129
|
+
const status = noFrames ? "unavailable" : capture.coverageGapReasons.length > 0 ? "partial" : "complete";
|
|
130
|
+
const unavailableReasons = noFrames ? ["No Android gfxinfo frame samples were recorded."] : [];
|
|
131
|
+
const coverageGapReasons = [...capture.coverageGapReasons];
|
|
132
|
+
const rawTraceEvents = buildAndroidFrameTraceEvents(
|
|
133
|
+
rows,
|
|
134
|
+
capture.clock,
|
|
135
|
+
fallbackBudgetMs
|
|
136
|
+
);
|
|
137
|
+
return {
|
|
138
|
+
platform: "android",
|
|
139
|
+
javascript: notApplicableJavaScript(),
|
|
140
|
+
nativeUi: {
|
|
141
|
+
status,
|
|
142
|
+
kind: "android-gfxinfo-framestats",
|
|
143
|
+
unavailableReasons,
|
|
144
|
+
coverageGapReasons,
|
|
145
|
+
samples,
|
|
146
|
+
summary: summarizeFrames(samples),
|
|
147
|
+
clockSyncUncertaintyMs: capture.clock.uncertaintyMs
|
|
148
|
+
},
|
|
149
|
+
trace: {
|
|
150
|
+
beginFrameCount: rows.length,
|
|
151
|
+
dataLossOccurred: coverageGapReasons.length > 0,
|
|
152
|
+
drawFrameCount: rows.length,
|
|
153
|
+
overflow: capture.frames.size >= MAX_FRAMES,
|
|
154
|
+
profileChunkCount: 0,
|
|
155
|
+
runTaskCount: 0,
|
|
156
|
+
scope: "mobile-host",
|
|
157
|
+
totalEventCount: rawTraceEvents.length
|
|
158
|
+
},
|
|
159
|
+
rawTraceEvents
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function parseFrameStats(output) {
|
|
163
|
+
const frames = [];
|
|
164
|
+
let bufferCapacityReached = false;
|
|
165
|
+
let columnsByName;
|
|
166
|
+
let sectionRowCount = 0;
|
|
167
|
+
let sawHeader = false;
|
|
168
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
169
|
+
if (line === "---PROFILEDATA---") {
|
|
170
|
+
bufferCapacityReached ||= sectionRowCount >= GFXINFO_FRAME_BUFFER_SIZE;
|
|
171
|
+
columnsByName = void 0;
|
|
172
|
+
sectionRowCount = 0;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (line.startsWith("Flags,")) {
|
|
176
|
+
columnsByName = new Map(
|
|
177
|
+
line.split(",").map((name, index) => [name.trim(), index])
|
|
178
|
+
);
|
|
179
|
+
sawHeader = true;
|
|
180
|
+
const missing = ["Flags", "IntendedVsync", "Vsync", "FrameCompleted"].filter((name) => !columnsByName?.has(name));
|
|
181
|
+
if (missing.length > 0) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Unsupported Android gfxinfo framestats header; missing ${missing.join(", ")}.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!/^\d+,/u.test(line)) continue;
|
|
189
|
+
if (!columnsByName) continue;
|
|
190
|
+
const columns = line.split(",");
|
|
191
|
+
sectionRowCount += 1;
|
|
192
|
+
if (Number(column(columns, columnsByName, "Flags")) !== 0) continue;
|
|
193
|
+
const intendedVsyncNs = Number(
|
|
194
|
+
column(columns, columnsByName, "IntendedVsync")
|
|
195
|
+
);
|
|
196
|
+
const vsyncNs = Number(column(columns, columnsByName, "Vsync"));
|
|
197
|
+
const deadlineNs = Number(
|
|
198
|
+
column(columns, columnsByName, "FrameDeadline") ?? 0
|
|
199
|
+
);
|
|
200
|
+
const frameIntervalNs = Number(
|
|
201
|
+
column(columns, columnsByName, "FrameInterval") ?? 0
|
|
202
|
+
);
|
|
203
|
+
const completedNs = Number(
|
|
204
|
+
column(columns, columnsByName, "FrameCompleted")
|
|
205
|
+
);
|
|
206
|
+
if (!positiveFinite(intendedVsyncNs) || !positiveFinite(vsyncNs) || !positiveFinite(completedNs) || completedNs <= intendedVsyncNs) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
frames.push({
|
|
210
|
+
completedNs,
|
|
211
|
+
deadlineNs,
|
|
212
|
+
frameIntervalNs,
|
|
213
|
+
intendedVsyncNs,
|
|
214
|
+
vsyncId: String(
|
|
215
|
+
column(columns, columnsByName, "FrameTimelineVsyncId") ?? intendedVsyncNs
|
|
216
|
+
),
|
|
217
|
+
vsyncNs
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
bufferCapacityReached ||= sectionRowCount >= GFXINFO_FRAME_BUFFER_SIZE;
|
|
221
|
+
if (output.includes("---PROFILEDATA---") && !sawHeader) {
|
|
222
|
+
throw new Error("Android gfxinfo framestats header was not found.");
|
|
223
|
+
}
|
|
224
|
+
return { bufferCapacityReached, frames };
|
|
225
|
+
}
|
|
226
|
+
function column(columns, columnsByName, name) {
|
|
227
|
+
const index = columnsByName.get(name);
|
|
228
|
+
return index === void 0 ? void 0 : columns[index];
|
|
229
|
+
}
|
|
230
|
+
function buildAndroidFrameTraceEvents(rows, clock, fallbackBudgetMs) {
|
|
231
|
+
return rows.flatMap((row, index) => {
|
|
232
|
+
const measurement = frameMeasurement(row, fallbackBudgetMs);
|
|
233
|
+
const args = {
|
|
234
|
+
frameSeqId: row.vsyncId || String(index),
|
|
235
|
+
durationMs: measurement.durationMs,
|
|
236
|
+
...measurement.frameBudgetMs ? { frameBudgetMs: measurement.frameBudgetMs } : {},
|
|
237
|
+
...measurement.overBudget === void 0 ? {} : {
|
|
238
|
+
overBudget: measurement.overBudget,
|
|
239
|
+
overBudgetClassification: measurement.classification
|
|
240
|
+
},
|
|
241
|
+
...measurement.classification === "frame-deadline" ? { frameDeadlineMissed: measurement.overBudget } : {},
|
|
242
|
+
source: "android-gfxinfo"
|
|
243
|
+
};
|
|
244
|
+
const intendedVsyncEpochUs = (clock.hostEpochAtDeviceUptimeMs + row.intendedVsyncNs / 1e6) * 1e3;
|
|
245
|
+
const completedEpochUs = (clock.hostEpochAtDeviceUptimeMs + row.completedNs / 1e6) * 1e3;
|
|
246
|
+
return [
|
|
247
|
+
{
|
|
248
|
+
name: "BeginFrame",
|
|
249
|
+
cat: "disabled-by-default-devtools.timeline.frame",
|
|
250
|
+
ph: "I",
|
|
251
|
+
ts: intendedVsyncEpochUs,
|
|
252
|
+
pid: 1,
|
|
253
|
+
tid: 1,
|
|
254
|
+
args
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "DrawFrame",
|
|
258
|
+
cat: "disabled-by-default-devtools.timeline.frame",
|
|
259
|
+
ph: "I",
|
|
260
|
+
ts: completedEpochUs,
|
|
261
|
+
pid: 1,
|
|
262
|
+
tid: 1,
|
|
263
|
+
args
|
|
264
|
+
}
|
|
265
|
+
];
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function frameMeasurement(row, fallbackBudgetMs) {
|
|
269
|
+
const durationMs = (row.completedNs - row.intendedVsyncNs) / 1e6;
|
|
270
|
+
const frameBudgetMs = row.frameIntervalNs > 0 ? row.frameIntervalNs / 1e6 : fallbackBudgetMs;
|
|
271
|
+
if (row.deadlineNs > row.intendedVsyncNs) {
|
|
272
|
+
return {
|
|
273
|
+
classification: "frame-deadline",
|
|
274
|
+
durationMs,
|
|
275
|
+
...frameBudgetMs ? { frameBudgetMs } : {},
|
|
276
|
+
overBudget: row.completedNs > row.deadlineNs
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
...frameBudgetMs ? {
|
|
281
|
+
classification: "refresh-budget",
|
|
282
|
+
frameBudgetMs,
|
|
283
|
+
overBudget: durationMs > frameBudgetMs * 1.01
|
|
284
|
+
} : {},
|
|
285
|
+
durationMs
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async function synchronizeAndroidClock(adb, serial, packageId) {
|
|
289
|
+
const before = Date.now();
|
|
290
|
+
const output = await adbCommand(adb, serial, [
|
|
291
|
+
"shell",
|
|
292
|
+
"dumpsys",
|
|
293
|
+
"gfxinfo",
|
|
294
|
+
packageId,
|
|
295
|
+
"framestats"
|
|
296
|
+
]);
|
|
297
|
+
const after = Date.now();
|
|
298
|
+
const deviceUptimeMs = Number(/Uptime:\s+(\d+)/u.exec(output)?.[1]);
|
|
299
|
+
return androidClockSync(before, after, deviceUptimeMs);
|
|
300
|
+
}
|
|
301
|
+
function androidClockSync(beforeEpochMs, afterEpochMs, deviceUptimeMs) {
|
|
302
|
+
if (!positiveFinite(deviceUptimeMs)) {
|
|
303
|
+
throw new Error("Android performance capture could not read device uptime.");
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
hostEpochAtDeviceUptimeMs: (beforeEpochMs + afterEpochMs) / 2 - deviceUptimeMs,
|
|
307
|
+
uncertaintyMs: (afterEpochMs - beforeEpochMs) / 2
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async function adbCommand(adb, serial, args) {
|
|
311
|
+
const { stdout } = await execFileAsync(adb, ["-s", serial, ...args], {
|
|
312
|
+
encoding: "utf8",
|
|
313
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
314
|
+
timeout: 1e4
|
|
315
|
+
});
|
|
316
|
+
return stdout;
|
|
317
|
+
}
|
|
318
|
+
function estimateFrameBudgetMs(rows) {
|
|
319
|
+
const intervals = [];
|
|
320
|
+
for (let index = 1; index < rows.length; index += 1) {
|
|
321
|
+
const previous = rows[index - 1];
|
|
322
|
+
const current = rows[index];
|
|
323
|
+
if (!previous || !current) continue;
|
|
324
|
+
const intervalMs = (current.intendedVsyncNs - previous.intendedVsyncNs) / 1e6;
|
|
325
|
+
if (intervalMs >= 3.5 && intervalMs <= 40) intervals.push(intervalMs);
|
|
326
|
+
}
|
|
327
|
+
if (intervals.length < 5) return void 0;
|
|
328
|
+
intervals.sort((first, second) => first - second);
|
|
329
|
+
const observed = percentile(intervals, 0.1);
|
|
330
|
+
const refreshRateHz = Math.round(1e3 / observed);
|
|
331
|
+
return refreshRateHz >= 24 && refreshRateHz <= 240 ? 1e3 / refreshRateHz : void 0;
|
|
332
|
+
}
|
|
333
|
+
function percentile(sorted, quantile) {
|
|
334
|
+
const position = (sorted.length - 1) * quantile;
|
|
335
|
+
const lower = Math.floor(position);
|
|
336
|
+
const upper = Math.ceil(position);
|
|
337
|
+
const lowerValue = sorted[lower] ?? 0;
|
|
338
|
+
const upperValue = sorted[upper] ?? lowerValue;
|
|
339
|
+
return lowerValue + (upperValue - lowerValue) * (position - lower);
|
|
340
|
+
}
|
|
341
|
+
function notApplicableJavaScript() {
|
|
342
|
+
return {
|
|
343
|
+
applicability: "not_applicable",
|
|
344
|
+
status: "unavailable",
|
|
345
|
+
kind: "not-applicable-mobile-js-tasks",
|
|
346
|
+
unavailableReasons: [],
|
|
347
|
+
coverageGapReasons: [],
|
|
348
|
+
samples: [],
|
|
349
|
+
summary: {}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function addGap(capture, reason) {
|
|
353
|
+
if (!capture.coverageGapReasons.includes(reason)) {
|
|
354
|
+
capture.coverageGapReasons.push(reason);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function positiveFinite(value) {
|
|
358
|
+
return Number.isFinite(value) && value > 0;
|
|
359
|
+
}
|
|
360
|
+
function errorMessage(error) {
|
|
361
|
+
return error instanceof Error ? error.message : String(error);
|
|
362
|
+
}
|
|
363
|
+
export {
|
|
364
|
+
androidClockSync,
|
|
365
|
+
buildAndroidFrameTraceEvents,
|
|
366
|
+
createAndroidGfxinfoPerformanceBackend,
|
|
367
|
+
parseFrameStats
|
|
368
|
+
};
|
|
@@ -1,25 +1,69 @@
|
|
|
1
|
+
const MIN_ACTIVE_INTERVALS = 5;
|
|
1
2
|
function summarizeFrames(samples) {
|
|
2
|
-
const
|
|
3
|
+
const allValidSamples = samples.filter(
|
|
3
4
|
(sample) => Number.isFinite(sample.durationMs) && sample.durationMs > 0
|
|
4
5
|
);
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
6
|
+
const frameBudgetMs = commonFrameBudget(allValidSamples);
|
|
7
|
+
const cadenceSamples = allValidSamples.length > 0 && allValidSamples.every((sample) => sample.cadenceInterval === true);
|
|
8
|
+
const cadenceGaps = cadenceSamples && frameBudgetMs ? allValidSamples.filter(
|
|
9
|
+
(sample) => sample.durationMs > frameBudgetMs * 3.01
|
|
10
|
+
) : [];
|
|
11
|
+
const validSamples = cadenceGaps.length > 0 ? allValidSamples.filter(
|
|
12
|
+
(sample) => sample.durationMs <= (frameBudgetMs ?? 0) * 3.01
|
|
13
|
+
) : allValidSamples;
|
|
14
|
+
const durations = validSamples.map((sample) => sample.durationMs).sort((first, second) => first - second);
|
|
15
|
+
if (durations.length === 0) {
|
|
16
|
+
return {
|
|
17
|
+
fpsUnavailableReason: frameBudgetMs ? "insufficient_active_frames" : "refresh_rate_unavailable",
|
|
18
|
+
...cadenceGaps.length > 0 ? {
|
|
19
|
+
cadenceGapCount: cadenceGaps.length,
|
|
20
|
+
longestCadenceGapMs: round(
|
|
21
|
+
Math.max(...cadenceGaps.map((sample) => sample.durationMs))
|
|
22
|
+
)
|
|
23
|
+
} : {},
|
|
24
|
+
...frameBudgetMs ? {
|
|
25
|
+
frameBudgetMs: round(frameBudgetMs),
|
|
26
|
+
refreshRateHz: round(1e3 / frameBudgetMs)
|
|
27
|
+
} : {},
|
|
28
|
+
frameCount: 0
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const frameBudgetOutcomes = validSamples.map((sample) => sample.overBudget).filter((value) => value !== void 0);
|
|
32
|
+
const missedFrameBudgetCount = frameBudgetOutcomes.filter(Boolean).length;
|
|
33
|
+
const activeIntervals = frameBudgetMs ? activeRenderingIntervals(validSamples, frameBudgetMs) : [];
|
|
34
|
+
const activeElapsedMs = activeIntervals.reduce(
|
|
35
|
+
(total, interval) => total + interval,
|
|
36
|
+
0
|
|
37
|
+
);
|
|
38
|
+
const hasActiveFps = cadenceGaps.length === 0 && activeIntervals.length >= MIN_ACTIVE_INTERVALS && activeElapsedMs > 0;
|
|
39
|
+
const refreshRateHz = frameBudgetMs ? 1e3 / frameBudgetMs : void 0;
|
|
12
40
|
return {
|
|
13
|
-
...
|
|
14
|
-
|
|
15
|
-
(
|
|
41
|
+
...hasActiveFps ? {
|
|
42
|
+
activeFps: round(
|
|
43
|
+
Math.min(
|
|
44
|
+
refreshRateHz ?? Number.POSITIVE_INFINITY,
|
|
45
|
+
activeIntervals.length * 1e3 / activeElapsedMs
|
|
46
|
+
)
|
|
47
|
+
),
|
|
48
|
+
fpsIntervalCount: activeIntervals.length
|
|
49
|
+
} : {
|
|
50
|
+
fpsUnavailableReason: frameBudgetMs ? cadenceGaps.length > 0 ? "unclassified_cadence_gap" : "insufficient_active_frames" : "refresh_rate_unavailable"
|
|
51
|
+
},
|
|
52
|
+
...cadenceGaps.length > 0 ? {
|
|
53
|
+
cadenceGapCount: cadenceGaps.length,
|
|
54
|
+
longestCadenceGapMs: round(
|
|
55
|
+
Math.max(...cadenceGaps.map((sample) => sample.durationMs))
|
|
16
56
|
)
|
|
17
57
|
} : {},
|
|
58
|
+
...frameBudgetMs ? {
|
|
59
|
+
frameBudgetMs: round(frameBudgetMs),
|
|
60
|
+
refreshRateHz: round(1e3 / frameBudgetMs)
|
|
61
|
+
} : {},
|
|
18
62
|
frameCount: durations.length,
|
|
19
|
-
...
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
63
|
+
...frameBudgetOutcomes.length === validSamples.length ? {
|
|
64
|
+
overBudgetFrameCount: missedFrameBudgetCount,
|
|
65
|
+
overBudgetFramePercent: round(
|
|
66
|
+
missedFrameBudgetCount / validSamples.length * 100
|
|
23
67
|
)
|
|
24
68
|
} : {},
|
|
25
69
|
longestFrameMs: round(durations.at(-1) ?? 0),
|
|
@@ -28,6 +72,35 @@ function summarizeFrames(samples) {
|
|
|
28
72
|
p99FrameMs: round(percentile(durations, 0.99))
|
|
29
73
|
};
|
|
30
74
|
}
|
|
75
|
+
function commonFrameBudget(samples) {
|
|
76
|
+
const budgets = samples.map((sample) => sample.frameBudgetMs).filter(
|
|
77
|
+
(value) => value !== void 0 && Number.isFinite(value) && value > 0
|
|
78
|
+
).sort((first, second) => first - second);
|
|
79
|
+
return budgets.length > 0 ? percentile(budgets, 0.5) : void 0;
|
|
80
|
+
}
|
|
81
|
+
function activeRenderingIntervals(samples, frameBudgetMs) {
|
|
82
|
+
if (samples.every((sample) => sample.cadenceInterval === true)) {
|
|
83
|
+
return samples.map((sample) => sample.durationMs).filter(
|
|
84
|
+
(durationMs) => Number.isFinite(durationMs) && durationMs > 0 && durationMs <= frameBudgetMs * 3.01
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const ordered = [...samples].map((sample) => ({
|
|
88
|
+
...sample,
|
|
89
|
+
startedAtEpochMs: sample.startedAtEpochMs ?? sample.completedAtEpochMs - sample.durationMs
|
|
90
|
+
})).sort((first, second) => first.startedAtEpochMs - second.startedAtEpochMs);
|
|
91
|
+
const intervals = [];
|
|
92
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
93
|
+
const previous = ordered[index - 1];
|
|
94
|
+
const current = ordered[index];
|
|
95
|
+
if (!previous || !current) continue;
|
|
96
|
+
const interval = current.startedAtEpochMs - previous.startedAtEpochMs;
|
|
97
|
+
if (!Number.isFinite(interval) || interval <= 0) continue;
|
|
98
|
+
const continuous = interval <= frameBudgetMs * 3.01;
|
|
99
|
+
const explainedBySlowFrame = Math.max(previous.durationMs, current.durationMs) >= interval - frameBudgetMs * 1.01;
|
|
100
|
+
if (continuous || explainedBySlowFrame) intervals.push(interval);
|
|
101
|
+
}
|
|
102
|
+
return intervals;
|
|
103
|
+
}
|
|
31
104
|
function percentile(sorted, quantile) {
|
|
32
105
|
if (sorted.length === 1) return sorted[0] ?? 0;
|
|
33
106
|
const position = (sorted.length - 1) * quantile;
|
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
connectMobileBroker
|
|
6
6
|
} from "../../network-observation.js";
|
|
7
|
+
import { createAndroidGfxinfoPerformanceBackend } from "./android-gfxinfo-performance.js";
|
|
7
8
|
async function createMobilePerformanceBackend(target, env) {
|
|
9
|
+
if (resolvePlatform(env) === "android") {
|
|
10
|
+
return createAndroidGfxinfoPerformanceBackend(env);
|
|
11
|
+
}
|
|
8
12
|
const { client } = await connectMobileBroker(target, env);
|
|
9
13
|
return createCdpTraceCollector(
|
|
10
14
|
client,
|