@farmslot/adapter-rn 0.30.0 → 0.31.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.
@@ -0,0 +1,751 @@
1
+ // app.performance_capture on adapter-rn runs. `phase: "start"` opens a named
2
+ // window on the device's native frames, `phase: "end"` closes it and writes
3
+ // its summary (frames, the lowest sustained FPS, the latency of every press
4
+ // inside it, and the window's budget verdict), and `phase: "report"` writes
5
+ // one report over every window of the run and fails the node when a window
6
+ // broke its budget. Targets are reported, never failed on.
7
+ import { execFile } from 'node:child_process';
8
+ import { mkdir, writeFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { promisify } from 'node:util';
11
+ import { RecipeExecutionError, } from '@farmslot/recipe-runner';
12
+ import { lowestWindowedFps, percentile, round, summarizeFrames, } from '@farmslot/recipe-runner/runtime/cdp-trace';
13
+ import { createAndroidGfxinfoPerformanceBackend, } from './android-gfxinfo-performance.js';
14
+ import { resolveMobileToolPath } from './tool-paths.js';
15
+ export const PERFORMANCE_CAPTURE_ACTION = 'app.performance_capture';
16
+ const execFileAsync = promisify(execFile);
17
+ // Actions whose node is one press: their latency runs from the frame that
18
+ // handled the input to the frame that answered it.
19
+ const PRESS_ACTIONS = new Set(['ui.press', 'ui.key_press']);
20
+ const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1000;
21
+ const MAX_DURATION_MS = 60 * 60 * 1000;
22
+ const DEFAULT_SUSTAINED_WINDOW_MS = 500;
23
+ // How long after the touch a first frame can answer a press that mounts
24
+ // nothing: the window the atrace tab-press measurements searched (600 ms).
25
+ const DEFAULT_RESPONSE_WINDOW_MS = 600;
26
+ // A mount frame stands out from the touch and animation frames around it
27
+ // (about 1 ms of UI-thread work on a Pixel 6; tab mounts measured 9-80 ms):
28
+ // at least MOUNT_MIN_MS, and either a whole frame budget of UI-thread work or
29
+ // MOUNT_OVER_MEDIAN times the median of the other frames after the touch.
30
+ const MOUNT_MIN_MS = 4;
31
+ const MOUNT_OVER_MEDIAN = 3;
32
+ // A mount up to this many response windows after the touch answers the press,
33
+ // even after a pause (a JavaScript stall delays it): 900 ms by default. Past
34
+ // the window only a whole frame budget of UI-thread work counts as the mount;
35
+ // a live update on an already-mounted screen is lighter (main's release
36
+ // captures had a 6.9 ms update 1076 ms after a tab press).
37
+ const MOUNT_LOOKAHEAD = 1.5;
38
+ // Frames closer than this are one stretch of rendering for sustained FPS, so a
39
+ // slow cadence (4 vsyncs at 90 Hz is 22.5 fps) counts against the floor.
40
+ const MAX_ACTIVE_GAP_MS = 100;
41
+ const DEFAULT_FRAME_BUDGET_MS = 1000 / 60;
42
+ // The app logs its shown line from the second animation frame after focus,
43
+ // once JavaScript is free, which can be before the UI thread finishes the
44
+ // frame that mounts the screen. A shown press ends at the end of the last
45
+ // frame that started by this many frame budgets after the line.
46
+ const SHOWN_MARKER_FRAMES = 2;
47
+ // The metrics failures name, so a reader knows what was measured.
48
+ const PRESS_METRIC = 'gfxinfo input->mount frame';
49
+ const SHOWN_PRESS_METRIC = 'gfxinfo input->shown marker';
50
+ const FPS_METRIC = 'gfxinfo, lowest 500 ms';
51
+ export function createPerformanceCaptureSession({ env, logger = console, dryRun = false, createBackend = createDeviceBackend, now = Date.now, }) {
52
+ const spans = [];
53
+ const windows = new Map();
54
+ const summaries = [];
55
+ let backend;
56
+ const resolveBackend = () => (backend ??= createBackend(env));
57
+ function finish(window) {
58
+ window.result ??= resolveBackend().end(window.id);
59
+ return window.result;
60
+ }
61
+ async function execute(node, context) {
62
+ const phase = String(node.phase ?? '').toLowerCase();
63
+ if (dryRun) {
64
+ return {
65
+ output: { action: PERFORMANCE_CAPTURE_ACTION, phase, dryRun: true, nodeId: context.nodeId },
66
+ };
67
+ }
68
+ if (phase === 'report')
69
+ return report(node, context);
70
+ const id = String(node.id ?? '').trim();
71
+ if (!['start', 'end'].includes(phase) || !id) {
72
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} requires phase=start|end with a non-empty id, or phase=report.`);
73
+ }
74
+ if (phase === 'start') {
75
+ if (windows.size > 0) {
76
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} window ${[...windows.keys()][0]} is still open; end it before starting ${id}.`);
77
+ }
78
+ await resolveBackend().start(id);
79
+ windows.set(id, {
80
+ id,
81
+ maxDurationMs: boundedDuration(node.max_duration_ms),
82
+ startedAtEpochMs: now(),
83
+ });
84
+ return { output: { action: PERFORMANCE_CAPTURE_ACTION, id, phase, status: 'started' } };
85
+ }
86
+ const window = windows.get(id);
87
+ if (!window) {
88
+ throw new RecipeExecutionError('harness', `Performance capture is not active: ${id}`);
89
+ }
90
+ windows.delete(id);
91
+ const result = await finish(window);
92
+ const summary = summarizeWindow({
93
+ id,
94
+ startedAtEpochMs: window.startedAtEpochMs,
95
+ endedAtEpochMs: now(),
96
+ maxDurationMs: window.maxDurationMs,
97
+ result,
98
+ spans,
99
+ budget: parseBudget(node.budget),
100
+ shownMarkers: parseShownMarkerMap(node.shown_markers),
101
+ baseline: parseBaseline(node.baseline),
102
+ targets: parseTargets(node.targets),
103
+ sustainedWindowMs: positiveNumber(node.sustained_window_ms) ?? DEFAULT_SUSTAINED_WINDOW_MS,
104
+ responseWindowMs: positiveNumber(node.response_window_ms) ?? DEFAULT_RESPONSE_WINDOW_MS,
105
+ });
106
+ summaries.push(summary);
107
+ const artifactPath = `performance/${safeId(id)}-summary.json`;
108
+ // The frames (and shown lines) the summary was read from, so a reviewer can
109
+ // check which frame answered a press.
110
+ const framesPath = `performance/${safeId(id)}-frames.json`;
111
+ await writeArtifact(context, artifactPath, `${JSON.stringify(summary, null, 2)}\n`);
112
+ await writeArtifact(context, framesPath, `${JSON.stringify({
113
+ schemaVersion: 1,
114
+ id,
115
+ spans: windowSpans(spans, summary),
116
+ frames: result.nativeUi.samples,
117
+ ...(result.shownMarkers ? { shownMarkers: result.shownMarkers } : {}),
118
+ })}\n`);
119
+ return {
120
+ output: { action: PERFORMANCE_CAPTURE_ACTION, phase, artifactPath, framesPath, ...summary },
121
+ artifacts: [
122
+ { path: artifactPath, type: 'metric', nodeId: context.nodeId, label: `Performance ${id}` },
123
+ { path: framesPath, type: 'json', nodeId: context.nodeId, label: `Frames ${id}` },
124
+ ],
125
+ };
126
+ }
127
+ async function report(node, context) {
128
+ const title = typeof node.title === 'string' && node.title.trim() ? node.title.trim() : 'Performance';
129
+ if (summaries.length === 0) {
130
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} phase=report found no ended capture window.`);
131
+ }
132
+ const failed = summaries.filter((summary) => summary.budget?.verdict === 'fail');
133
+ const inconclusive = summaries.filter((summary) => summary.budget?.verdict === 'inconclusive');
134
+ const environment = {
135
+ device: await describeDevice(),
136
+ checkout: await describeCheckout(context.projectRoot, logger),
137
+ };
138
+ const document = {
139
+ schemaVersion: 1,
140
+ verdict: failed.length > 0 ? 'fail' : inconclusive.length > 0 ? 'inconclusive' : 'pass',
141
+ environment,
142
+ windows: summaries,
143
+ };
144
+ const jsonPath = 'performance/report.json';
145
+ const markdownPath = 'performance/report.md';
146
+ await writeArtifact(context, jsonPath, `${JSON.stringify(document, null, 2)}\n`);
147
+ await writeArtifact(context, markdownPath, renderPerformanceReport(title, summaries, environment));
148
+ const artifacts = [
149
+ { path: jsonPath, type: 'metric', nodeId: context.nodeId, label: `${title} report` },
150
+ { path: markdownPath, type: 'report', nodeId: context.nodeId, label: `${title} report` },
151
+ ];
152
+ if (failed.length > 0 || inconclusive.length > 0) {
153
+ for (const artifact of artifacts)
154
+ context.registerArtifact(artifact);
155
+ const reasons = [
156
+ ...failed.map((summary) => `${summary.id} (${summary.budget?.failures.join('; ')})`),
157
+ ...inconclusive.map((summary) => `${summary.id} inconclusive (${summary.budget?.inconclusive.join('; ')})`),
158
+ ];
159
+ throw new RecipeExecutionError(failed.length > 0 ? 'subject' : 'environment', `Performance budget ${failed.length > 0 ? 'failed' : 'inconclusive'}: ${reasons.join(', ')}`, {
160
+ code: failed.length > 0 ? 'PERFORMANCE_BUDGET_FAILED' : 'PERFORMANCE_INCONCLUSIVE',
161
+ details: document,
162
+ });
163
+ }
164
+ return {
165
+ output: { action: PERFORMANCE_CAPTURE_ACTION, phase: 'report', ...document },
166
+ artifacts,
167
+ };
168
+ }
169
+ async function describeDevice() {
170
+ try {
171
+ return await backend?.describe?.();
172
+ }
173
+ catch (error) {
174
+ // The report still renders without the device line; the reason is logged.
175
+ logger.warn(`Performance report could not describe the device: ${errorMessage(error)}`);
176
+ return undefined;
177
+ }
178
+ }
179
+ return {
180
+ handler: {
181
+ action: PERFORMANCE_CAPTURE_ACTION,
182
+ // Package code, not a recipe's: trusted like the runner's built-in actions.
183
+ source: { kind: 'bundled', trust: 'trusted', name: '@farmslot/adapter-rn' },
184
+ execute,
185
+ },
186
+ recordNodeEvent(event) {
187
+ if (event.status === 'running') {
188
+ spans.push({
189
+ nodeId: event.nodeId,
190
+ action: event.action,
191
+ ...(event.target ? { target: event.target } : {}),
192
+ startedAtEpochMs: now(),
193
+ });
194
+ return;
195
+ }
196
+ for (let index = spans.length - 1; index >= 0; index -= 1) {
197
+ const span = spans[index];
198
+ if (span?.nodeId !== event.nodeId || span.endedAtEpochMs !== undefined)
199
+ continue;
200
+ span.endedAtEpochMs = now();
201
+ return;
202
+ }
203
+ },
204
+ async close() {
205
+ const open = [...windows.values()];
206
+ windows.clear();
207
+ for (const window of open) {
208
+ // A window still open here belongs to a run that failed before its
209
+ // phase=end. Ending it stops the polling; its error must not replace
210
+ // the run's own, so it is logged.
211
+ await finish(window).catch((error) => {
212
+ logger.warn(`Performance window ${window.id} did not end: ${errorMessage(error)}`);
213
+ });
214
+ }
215
+ await backend?.close();
216
+ },
217
+ };
218
+ }
219
+ function createDeviceBackend(env) {
220
+ const platform = env.PLATFORM ?? '';
221
+ if (!platform.startsWith('android')) {
222
+ throw new RecipeExecutionError('environment', `${PERFORMANCE_CAPTURE_ACTION} reads Android gfxinfo frames; it needs PLATFORM=android (got ${platform || 'none'}).`);
223
+ }
224
+ const serial = env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? env.ANDROID_DEVICE ?? '';
225
+ const packageId = env.FARMSLOT_RECIPE_APP_ID ?? '';
226
+ if (!serial || !packageId) {
227
+ throw new RecipeExecutionError('environment', `${PERFORMANCE_CAPTURE_ACTION} needs ADB_SERIAL and FARMSLOT_RECIPE_APP_ID for the target app.`);
228
+ }
229
+ return createAndroidGfxinfoPerformanceBackend({
230
+ serial,
231
+ packageId,
232
+ adbPath: resolveMobileToolPath('adb', { required: true }) ?? 'adb',
233
+ shownMarkers: true,
234
+ });
235
+ }
236
+ export function summarizeWindow({ id, startedAtEpochMs, endedAtEpochMs, maxDurationMs, result, spans, budget, shownMarkers = {}, baseline, targets, sustainedWindowMs, responseWindowMs, }) {
237
+ const samples = result.nativeUi.samples;
238
+ const coverageGapReasons = [...result.nativeUi.coverageGapReasons];
239
+ const exceeded = endedAtEpochMs - startedAtEpochMs >= maxDurationMs;
240
+ if (exceeded)
241
+ coverageGapReasons.push('Performance capture exceeded max_duration_ms.');
242
+ const pressSpans = spans.filter((span) => PRESS_ACTIONS.has(span.action) &&
243
+ span.startedAtEpochMs >= startedAtEpochMs &&
244
+ (span.endedAtEpochMs ?? endedAtEpochMs) <= endedAtEpochMs);
245
+ const markers = (result.shownMarkers?.markers ?? []).filter((marker) => marker.atEpochMs >= startedAtEpochMs && marker.atEpochMs <= endedAtEpochMs);
246
+ // An app that logs no shown line (an older build) keeps the frame rule, flagged.
247
+ const markerTimed = Object.keys(shownMarkers).length > 0 && markers.length > 0;
248
+ const frames = summarizeFrames(samples);
249
+ const presses = pressSpans.map((span, index) => {
250
+ const screen = markerTimed && span.target ? shownMarkers[span.target] : undefined;
251
+ return result.inputEvents === 'missing'
252
+ ? {
253
+ nodeId: span.nodeId,
254
+ action: span.action,
255
+ ...(span.target ? { target: span.target } : {}),
256
+ unmeasuredReason: "this device's gfxinfo framestats has no InputEventId column (Android 12 or later is needed to time presses)",
257
+ }
258
+ : screen
259
+ ? measureShownPress(samples, span, screen, markers, pressSpans[index + 1]?.startedAtEpochMs ?? endedAtEpochMs,
260
+ // A window with no refresh budget is inconclusive whatever this reads.
261
+ frames.frameBudgetMs ?? DEFAULT_FRAME_BUDGET_MS)
262
+ : measurePress(samples, span, responseWindowMs, endedAtEpochMs);
263
+ });
264
+ const press = pressStats(presses);
265
+ const pressByTarget = {};
266
+ for (const target of new Set(presses.map((entry) => entry.target ?? entry.nodeId))) {
267
+ pressByTarget[target] = pressStats(presses.filter((entry) => (entry.target ?? entry.nodeId) === target));
268
+ }
269
+ const sustainedFps = lowestWindowedFps(samples, {
270
+ windowMs: sustainedWindowMs,
271
+ maxGapMs: MAX_ACTIVE_GAP_MS,
272
+ });
273
+ const summary = {
274
+ schemaVersion: 1,
275
+ id,
276
+ status: exceeded ? 'partial' : result.nativeUi.status,
277
+ platform: 'android',
278
+ source: result.nativeUi.kind,
279
+ startedAtEpochMs,
280
+ endedAtEpochMs,
281
+ durationMs: endedAtEpochMs - startedAtEpochMs,
282
+ frames,
283
+ ...(sustainedFps === undefined ? {} : { sustainedFps }),
284
+ sustainedWindowMs,
285
+ presses,
286
+ press,
287
+ pressByTarget,
288
+ coverageGapReasons,
289
+ unavailableReasons: [...result.nativeUi.unavailableReasons],
290
+ notes: [],
291
+ };
292
+ if (Object.keys(shownMarkers).length > 0 && !markerTimed) {
293
+ summary.notes.push(`no [farmslot-perf] shown line in this window (${result.shownMarkers
294
+ ? (result.shownMarkers.unavailableReason ?? 'none logged during it')
295
+ : 'the capture did not read logcat'}); its presses were timed by the frame rule (${PRESS_METRIC})`);
296
+ }
297
+ if (baseline?.refresh_hz !== undefined &&
298
+ frames.refreshRateHz !== undefined &&
299
+ Math.abs(frames.refreshRateHz - baseline.refresh_hz) > baseline.refresh_hz * 0.05) {
300
+ summary.notes.push(`measured ${Math.round(frames.refreshRateHz)} Hz; the baseline was recorded at ${baseline.refresh_hz} Hz`);
301
+ }
302
+ if (baseline)
303
+ summary.baseline = baseline;
304
+ if (budget)
305
+ summary.budget = checkBudget(summary, budget, baseline);
306
+ if (targets)
307
+ summary.targets = checkTargets(summary, targets);
308
+ return summary;
309
+ }
310
+ /**
311
+ * The press's latency: from the first frame that handled input during the
312
+ * press node to the end of the frame that answered it. The answer is the
313
+ * first later frame that stands out (see MOUNT_MIN_MS) within
314
+ * `responseWindowMs`, or past it within `MOUNT_LOOKAHEAD` windows, after a
315
+ * pause too, a frame with a whole frame budget of UI-thread work: the frame
316
+ * that mounts and draws the new screen. Touch ripples run on the render
317
+ * thread. Only when nothing answers that way is the press timed to the first
318
+ * frame within `responseWindowMs` (`first-frame`). A press with no input
319
+ * frame, or no frame after it, is untimed.
320
+ */
321
+ export function measurePress(samples, span, responseWindowMs, windowEndEpochMs) {
322
+ const base = {
323
+ nodeId: span.nodeId,
324
+ action: span.action,
325
+ ...(span.target ? { target: span.target } : {}),
326
+ };
327
+ const input = inputFrame(samples, span, windowEndEpochMs);
328
+ if (!input)
329
+ return { ...base, unmeasuredReason: 'no frame handled input during the press' };
330
+ const searchEnd = Math.min(windowEndEpochMs, input.startedAtEpochMs + responseWindowMs * MOUNT_LOOKAHEAD);
331
+ const windowEnd = input.startedAtEpochMs + responseWindowMs;
332
+ const candidates = samples
333
+ .filter((sample) => sample.input !== true &&
334
+ sample.startedAtEpochMs > input.startedAtEpochMs &&
335
+ sample.startedAtEpochMs <= searchEnd)
336
+ .sort((left, right) => left.startedAtEpochMs - right.startedAtEpochMs);
337
+ const work = (sample) => sample.uiThreadMs ?? sample.durationMs;
338
+ const standsOut = (sample) => {
339
+ const ms = work(sample);
340
+ const wholeFrame = ms >= (sample.frameBudgetMs ?? DEFAULT_FRAME_BUDGET_MS);
341
+ if (sample.startedAtEpochMs > windowEnd)
342
+ return wholeFrame;
343
+ if (ms < MOUNT_MIN_MS)
344
+ return false;
345
+ if (wholeFrame)
346
+ return true;
347
+ const others = candidates
348
+ .filter((other) => other !== sample)
349
+ .map(work)
350
+ .sort((left, right) => left - right);
351
+ return others.length === 0 || ms >= percentile(others, 0.5) * MOUNT_OVER_MEDIAN;
352
+ };
353
+ // Any stand-out frame in reach answers, so a press is never read as instant
354
+ // while a mount follows it.
355
+ const mount = candidates.find(standsOut);
356
+ const response = mount ?? candidates.find((sample) => sample.startedAtEpochMs <= windowEnd);
357
+ if (!response) {
358
+ return {
359
+ ...base,
360
+ unmeasuredReason: `no frame answered the input within ${responseWindowMs} ms`,
361
+ };
362
+ }
363
+ return {
364
+ ...base,
365
+ latencyMs: round(response.completedAtEpochMs - input.startedAtEpochMs),
366
+ answer: mount ? 'mount' : 'first-frame',
367
+ };
368
+ }
369
+ /**
370
+ * A press timed by the app's own word: from the first frame that handled input
371
+ * during the press node to the screen's next `[farmslot-perf] shown` line, or
372
+ * to the end of the last frame from the touch on that started by
373
+ * `SHOWN_MARKER_FRAMES` frame budgets after the line, if that is later. The
374
+ * app logs the line from the second animation frame after focus, once
375
+ * JavaScript is free, so the UI thread can still be finishing the mount frame
376
+ * then. A press can over-read by up to about two light frames, and could miss
377
+ * a mount frame that starts more than two budgets after the line; a live
378
+ * update that starts later doesn't count, and a stall that delays the mount
379
+ * delays the line too. No line before the next press leaves the press untimed.
380
+ */
381
+ export function measureShownPress(samples, span, screen, markers, searchEndEpochMs, frameBudgetMs) {
382
+ const base = {
383
+ nodeId: span.nodeId,
384
+ action: span.action,
385
+ ...(span.target ? { target: span.target } : {}),
386
+ marker: screen,
387
+ };
388
+ const input = inputFrame(samples, span, searchEndEpochMs);
389
+ if (!input)
390
+ return { ...base, unmeasuredReason: 'no frame handled input during the press' };
391
+ const shown = markers.find((marker) => marker.screen === screen &&
392
+ marker.atEpochMs >= input.startedAtEpochMs &&
393
+ marker.atEpochMs <= searchEndEpochMs);
394
+ if (!shown) {
395
+ return {
396
+ ...base,
397
+ unmeasuredReason: `no [farmslot-perf] shown ${screen} line after the touch, before the next press`,
398
+ };
399
+ }
400
+ const reach = shown.atEpochMs + SHOWN_MARKER_FRAMES * frameBudgetMs;
401
+ const endedAtEpochMs = samples.reduce((end, sample) => sample.startedAtEpochMs >= input.startedAtEpochMs && sample.startedAtEpochMs <= reach
402
+ ? Math.max(end, sample.completedAtEpochMs)
403
+ : end, shown.atEpochMs);
404
+ return {
405
+ ...base,
406
+ latencyMs: round(endedAtEpochMs - input.startedAtEpochMs),
407
+ answer: 'shown',
408
+ };
409
+ }
410
+ function inputFrame(samples, span, windowEndEpochMs) {
411
+ const spanEnd = span.endedAtEpochMs ?? windowEndEpochMs;
412
+ return samples.find((sample) => sample.input === true &&
413
+ sample.startedAtEpochMs >= span.startedAtEpochMs &&
414
+ sample.startedAtEpochMs <= spanEnd);
415
+ }
416
+ // Every failure names the metric, the limit, the measured value, where it was
417
+ // measured and, given a baseline, the baseline's value. Values print with
418
+ // enough decimals that a value over its limit never reads as equal to it.
419
+ function checkBudget(summary, limits, baseline) {
420
+ const failures = [];
421
+ const against = (name, value, unit) => baseline && value !== undefined
422
+ ? `; ${baseline.label} baseline ${name} ${formatValue(value)} ${unit}`
423
+ : '';
424
+ if (limits.min_sustained_fps !== undefined &&
425
+ summary.sustainedFps !== undefined &&
426
+ summary.sustainedFps < limits.min_sustained_fps) {
427
+ failures.push(`sustained fps (${FPS_METRIC}) ${formatAgainst(summary.sustainedFps, limits.min_sustained_fps)} fps < ${limits.min_sustained_fps} fps (${summary.id})${against('sustained', baseline?.sustained_fps, 'fps')}`);
428
+ }
429
+ if (limits.max_press_p95_ms !== undefined &&
430
+ summary.press.p95Ms !== undefined &&
431
+ summary.press.p95Ms > limits.max_press_p95_ms) {
432
+ const [slowest, stats] = Object.entries(summary.pressByTarget).reduce((worst, entry) => ((entry[1].maxMs ?? 0) > (worst[1]?.maxMs ?? -1) ? entry : worst), [undefined, undefined]);
433
+ failures.push(`press p95 (${summary.presses.every((press) => press.marker) ? SHOWN_PRESS_METRIC : PRESS_METRIC}) ${formatAgainst(summary.press.p95Ms, limits.max_press_p95_ms)} ms > ${limits.max_press_p95_ms} ms (${summary.id}${slowest && stats?.maxMs !== undefined
434
+ ? `; slowest ${slowest} ${formatValue(stats.maxMs)} ms`
435
+ : ''})${against('p95', baseline?.press_p95_ms, 'ms')}`);
436
+ }
437
+ if (limits.max_press_ms !== undefined) {
438
+ for (const press of summary.presses) {
439
+ const where = `${press.target ?? press.nodeId}, node ${press.nodeId}`;
440
+ const metric = press.marker ? SHOWN_PRESS_METRIC : PRESS_METRIC;
441
+ if (press.latencyMs === undefined) {
442
+ failures.push(`press (${metric}) untimed, counted over ${limits.max_press_ms} ms (${where}): ${press.unmeasuredReason}${against('max', baseline?.press_max_ms, 'ms')}`);
443
+ }
444
+ else if (press.latencyMs > limits.max_press_ms) {
445
+ failures.push(`press (${metric}) ${formatAgainst(press.latencyMs, limits.max_press_ms)} ms > ${limits.max_press_ms} ms (${where})${against('max', baseline?.press_max_ms, 'ms')}`);
446
+ }
447
+ }
448
+ }
449
+ if (limits.min_frames !== undefined && summary.frames.frameCount < limits.min_frames) {
450
+ failures.push(`gfxinfo frames ${summary.frames.frameCount} < ${limits.min_frames} (${summary.id})${against('frames', baseline?.frames, '').trimEnd()}`);
451
+ }
452
+ // A capture with a gap can't support any verdict: a dropped device leaves no
453
+ // frames and untimed presses, which would otherwise read as the app failing.
454
+ // Nor can frames with no refresh budget: sustained FPS and the frame budget
455
+ // a mount needs would be guesses.
456
+ const inconclusive = summary.coverageGapReasons.map((reason) => `coverage gap: ${reason}`);
457
+ if (summary.frames.frameCount > 0 && summary.frames.refreshRateHz === undefined) {
458
+ inconclusive.push('no refresh budget: gfxinfo gave no FrameInterval and the frame starts show no refresh rate');
459
+ }
460
+ return {
461
+ verdict: inconclusive.length > 0 ? 'inconclusive' : failures.length > 0 ? 'fail' : 'pass',
462
+ limits,
463
+ failures,
464
+ inconclusive,
465
+ };
466
+ }
467
+ /** The value with the fewest decimals (up to 3) that don't round it onto `limit`. */
468
+ function formatAgainst(value, limit) {
469
+ for (let decimals = 0; decimals < 3; decimals += 1) {
470
+ const text = value.toFixed(decimals);
471
+ if (Number(text) !== limit)
472
+ return String(Number(text));
473
+ }
474
+ return String(Number(value.toFixed(3)));
475
+ }
476
+ function formatValue(value) {
477
+ return String(Math.round(value * 10) / 10);
478
+ }
479
+ async function describeCheckout(projectRoot, logger) {
480
+ try {
481
+ const head = (await execFileAsync('git', ['-C', projectRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' })).stdout.trim();
482
+ const status = (await execFileAsync('git', ['-C', projectRoot, 'status', '--porcelain', '--untracked-files=no'], { encoding: 'utf8' })).stdout.trim();
483
+ return { head, dirty: status.length > 0 };
484
+ }
485
+ catch (error) {
486
+ // Outside a git checkout the report names no source revision; any other
487
+ // git failure leaves it out too, and says why.
488
+ if (!/not a git repository/u.test(execStderr(error))) {
489
+ logger.warn(`Performance report could not read the checkout: ${errorMessage(error)}`);
490
+ }
491
+ return undefined;
492
+ }
493
+ }
494
+ function execStderr(error) {
495
+ if (error && typeof error === 'object' && 'stderr' in error)
496
+ return String(error.stderr);
497
+ return '';
498
+ }
499
+ function errorMessage(error) {
500
+ return error instanceof Error ? error.message : String(error);
501
+ }
502
+ function windowSpans(spans, summary) {
503
+ return spans.filter((span) => span.startedAtEpochMs >= summary.startedAtEpochMs &&
504
+ span.startedAtEpochMs <= summary.endedAtEpochMs);
505
+ }
506
+ function pressStats(presses) {
507
+ const timed = presses
508
+ .map((press) => press.latencyMs)
509
+ .filter((value) => value !== undefined)
510
+ .sort((first, second) => first - second);
511
+ return {
512
+ count: presses.length,
513
+ timed: timed.length,
514
+ shown: presses.filter((press) => press.answer === 'shown').length,
515
+ firstFrame: presses.filter((press) => press.answer === 'first-frame').length,
516
+ ...(timed.length > 0
517
+ ? {
518
+ p50Ms: round(percentile(timed, 0.5)),
519
+ p95Ms: round(percentile(timed, 0.95)),
520
+ maxMs: round(timed.at(-1) ?? 0),
521
+ }
522
+ : {}),
523
+ };
524
+ }
525
+ function answeredBy(stats) {
526
+ const how = [
527
+ ...(stats.shown > 0 ? [`${stats.shown} by shown line`] : []),
528
+ ...(stats.firstFrame > 0 ? [`${stats.firstFrame} first-frame`] : []),
529
+ ];
530
+ return how.length > 0 ? ` (${how.join(', ')})` : '';
531
+ }
532
+ function checkTargets(summary, targets) {
533
+ return {
534
+ ...(targets.fps === undefined
535
+ ? {}
536
+ : {
537
+ fps: {
538
+ target: targets.fps,
539
+ ...(summary.sustainedFps === undefined ? {} : { sustainedFps: summary.sustainedFps }),
540
+ ...(summary.frames.activeFps === undefined
541
+ ? {}
542
+ : { activeFps: summary.frames.activeFps }),
543
+ met: summary.sustainedFps === undefined || summary.sustainedFps >= targets.fps,
544
+ },
545
+ }),
546
+ ...(targets.press_ms === undefined
547
+ ? {}
548
+ : {
549
+ press: {
550
+ target: targets.press_ms,
551
+ ...(summary.press.p95Ms === undefined ? {} : { p95Ms: summary.press.p95Ms }),
552
+ ...(summary.press.maxMs === undefined ? {} : { maxMs: summary.press.maxMs }),
553
+ met: summary.press.maxMs === undefined || summary.press.maxMs <= targets.press_ms,
554
+ },
555
+ }),
556
+ };
557
+ }
558
+ export function renderPerformanceReport(title, summaries, environment = {}) {
559
+ const value = (number, unit = '') => number === undefined ? 'n/a' : `${formatValue(number)}${unit}`;
560
+ const rows = summaries.map((summary) => {
561
+ const fpsTarget = summary.targets?.fps;
562
+ const toTarget = fpsTarget && summary.sustainedFps !== undefined
563
+ ? `${Math.round((Math.min(summary.sustainedFps, fpsTarget.target) / fpsTarget.target) * 100)}% of ${fpsTarget.target}`
564
+ : 'n/a';
565
+ return [
566
+ summary.id,
567
+ summary.budget?.verdict ?? 'n/a',
568
+ value(summary.sustainedFps),
569
+ value(summary.frames.activeFps),
570
+ toTarget,
571
+ `${value(summary.press.p50Ms)} / ${value(summary.press.p95Ms)} / ${value(summary.press.maxMs)}`,
572
+ value(summary.frames.longestFrameMs, ' ms'),
573
+ value(summary.frames.overBudgetFramePercent, '%'),
574
+ String(summary.frames.frameCount),
575
+ value(summary.frames.refreshRateHz),
576
+ ];
577
+ });
578
+ const header = [
579
+ 'flow',
580
+ 'budget',
581
+ 'sustained fps',
582
+ 'active fps',
583
+ 'fps target',
584
+ 'press p50 / p95 / max (ms)',
585
+ 'worst frame',
586
+ 'janky',
587
+ 'frames',
588
+ 'Hz',
589
+ ];
590
+ const { device, checkout } = environment;
591
+ const lines = [
592
+ `# ${title}`,
593
+ '',
594
+ `- Device: ${device
595
+ ? [
596
+ device.model ?? 'unknown model',
597
+ device.androidRelease ? `Android ${device.androidRelease}` : '',
598
+ device.serial,
599
+ ]
600
+ .filter(Boolean)
601
+ .join(', ')
602
+ : 'unknown'}`,
603
+ `- App: ${device
604
+ ? [
605
+ device.packageId,
606
+ device.versionName,
607
+ device.buildType ? `${device.buildType} build` : '',
608
+ device.lastUpdateTime ? `installed ${device.lastUpdateTime}` : '',
609
+ ]
610
+ .filter(Boolean)
611
+ .join(', ')
612
+ : 'unknown'}`,
613
+ `- Checkout: ${checkout?.head ? `${checkout.head}${checkout.dirty ? ' (uncommitted changes)' : ''}` : 'unknown'}`,
614
+ '',
615
+ `| ${header.join(' | ')} |`,
616
+ `| ${header.map(() => '---').join(' | ')} |`,
617
+ ...rows.map((row) => `| ${row.join(' | ')} |`),
618
+ '',
619
+ ];
620
+ const targets = summaries.flatMap((summary) => Object.entries(summary.pressByTarget).map(([target, stats]) => [
621
+ summary.id,
622
+ target,
623
+ `${stats.timed}/${stats.count}${answeredBy(stats)}`,
624
+ `${value(stats.p50Ms)} / ${value(stats.p95Ms)} / ${value(stats.maxMs)}`,
625
+ summary.targets?.press
626
+ ? stats.maxMs !== undefined && stats.maxMs <= summary.targets.press.target
627
+ ? 'yes'
628
+ : 'no'
629
+ : 'n/a',
630
+ ]));
631
+ if (targets.length > 0) {
632
+ lines.push('| flow | press target | timed | p50 / p95 / max (ms) | within press target |', '| --- | --- | --- | --- | --- |', ...targets.map((row) => `| ${row.join(' | ')} |`), '');
633
+ }
634
+ for (const summary of summaries) {
635
+ if (summary.press.shown === 0)
636
+ continue;
637
+ const hz = summary.frames.refreshRateHz;
638
+ lines.push(`- ${summary.id} shown presses: touch frame to the end of the last frame that started by two vsyncs${hz === undefined
639
+ ? ''
640
+ : ` (${formatValue((SHOWN_MARKER_FRAMES * 1000) / hz)} ms at ${Math.round(hz)} Hz)`} after the screen's \`[farmslot-perf] shown\` line, logged from the second animation frame after focus. A press can over-read by up to about two light frames, and could miss a mount frame that starts more than two vsyncs after the line.`);
641
+ }
642
+ for (const summary of summaries) {
643
+ const baseline = summary.baseline;
644
+ if (!baseline)
645
+ continue;
646
+ const parts = [
647
+ baseline.press_p95_ms === undefined ? '' : `press p95 ${value(baseline.press_p95_ms, ' ms')}`,
648
+ baseline.press_max_ms === undefined ? '' : `max ${value(baseline.press_max_ms, ' ms')}`,
649
+ baseline.sustained_fps === undefined ? '' : `sustained ${value(baseline.sustained_fps)} fps`,
650
+ baseline.frames === undefined ? '' : `${baseline.frames} frames`,
651
+ baseline.refresh_hz === undefined ? '' : `${baseline.refresh_hz} Hz`,
652
+ ].filter(Boolean);
653
+ lines.push(`- ${summary.id} baseline, ${baseline.label}: ${parts.join(', ') || 'recorded'}`);
654
+ }
655
+ for (const summary of summaries) {
656
+ for (const failure of summary.budget?.failures ?? [])
657
+ lines.push(`- FAIL ${failure}`);
658
+ for (const reason of summary.budget?.inconclusive ?? []) {
659
+ lines.push(`- INCONCLUSIVE ${summary.id}: ${reason}`);
660
+ }
661
+ for (const note of summary.notes)
662
+ lines.push(`- NOTE ${summary.id}: ${note}`);
663
+ }
664
+ // Budgeted windows list their gaps as INCONCLUSIVE above.
665
+ const gaps = summaries.filter((summary) => !summary.budget && summary.coverageGapReasons.length > 0);
666
+ for (const summary of gaps) {
667
+ lines.push(`- ${summary.id} coverage: ${summary.coverageGapReasons.join('; ')}`);
668
+ }
669
+ return `${lines.join('\n')}\n`;
670
+ }
671
+ function parseBudget(value) {
672
+ if (value === undefined)
673
+ return undefined;
674
+ const record = requireRecord(value, 'budget');
675
+ return {
676
+ ...optionalLimit(record, 'min_sustained_fps'),
677
+ ...optionalLimit(record, 'max_press_p95_ms'),
678
+ ...optionalLimit(record, 'max_press_ms'),
679
+ ...optionalLimit(record, 'min_frames'),
680
+ };
681
+ }
682
+ function parseShownMarkerMap(value) {
683
+ if (value === undefined)
684
+ return undefined;
685
+ const map = {};
686
+ for (const [target, screen] of Object.entries(requireRecord(value, 'shown_markers'))) {
687
+ if (typeof screen !== 'string' || !screen.trim()) {
688
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} shown_markers.${target} must name a screen.`);
689
+ }
690
+ map[target] = screen.trim();
691
+ }
692
+ return map;
693
+ }
694
+ function parseBaseline(value) {
695
+ if (value === undefined)
696
+ return undefined;
697
+ const record = requireRecord(value, 'baseline');
698
+ if (typeof record.label !== 'string' || !record.label.trim()) {
699
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} baseline.label must be a non-empty string.`);
700
+ }
701
+ return {
702
+ ...record,
703
+ label: record.label.trim(),
704
+ ...optionalLimit(record, 'sustained_fps'),
705
+ ...optionalLimit(record, 'press_p95_ms'),
706
+ ...optionalLimit(record, 'press_max_ms'),
707
+ ...optionalLimit(record, 'frames'),
708
+ ...optionalLimit(record, 'refresh_hz'),
709
+ };
710
+ }
711
+ function parseTargets(value) {
712
+ if (value === undefined)
713
+ return undefined;
714
+ const record = requireRecord(value, 'targets');
715
+ return { ...optionalLimit(record, 'fps'), ...optionalLimit(record, 'press_ms') };
716
+ }
717
+ function requireRecord(value, label) {
718
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
719
+ return Object.fromEntries(Object.entries(value));
720
+ }
721
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} ${label} must be an object.`);
722
+ }
723
+ function optionalLimit(record, key) {
724
+ const raw = record[key];
725
+ if (raw === undefined)
726
+ return {};
727
+ const number = typeof raw === 'string' ? Number(raw) : raw;
728
+ if (typeof number !== 'number' || !Number.isFinite(number) || number <= 0) {
729
+ throw new RecipeExecutionError('harness', `${PERFORMANCE_CAPTURE_ACTION} ${key} must be a positive number.`);
730
+ }
731
+ const limit = {};
732
+ limit[key] = number;
733
+ return limit;
734
+ }
735
+ async function writeArtifact(context, relativePath, content) {
736
+ const file = context.resolveArtifactPath(relativePath);
737
+ await mkdir(path.dirname(file), { recursive: true });
738
+ await writeFile(file, content);
739
+ }
740
+ function boundedDuration(value) {
741
+ const number = positiveNumber(value);
742
+ return number === undefined ? DEFAULT_MAX_DURATION_MS : Math.min(number, MAX_DURATION_MS);
743
+ }
744
+ function positiveNumber(value) {
745
+ const number = typeof value === 'string' ? Number(value) : value;
746
+ return typeof number === 'number' && Number.isFinite(number) && number > 0 ? number : undefined;
747
+ }
748
+ function safeId(id) {
749
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '-');
750
+ }
751
+ //# sourceMappingURL=performance-capture.js.map