@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 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({ nameIncludes: waiter.nameIncludes });
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({ nameIncludes: params.nameIncludes });
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: String(session.name || previous?.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 validSamples = samples.filter(
3
+ const allValidSamples = samples.filter(
3
4
  (sample) => Number.isFinite(sample.durationMs) && sample.durationMs > 0
4
5
  );
5
- const durations = validSamples.map((sample) => sample.durationMs).sort((first2, second) => first2 - second);
6
- if (durations.length === 0) return { frameCount: 0 };
7
- const jankValues = validSamples.map((sample) => sample.janky).filter((value) => value !== void 0);
8
- const jankyFrameCount = jankValues.filter(Boolean).length;
9
- const first = validSamples[0]?.completedAtEpochMs;
10
- const last = validSamples.at(-1)?.completedAtEpochMs;
11
- const elapsedMs = first !== void 0 && last !== void 0 && last > first ? last - first : void 0;
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
- ...elapsedMs ? {
14
- averageFps: round(
15
- (validSamples.length - 1) * 1e3 / elapsedMs
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
- ...jankValues.length === validSamples.length ? {
20
- jankyFrameCount,
21
- jankyFramePercent: round(
22
- jankyFrameCount / validSamples.length * 100
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,