diff --git a/src/__tests__/cli-perf.test.ts b/src/__tests__/cli-perf.test.ts new file mode 100644 index 00000000..b6537b82 --- /dev/null +++ b/src/__tests__/cli-perf.test.ts @@ -0,0 +1,93 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { runCliCapture } from './cli-capture.ts'; + +test('perf prints compact platform-independent frame health summary by default', async () => { + const result = await runCliCapture(['perf'], async () => ({ + ok: true, + data: { + session: 'android-perf', + platform: 'android', + device: 'Pixel', + metrics: { + fps: { + available: true, + droppedFramePercent: 7.6, + droppedFrameCount: 637, + totalFrameCount: 8407, + sampleWindowMs: 615390, + method: 'adb-shell-dumpsys-gfxinfo-framestats', + source: 'android-gfxinfo-summary', + worstWindows: [ + { + startOffsetMs: 1200, + endOffsetMs: 2100, + missedDeadlineFrameCount: 8, + worstFrameMs: 84, + }, + ], + }, + memory: { + available: true, + totalPssKb: 250000, + }, + cpu: { + available: true, + usagePercent: 13, + }, + }, + }, + })); + + assert.equal(result.code, null); + const lines = result.stdout.trimEnd().split('\n'); + assert.equal(lines[0], 'Frame health: dropped 7.6% (637/8407 frames) window 10m 15s'); + assert.equal(lines[1], 'Worst windows:'); + assert.equal(lines[2], '- +1s-+2s: 8 missed-deadline frames, worst 84ms'); + assert.doesNotMatch(result.stdout, /android|Pixel|memory|cpu|gfxinfo/i); +}); + +test('perf prints unavailable frame health reason by default', async () => { + const result = await runCliCapture(['perf'], async () => ({ + ok: true, + data: { + metrics: { + fps: { + available: false, + reason: 'Dropped-frame sampling is currently available only on Android.', + }, + }, + }, + })); + + assert.equal(result.code, null); + assert.equal( + result.stdout, + 'Frame health: unavailable - Dropped-frame sampling is currently available only on Android.\n', + ); +}); + +test('perf prints compact CPU and memory summary when frame health is unavailable', async () => { + const result = await runCliCapture(['perf'], async () => ({ + ok: true, + data: { + metrics: { + fps: { + available: false, + reason: 'Dropped-frame sampling is currently available only on Android.', + }, + memory: { + available: true, + residentMemoryKb: 250000, + }, + cpu: { + available: true, + usagePercent: 12.5, + }, + }, + }, + })); + + assert.equal(result.code, null); + assert.equal(result.stdout, 'Performance: CPU 12.5%, memory 244MB\n'); +}); diff --git a/src/cli/commands/output.ts b/src/cli/commands/output.ts index 1b537d09..eb36c68e 100644 --- a/src/cli/commands/output.ts +++ b/src/cli/commands/output.ts @@ -5,6 +5,13 @@ import { printJson } from '../../utils/output.ts'; import { renderReplayTestResponse } from '../../cli-test.ts'; import type { ReplaySuiteResult } from '../../daemon/types.ts'; +type CliOutputFlags = Pick; +type TextOutputHandler = (options: { + positionals: string[]; + flags: CliOutputFlags; + data: Record; +}) => boolean; + function renderBatchSummary(data: Record): void { const total = typeof data.total === 'number' ? data.total : 0; const executed = typeof data.executed === 'number' ? data.executed : 0; @@ -14,45 +21,36 @@ function renderBatchSummary(data: Record): void { ); const results = Array.isArray(data.results) ? data.results : []; for (const entry of results) { - if (!entry || typeof entry !== 'object') continue; - const result = entry as Record; - const step = typeof result.step === 'number' ? result.step : undefined; - const command = typeof result.command === 'string' ? result.command : 'step'; - const stepOk = result.ok !== false; - const stepDurationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; - const stepData = - result.data && typeof result.data === 'object' - ? (result.data as Record) - : undefined; - const stepError = - result.error && typeof result.error === 'object' - ? (result.error as Record) - : undefined; - const description = stepOk - ? (readCommandMessage(stepData) ?? command) - : (readBatchStepFailure(stepError) ?? command); - const prefix = step !== undefined ? `${step}. ` : '- '; - const durationSuffix = stepDurationMs !== undefined ? ` (${stepDurationMs}ms)` : ''; - process.stdout.write(`${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}\n`); + const line = renderBatchStepLine(entry); + if (line) process.stdout.write(line); } } +function renderBatchStepLine(entry: unknown): string | undefined { + const result = readRecord(entry); + if (!result) return undefined; + const step = typeof result.step === 'number' ? result.step : undefined; + const command = typeof result.command === 'string' ? result.command : 'step'; + const stepOk = result.ok !== false; + const stepData = readRecord(result.data); + const stepError = readRecord(result.error); + const description = stepOk + ? (readCommandMessage(stepData) ?? command) + : (readBatchStepFailure(stepError) ?? command); + const prefix = step !== undefined ? `${step}. ` : '- '; + const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; + const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; + return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}\n`; +} + export function writeCommandCliOutput( command: string, positionals: string[], - flags: Pick, + flags: CliOutputFlags, data: Record, ): number { if (flags.json) { - if (command === CLIENT_COMMANDS.test) { - return renderReplayTestResponse({ - suite: data as ReplaySuiteResult, - json: true, - reportJunit: flags.reportJunit, - }); - } - printJson({ success: true, data }); - return 0; + return writeJsonCliOutput(command, flags, data); } if (command === CLIENT_COMMANDS.test) { @@ -62,76 +60,242 @@ export function writeCommandCliOutput( reportJunit: flags.reportJunit, }); } - if (command === CLIENT_COMMANDS.batch) { - renderBatchSummary(data); + + const handler = TEXT_OUTPUT_HANDLERS[command]; + if (handler?.({ positionals, flags, data })) { return 0; } - if (command === CLIENT_COMMANDS.get) { - const sub = positionals[0]; - if (sub === 'text') { - process.stdout.write(`${typeof data.text === 'string' ? data.text : ''}\n`); - return 0; - } - if (sub === 'attrs') { - process.stdout.write(`${JSON.stringify(data.node ?? {}, null, 2)}\n`); - return 0; - } + + const successText = readCommandMessage(data); + if (successText) { + process.stdout.write(`${successText}\n`); } - if (command === CLIENT_COMMANDS.find) { - if (typeof data.text === 'string') { - process.stdout.write(`${data.text}\n`); - return 0; - } - if (typeof data.found === 'boolean') { - process.stdout.write(`Found: ${data.found}\n`); - return 0; - } - if (data.node) { - process.stdout.write(`${JSON.stringify(data.node, null, 2)}\n`); - return 0; - } + return 0; +} + +function writeJsonCliOutput( + command: string, + flags: CliOutputFlags, + data: Record, +): number { + if (command === CLIENT_COMMANDS.test) { + return renderReplayTestResponse({ + suite: data as ReplaySuiteResult, + json: true, + reportJunit: flags.reportJunit, + }); } - if (command === CLIENT_COMMANDS.is) { + printJson({ success: true, data }); + return 0; +} + +const TEXT_OUTPUT_HANDLERS: Partial> = { + [CLIENT_COMMANDS.batch]: ({ data }) => { + renderBatchSummary(data); + return true; + }, + [CLIENT_COMMANDS.get]: ({ positionals, data }) => writeGetCliOutput(positionals, data), + [CLIENT_COMMANDS.find]: ({ data }) => writeFindCliOutput(data), + [CLIENT_COMMANDS.is]: ({ data }) => { process.stdout.write(`Passed: is ${data.predicate ?? 'assertion'}\n`); - return 0; - } - if (command === CLIENT_COMMANDS.boot) { + return true; + }, + [CLIENT_COMMANDS.boot]: ({ data }) => { const platform = data.platform ?? 'unknown'; const device = data.device ?? data.id ?? 'unknown'; process.stdout.write(`Boot ready: ${device} (${platform})\n`); - return 0; - } - if (command === CLIENT_COMMANDS.record) { + return true; + }, + [CLIENT_COMMANDS.record]: ({ data }) => { const outPath = typeof data.outPath === 'string' ? data.outPath : ''; if (outPath) process.stdout.write(`${outPath}\n`); - return 0; - } - if (command === CLIENT_COMMANDS.logs) { + return true; + }, + [CLIENT_COMMANDS.logs]: ({ data, flags }) => { writeLogsCliOutput(data, flags); - return 0; - } - if (command === CLIENT_COMMANDS.network) { + return true; + }, + [CLIENT_COMMANDS.network]: ({ data }) => { writeNetworkCliOutput(data); - return 0; + return true; + }, + [CLIENT_COMMANDS.click]: ({ data }) => writeTapCliOutput(data), + [CLIENT_COMMANDS.press]: ({ data }) => writeTapCliOutput(data), + [CLIENT_COMMANDS.perf]: ({ data }) => { + writePerfCliOutput(data); + return true; + }, +}; + +function writeGetCliOutput(positionals: string[], data: Record): boolean { + const sub = positionals[0]; + if (sub === 'text') { + process.stdout.write(`${typeof data.text === 'string' ? data.text : ''}\n`); + return true; + } + if (sub === 'attrs') { + process.stdout.write(`${JSON.stringify(data.node ?? {}, null, 2)}\n`); + return true; } - if (command === CLIENT_COMMANDS.click || command === CLIENT_COMMANDS.press) { - const ref = data.ref ?? ''; - const x = data.x; - const y = data.y; - if (ref && typeof x === 'number' && typeof y === 'number') { - process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); - return 0; + return false; +} + +function writeFindCliOutput(data: Record): boolean { + if (typeof data.text === 'string') { + process.stdout.write(`${data.text}\n`); + return true; + } + if (typeof data.found === 'boolean') { + process.stdout.write(`Found: ${data.found}\n`); + return true; + } + if (!data.node) return false; + process.stdout.write(`${JSON.stringify(data.node, null, 2)}\n`); + return true; +} + +function writeTapCliOutput(data: Record): boolean { + const ref = data.ref ?? ''; + const x = data.x; + const y = data.y; + if (!ref || typeof x !== 'number' || typeof y !== 'number') return false; + process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); + return true; +} + +function writePerfCliOutput(data: Record): void { + const metrics = readRecord(data.metrics); + const fps = readRecord(metrics?.fps); + const resourceSummary = buildResourcePerfSummary(metrics); + if (!fps) { + process.stdout.write( + resourceSummary + ? `Performance: ${resourceSummary}\n` + : 'Frame health: unavailable - missing frame metric\n', + ); + return; + } + + if (fps.available === false) { + if (resourceSummary) { + process.stdout.write(`Performance: ${resourceSummary}\n`); + return; } + const reason = + typeof fps.reason === 'string' && fps.reason.length > 0 ? fps.reason : 'not available'; + process.stdout.write(`Frame health: unavailable - ${reason}\n`); + return; } - if (command === CLIENT_COMMANDS.perf) { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); - return 0; + + const droppedFramePercent = readFiniteNumber(fps.droppedFramePercent); + const droppedFrameCount = readFiniteNumber(fps.droppedFrameCount); + const totalFrameCount = readFiniteNumber(fps.totalFrameCount); + if (droppedFramePercent === undefined || droppedFrameCount === undefined) { + process.stdout.write( + resourceSummary + ? `Performance: ${resourceSummary}\n` + : 'Frame health: unavailable - missing dropped-frame summary\n', + ); + return; } - const successText = readCommandMessage(data); - if (successText) { - process.stdout.write(`${successText}\n`); + + const parts = [`dropped ${formatPercent(droppedFramePercent)}`]; + if (totalFrameCount !== undefined) { + parts.push(`(${Math.round(droppedFrameCount)}/${Math.round(totalFrameCount)} frames)`); + } else { + parts.push(`(${Math.round(droppedFrameCount)} dropped frames)`); } - return 0; + + const sampleWindowMs = readFiniteNumber(fps.sampleWindowMs); + if (sampleWindowMs !== undefined) { + parts.push(`window ${formatDurationMs(sampleWindowMs)}`); + } + + process.stdout.write(`Frame health: ${parts.join(' ')}\n`); + writeWorstFrameWindows(fps); +} + +function writeWorstFrameWindows(fps: Record): void { + const worstWindows = readRecordArray(fps.worstWindows); + if (worstWindows.length === 0) return; + process.stdout.write('Worst windows:\n'); + for (const window of worstWindows) { + const line = formatWorstFrameWindow(window); + if (line) process.stdout.write(line); + } +} + +function formatWorstFrameWindow(window: Record): string | undefined { + const startOffsetMs = readFiniteNumber(window.startOffsetMs); + const endOffsetMs = readFiniteNumber(window.endOffsetMs); + const count = readFiniteNumber(window.missedDeadlineFrameCount); + if (startOffsetMs === undefined || endOffsetMs === undefined || count === undefined) { + return undefined; + } + const worstFrameMs = readFiniteNumber(window.worstFrameMs); + const worstFrameText = + worstFrameMs === undefined ? '' : `, worst ${formatDurationMs(worstFrameMs)}`; + return `- +${formatDurationMs(startOffsetMs)}-+${formatDurationMs(endOffsetMs)}: ${Math.round(count)} missed-deadline frames${worstFrameText}\n`; +} + +function buildResourcePerfSummary( + metrics: Record | undefined, +): string | undefined { + const parts: string[] = []; + const cpu = readRecord(metrics?.cpu); + if (cpu?.available === true) { + const usagePercent = readFiniteNumber(cpu.usagePercent); + if (usagePercent !== undefined) parts.push(`CPU ${formatPercent(usagePercent)}`); + } + + const memory = readRecord(metrics?.memory); + if (memory?.available === true) { + const memoryKb = + readFiniteNumber(memory.residentMemoryKb) ?? + readFiniteNumber(memory.totalPssKb) ?? + readFiniteNumber(memory.totalRssKb); + if (memoryKb !== undefined) parts.push(`memory ${formatMemoryKb(memoryKb)}`); + } + + return parts.length > 0 ? parts.join(', ') : undefined; +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readRecordArray(value: unknown): Array> { + return Array.isArray(value) + ? value.filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + : []; +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function formatPercent(value: number): string { + return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; +} + +function formatDurationMs(value: number): string { + const roundedMs = Math.max(0, Math.round(value)); + if (roundedMs < 1000) return `${roundedMs}ms`; + const seconds = Math.round(roundedMs / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; +} + +function formatMemoryKb(value: number): string { + const megabytes = value / 1024; + return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`; } function readBatchStepFailure(error: Record | undefined): string | null { @@ -142,34 +306,24 @@ function writeLogsCliOutput(data: Record, flags: { json?: boole const pathOut = typeof data.path === 'string' ? data.path : ''; if (!pathOut) return; process.stdout.write(`${pathOut}\n`); - const metaFields = ['active', 'state', 'backend', 'sizeBytes'] as const; - const meta = metaFields - .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) - .filter(Boolean) - .join(' '); + const meta = formatKeyValueFields(data, ['active', 'state', 'backend', 'sizeBytes']); if (meta && !flags.json) process.stderr.write(`${meta}\n`); - const actionFields = [ - 'started', - 'stopped', - 'marked', - 'cleared', - 'restarted', - 'removedRotatedFiles', - ] as const; - const actionMeta = actionFields - .map((key) => { - const value = data[key]; - return value === true ? `${key}=true` : typeof value === 'number' ? `${key}=${value}` : ''; - }) - .filter(Boolean) - .join(' '); + const actionMeta = formatActionFields(data); if (actionMeta && !flags.json) process.stderr.write(`${actionMeta}\n`); if (data.hint && !flags.json) process.stderr.write(`${data.hint}\n`); - if (Array.isArray(data.notes) && !flags.json) { - for (const note of data.notes) { - if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); - } - } + if (!flags.json) writeNotes(data.notes); +} + +function formatActionFields(data: Record): string { + return ['started', 'stopped', 'marked', 'cleared', 'restarted', 'removedRotatedFiles'] + .map((key) => formatActionField(key, data[key])) + .filter(Boolean) + .join(' '); +} + +function formatActionField(key: string, value: unknown): string { + if (value === true) return `${key}=true`; + return typeof value === 'number' ? `${key}=${value}` : ''; } function writeNetworkCliOutput(data: Record): void { @@ -180,36 +334,47 @@ function writeNetworkCliOutput(data: Record): void { process.stdout.write('No recent HTTP(s) entries found.\n'); } else { for (const entry of entries as Array>) { - const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; - const url = typeof entry.url === 'string' ? entry.url : ''; - const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; - const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; - const durationMs = - typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; - process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); - if (typeof entry.headers === 'string') process.stdout.write(` headers: ${entry.headers}\n`); - if (typeof entry.requestBody === 'string') - process.stdout.write(` request: ${entry.requestBody}\n`); - if (typeof entry.responseBody === 'string') - process.stdout.write(` response: ${entry.responseBody}\n`); + writeNetworkEntry(entry); } } - const networkMetaFields = [ + const meta = formatKeyValueFields(data, [ 'active', 'state', 'backend', 'include', 'scannedLines', 'matchedLines', - ] as const; - const meta = networkMetaFields + ]); + if (meta) process.stderr.write(`${meta}\n`); + writeNotes(data.notes); +} + +function writeNetworkEntry(entry: Record): void { + const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; + const url = typeof entry.url === 'string' ? entry.url : ''; + const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; + const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; + const durationMs = typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; + process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); + writeNetworkEntryBody('headers', entry.headers); + writeNetworkEntryBody('request', entry.requestBody); + writeNetworkEntryBody('response', entry.responseBody); +} + +function writeNetworkEntryBody(label: string, value: unknown): void { + if (typeof value === 'string') process.stdout.write(` ${label}: ${value}\n`); +} + +function formatKeyValueFields(data: Record, fields: string[]): string { + return fields .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) .filter(Boolean) .join(' '); - if (meta) process.stderr.write(`${meta}\n`); - if (Array.isArray(data.notes)) { - for (const note of data.notes) { - if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); - } +} + +function writeNotes(notes: unknown): void { + if (!Array.isArray(notes)) return; + for (const note of notes) { + if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); } } diff --git a/src/daemon/handlers/__tests__/session-open-target.test.ts b/src/daemon/handlers/__tests__/session-open-target.test.ts new file mode 100644 index 00000000..a35f9a47 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-open-target.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '../../../utils/device.ts'; + +vi.mock('../../../platforms/android/index.ts', () => ({ + getAndroidAppState: vi.fn(), +})); + +import { getAndroidAppState } from '../../../platforms/android/index.ts'; +import { inferAndroidPackageAfterOpen } from '../session-open-target.ts'; + +const mockGetAndroidAppState = vi.mocked(getAndroidAppState); +const androidDevice: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('inferAndroidPackageAfterOpen reads foreground package for Android URL opens', async () => { + mockGetAndroidAppState.mockResolvedValue({ + package: 'host.exp.exponent', + activity: 'host.exp.exponent.experience.ExperienceActivity', + }); + + await expect( + inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined), + ).resolves.toBe('host.exp.exponent'); +}); + +test('inferAndroidPackageAfterOpen preserves existing package context', async () => { + await expect( + inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', 'com.example.app'), + ).resolves.toBe('com.example.app'); + expect(mockGetAndroidAppState).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/__tests__/session-perf.test.ts b/src/daemon/handlers/__tests__/session-perf.test.ts new file mode 100644 index 00000000..f307ba19 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-perf.test.ts @@ -0,0 +1,110 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import type { SessionState } from '../../types.ts'; + +vi.mock('../../../platforms/android/perf.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sampleAndroidMemoryPerf: vi.fn(), + sampleAndroidCpuPerf: vi.fn(), + sampleAndroidFramePerf: vi.fn(), + }; +}); + +import { buildPerfResponseData } from '../session-perf.ts'; +import { + sampleAndroidCpuPerf, + sampleAndroidFramePerf, + sampleAndroidMemoryPerf, +} from '../../../platforms/android/perf.ts'; + +const mockSampleAndroidMemoryPerf = vi.mocked(sampleAndroidMemoryPerf); +const mockSampleAndroidCpuPerf = vi.mocked(sampleAndroidCpuPerf); +const mockSampleAndroidFramePerf = vi.mocked(sampleAndroidFramePerf); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +test('buildPerfResponseData adds Android frame health metadata and related actions', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-01T10:00:11.000Z')); + mockAndroidPerfSamples(); + + const data = await buildPerfResponseData(makeAndroidSession()); + assertAndroidPerfMetrics(data.metrics as Record); + assertAndroidPerfSampling(data.sampling as Record); +}); + +function mockAndroidPerfSamples(): void { + mockSampleAndroidMemoryPerf.mockResolvedValue({ + totalPssKb: 216524, + totalRssKb: 340112, + measuredAt: '2026-04-01T10:00:11.000Z', + method: 'adb-shell-dumpsys-meminfo', + }); + mockSampleAndroidCpuPerf.mockResolvedValue({ + usagePercent: 9, + measuredAt: '2026-04-01T10:00:11.000Z', + method: 'adb-shell-dumpsys-cpuinfo', + matchedProcesses: ['com.example.app', 'com.example.app:sync'], + }); + mockSampleAndroidFramePerf.mockResolvedValue({ + droppedFramePercent: 33.3, + droppedFrameCount: 1, + totalFrameCount: 3, + windowStartedAt: '2026-04-01T10:00:10.000Z', + windowEndedAt: '2026-04-01T10:00:11.000Z', + measuredAt: '2026-04-01T10:00:11.000Z', + method: 'adb-shell-dumpsys-gfxinfo-framestats', + source: 'android-gfxinfo-summary', + }); +} + +function assertAndroidPerfMetrics(metrics: Record): void { + expect(metrics.memory?.available).toBe(true); + expect(metrics.memory?.totalPssKb).toBe(216524); + expect(metrics.cpu?.available).toBe(true); + expect(metrics.cpu?.usagePercent).toBe(9); + expect(metrics.fps?.available).toBe(true); + expect(metrics.fps?.droppedFramePercent).toBe(33.3); + expect(metrics.fps?.relatedActions).toEqual([ + { + at: '2026-04-01T10:00:10.050Z', + command: 'click', + offsetMs: 50, + target: 'Refresh metrics', + }, + ]); +} + +function assertAndroidPerfSampling(sampling: Record): void { + expect(sampling.fps?.primaryField).toBe('droppedFramePercent'); + expect(sampling.fps?.relatedActionsLimit).toBe(12); +} + +function makeAndroidSession(): SessionState { + return { + name: 'perf-session-android', + createdAt: Date.now(), + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }, + appBundleId: 'com.example.app', + appName: 'Example App', + actions: [ + { + ts: new Date('2026-04-01T10:00:10.050Z').getTime(), + command: 'click', + positionals: ['@e8'], + flags: {}, + result: { ref: 'e8', refLabel: 'Refresh metrics' }, + }, + ], + }; +} diff --git a/src/daemon/handlers/session-open-target.ts b/src/daemon/handlers/session-open-target.ts index 9b535e89..812c60a7 100644 --- a/src/daemon/handlers/session-open-target.ts +++ b/src/daemon/handlers/session-open-target.ts @@ -44,6 +44,24 @@ export async function resolveAndroidPackageForOpen( } } +export async function inferAndroidPackageAfterOpen( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId: string | undefined, +): Promise { + if (currentAppBundleId) return currentAppBundleId; + if (device.platform !== 'android' || !openTarget || !isDeepLinkTarget(openTarget)) { + return currentAppBundleId; + } + try { + const { getAndroidAppState } = await import('../../platforms/android/index.ts'); + const foreground = await getAndroidAppState(device); + return foreground.package?.trim() || currentAppBundleId; + } catch { + return currentAppBundleId; + } +} + function shouldPreserveAndroidPackageContext( device: DeviceInfo, openTarget: string | undefined, diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 9c50bb29..32e4a3f0 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -18,7 +18,9 @@ import { countConfiguredRuntimeHints, setSessionRuntimeHintsForOpen } from './se import { STARTUP_SAMPLE_METHOD, type StartupPerfSample } from './session-startup-metrics.ts'; import { buildNextOpenSession, buildOpenResult } from './session-open-surface.ts'; import { markAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; +import { resetAndroidFramePerfStats } from '../../platforms/android/perf.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; +import { inferAndroidPackageAfterOpen } from './session-open-target.ts'; import { invalidOpenArgs, prepareOpenCommandDetails, @@ -79,6 +81,7 @@ function buildStartupPerfSample( }; } +// fallow-ignore-next-line complexity async function completeOpenCommand(params: { req: DaemonRequest; sessionName: string; @@ -109,9 +112,10 @@ async function completeOpenCommand(params: { } = params; const shouldRelaunch = req.flags?.relaunch === true; const traceLogPath = existingSession?.trace?.outPath; + let sessionAppBundleId = appBundleId; if (shouldRelaunch && openTarget) { - const closeTarget = appBundleId ?? openTarget; + const closeTarget = sessionAppBundleId ?? openTarget; await relaunchCloseApp({ device, closeTarget, @@ -120,7 +124,7 @@ async function completeOpenCommand(params: { ...contextFromFlags( logPath, req.flags, - appBundleId ?? existingSession?.appBundleId, + sessionAppBundleId ?? existingSession?.appBundleId, traceLogPath, ), }, @@ -129,24 +133,28 @@ async function completeOpenCommand(params: { await applyRuntimeHintsToApp({ device, - appId: appBundleId, + appId: sessionAppBundleId, runtime, }); const openStartedAtMs = Date.now(); await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { - ...contextFromFlags(logPath, req.flags, appBundleId), + ...contextFromFlags(logPath, req.flags, sessionAppBundleId), }); await maybeApplySessionLaunchUrl({ runtime, device, req, logPath, - appBundleId, + appBundleId: sessionAppBundleId, traceLogPath, openPositionals, }); + sessionAppBundleId = await inferAndroidPackageAfterOpen(device, openTarget, sessionAppBundleId); + if (device.platform === 'android' && sessionAppBundleId) { + await resetAndroidFramePerfStats(device, sessionAppBundleId); + } const startupSample = openTarget - ? buildStartupPerfSample(openStartedAtMs, openTarget, appBundleId) + ? buildStartupPerfSample(openStartedAtMs, openTarget, sessionAppBundleId) : undefined; await settleIosSimulator(device, IOS_SIMULATOR_POST_OPEN_SETTLE_MS); if (isRequestCanceled(req.meta?.requestId)) { @@ -164,7 +172,7 @@ async function completeOpenCommand(params: { sessionName, device, surface, - appBundleId, + appBundleId: sessionAppBundleId, appName, saveScript: Boolean(req.flags?.saveScript), }); @@ -174,7 +182,7 @@ async function completeOpenCommand(params: { const openResult = buildOpenResult({ sessionName, appName, - appBundleId, + appBundleId: sessionAppBundleId, surface, startup: startupSample, device, diff --git a/src/daemon/handlers/session-perf.ts b/src/daemon/handlers/session-perf.ts index d2eef628..7c3bf2d6 100644 --- a/src/daemon/handlers/session-perf.ts +++ b/src/daemon/handlers/session-perf.ts @@ -3,9 +3,12 @@ import { normalizeError } from '../../utils/errors.ts'; import { ANDROID_CPU_SAMPLE_DESCRIPTION, ANDROID_CPU_SAMPLE_METHOD, + ANDROID_FRAME_SAMPLE_DESCRIPTION, + ANDROID_FRAME_SAMPLE_METHOD, ANDROID_MEMORY_SAMPLE_DESCRIPTION, ANDROID_MEMORY_SAMPLE_METHOD, sampleAndroidCpuPerf, + sampleAndroidFramePerf, sampleAndroidMemoryPerf, } from '../../platforms/android/perf.ts'; import { buildAppleSamplingMetadata, sampleApplePerfMetrics } from '../../platforms/ios/perf.ts'; @@ -17,6 +20,21 @@ import { type StartupPerfSample, } from './session-startup-metrics.ts'; +type SettledMetricResult = PromiseSettledResult>; +type MetricResult = + | ({ available: true } & Record) + | { available: false; reason: string; error: ReturnType }; +type PerfResponseData = { + session: string; + platform: string; + device: string; + deviceId: string; + metrics: Record; + sampling: Record; +}; + +const RELATED_PERF_ACTION_LIMIT = 12; + function readStartupPerfSamples(actions: SessionAction[]): StartupPerfSample[] { const samples: StartupPerfSample[] = []; for (const action of actions) { @@ -53,6 +71,27 @@ function readStartupPerfSamples(actions: SessionAction[]): StartupPerfSample[] { export async function buildPerfResponseData( session: SessionState, ): Promise> { + const response = buildBasePerfResponse(session); + + if (!supportsPlatformPerfMetrics(session)) { + return response; + } + + if (!session.appBundleId) { + applyMissingAppPerfMetrics(response, session); + return response; + } + + if (session.device.platform === 'android') { + await applyAndroidPerfMetrics(response, session); + return response; + } + + await applyApplePerfMetrics(response, session); + return response; +} + +function buildBasePerfResponse(session: SessionState): PerfResponseData { const startupSamples = readStartupPerfSamples(session.actions); const latestStartupSample = startupSamples.at(-1); const startupMetric = latestStartupSample @@ -69,27 +108,14 @@ export async function buildPerfResponseData( reason: 'No startup sample captured yet. Run open in this session first.', method: STARTUP_SAMPLE_METHOD, }; - const defaultUnavailableMetrics = { - fps: { available: false, reason: PERF_UNAVAILABLE_REASON }, - memory: { available: false, reason: PERF_UNAVAILABLE_REASON }, - cpu: { available: false, reason: PERF_UNAVAILABLE_REASON }, - }; - - const response: { - session: string; - platform: string; - device: string; - deviceId: string; - metrics: Record; - sampling: Record; - } = { + return { session: session.name, platform: session.device.platform, device: session.device.name, deviceId: session.device.id, metrics: { startup: startupMetric, - ...defaultUnavailableMetrics, + ...buildDefaultUnavailableMetrics(), }, sampling: { startup: { @@ -100,22 +126,46 @@ export async function buildPerfResponseData( ...buildPlatformSamplingMetadata(session), }, }; +} - if (!supportsPlatformPerfMetrics(session)) { - return response; - } +function buildDefaultUnavailableMetrics(): Record { + return { + fps: { + available: false, + reason: 'Dropped-frame sampling is currently available only on Android.', + }, + memory: { available: false, reason: PERF_UNAVAILABLE_REASON }, + cpu: { available: false, reason: PERF_UNAVAILABLE_REASON }, + }; +} - if (!session.appBundleId) { - const reason = buildMissingAppPerfReason(session); - response.metrics.memory = { available: false, reason }; - response.metrics.cpu = { available: false, reason }; - return response; - } +function applyMissingAppPerfMetrics(response: PerfResponseData, session: SessionState): void { + const reason = buildMissingAppPerfReason(session); + response.metrics.fps = { available: false, reason }; + response.metrics.memory = { available: false, reason }; + response.metrics.cpu = { available: false, reason }; +} - const [memoryResult, cpuResult] = await samplePlatformPerfResults(session); - response.metrics.memory = buildMetricResult(memoryResult); - response.metrics.cpu = buildMetricResult(cpuResult); - return response; +async function applyAndroidPerfMetrics( + response: PerfResponseData, + session: SessionState, +): Promise { + const results = await sampleAndroidPerfResults(session); + response.metrics.memory = buildMetricResult(results.memory); + response.metrics.cpu = buildMetricResult(results.cpu); + response.metrics.fps = enrichFrameMetricWithSessionContext( + buildMetricResult(results.fps), + session, + ); +} + +async function applyApplePerfMetrics( + response: PerfResponseData, + session: SessionState, +): Promise { + const results = await sampleApplePerfResultsForSession(session); + response.metrics.memory = buildMetricResult(results.memory); + response.metrics.cpu = buildMetricResult(results.cpu); } function supportsPlatformPerfMetrics(session: SessionState): boolean { @@ -146,44 +196,54 @@ function buildPlatformSamplingMetadata(session: SessionState): Record>, PromiseSettledResult>] -> { +async function sampleAndroidPerfResults(session: SessionState): Promise<{ + memory: SettledMetricResult; + cpu: SettledMetricResult; + fps: SettledMetricResult; +}> { const appBundleId = session.appBundleId as string; - if (session.device.platform === 'android') { - const [memoryResult, cpuResult] = await Promise.allSettled([ - sampleAndroidMemoryPerf(session.device, appBundleId), - sampleAndroidCpuPerf(session.device, appBundleId), - ]); - return [memoryResult, cpuResult]; - } + const [memory, cpu, fps] = await Promise.allSettled([ + sampleAndroidMemoryPerf(session.device, appBundleId), + sampleAndroidCpuPerf(session.device, appBundleId), + sampleAndroidFramePerf(session.device, appBundleId), + ]); + return { memory, cpu, fps }; +} +async function sampleApplePerfResultsForSession(session: SessionState): Promise<{ + memory: SettledMetricResult; + cpu: SettledMetricResult; +}> { + const appBundleId = session.appBundleId as string; try { const sample = await sampleApplePerfMetrics(session.device, appBundleId); - return [ - { status: 'fulfilled', value: sample.memory }, - { status: 'fulfilled', value: sample.cpu }, - ]; + return { + memory: { status: 'fulfilled', value: sample.memory }, + cpu: { status: 'fulfilled', value: sample.cpu }, + }; } catch (reason) { - return [ - { status: 'rejected', reason }, - { status: 'rejected', reason }, - ]; + return { + memory: { status: 'rejected', reason }, + cpu: { status: 'rejected', reason }, + }; } } -function buildMetricResult>( - result: PromiseSettledResult, -): - | ({ available: true } & T) - | { available: false; reason: string; error: ReturnType } { +function buildMetricResult(result: SettledMetricResult): MetricResult { if (result.status === 'fulfilled') { return { available: true, ...result.value }; } @@ -194,3 +254,56 @@ function buildMetricResult>( error, }; } + +function enrichFrameMetricWithSessionContext( + metric: MetricResult, + session: SessionState, +): MetricResult { + if (metric.available !== true) return metric; + const relatedActions = buildRelatedPerfActions(session.actions, metric); + if (relatedActions.length === 0) return metric; + return { + ...metric, + relatedActions, + }; +} + +function buildRelatedPerfActions( + actions: SessionAction[], + metric: Record, +): Array<{ + command: string; + at: string; + offsetMs?: number; + target?: string; +}> { + const windowStartedAtMs = parseIsoTime(metric.windowStartedAt); + const windowEndedAtMs = parseIsoTime(metric.windowEndedAt) ?? parseIsoTime(metric.measuredAt); + if (windowStartedAtMs === undefined || windowEndedAtMs === undefined) return []; + + return actions + .filter((action) => action.ts >= windowStartedAtMs && action.ts <= windowEndedAtMs) + .map((action) => ({ + command: action.command, + at: new Date(action.ts).toISOString(), + offsetMs: Math.max(0, Math.round(action.ts - windowStartedAtMs)), + target: readActionTarget(action), + })) + .slice(-RELATED_PERF_ACTION_LIMIT); +} + +function parseIsoTime(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined; + const time = Date.parse(value); + return Number.isFinite(time) ? time : undefined; +} + +function readActionTarget(action: SessionAction): string | undefined { + const result = action.result; + if (!result) return undefined; + for (const key of ['refLabel', 'ref', 'appName', 'appBundleId']) { + const value = result[key]; + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} diff --git a/src/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index a5f61d8a..6164eb53 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { parseAndroidCpuInfoSample, parseAndroidMemInfoSample } from '../perf.ts'; +import { + parseAndroidCpuInfoSample, + parseAndroidFramePerfSample, + parseAndroidMemInfoSample, +} from '../perf.ts'; test('parseAndroidCpuInfoSample aggregates package processes and ignores similar package names', () => { const sample = parseAndroidCpuInfoSample( @@ -65,3 +69,155 @@ test('parseAndroidMemInfoSample supports legacy total row layout', () => { assert.equal(sample.totalPssKb, 24358); assert.equal(sample.totalRssKb, undefined); }); + +test('parseAndroidFramePerfSample summarizes dropped frame percentage from framestats rows', () => { + const sample = parseAndroidFramePerfSample( + [ + 'Stats since: 123456789ns', + '---PROFILEDATA---', + 'Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,GpuCompleted', + '0,1000000000,1000000000,0,0,0,0,0,0,0,0,0,0,1010000000,0,0,1010000000', + '0,1016666667,1016666667,0,0,0,0,0,0,0,0,0,0,1034666667,0,0,1034666667', + '0,1033333334,1033333334,0,0,0,0,0,0,0,0,0,0,1063333334,0,0,1063333334', + '1,1050000001,1050000001,0,0,0,0,0,0,0,0,0,0,1100000001,0,0,1100000001', + '0,1066666668,1066666668,0,0,0,0,0,0,0,0,0,0,1082666668,0,0,1082666668', + '---PROFILEDATA---', + ].join('\n'), + 'com.example.app', + '2026-04-01T10:00:00.000Z', + ); + + assert.equal(sample.droppedFrameCount, 2); + assert.equal(sample.totalFrameCount, 4); + assert.equal(sample.droppedFramePercent, 50); + assert.equal(sample.frameDeadlineMs, 16.7); + assert.equal(sample.refreshRateHz, 60); + assert.equal(sample.method, 'adb-shell-dumpsys-gfxinfo-framestats'); + assert.equal(sample.source, 'framestats-rows'); + assert.equal(sample.worstWindows?.[0]?.missedDeadlineFrameCount, 2); +}); + +test('parseAndroidFramePerfSample prefers Android gfxinfo janky frame summary', () => { + const sample = parseAndroidFramePerfSample( + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 164892458 Realtime: 164892458', + '', + '** Graphics info for pid 16305 [host.exp.exponent] **', + '', + 'Stats since: 164496032562094ns', + 'Total frames rendered: 4569', + 'Janky frames: 115 (2.52%)', + 'Janky frames (legacy): 3971 (86.91%)', + 'Number Frame deadline missed: 115', + 'Profile data in ms:', + 'Flags,IntendedVsync,FrameCompleted', + '0,1000000000,1010000000', + ].join('\n'), + 'host.exp.exponent', + '2026-04-01T10:00:00.000Z', + ); + + assert.equal(sample.droppedFrameCount, 115); + assert.equal(sample.totalFrameCount, 4569); + assert.equal(sample.droppedFramePercent, 2.5); + assert.equal(sample.source, 'android-gfxinfo-summary'); +}); + +test('parseAndroidFramePerfSample omits frame deadline when rows are too sparse', () => { + const sample = parseAndroidFramePerfSample( + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 11000 Realtime: 11000', + 'Stats since: 10000000000ns', + 'Total frames rendered: 3', + 'Janky frames: 1 (33.33%)', + 'Profile data in ms:', + 'Flags,IntendedVsync,FrameCompleted', + '0,10000000000,10012000000', + '0,10150000000,10162000000', + '0,10300000000,10312000000', + ].join('\n'), + 'com.example.app', + '2026-04-01T10:00:11.000Z', + ); + + assert.equal(sample.droppedFramePercent, 33.3); + assert.equal(sample.frameDeadlineMs, undefined); + assert.equal(sample.refreshRateHz, undefined); + assert.equal(sample.worstWindows?.[0]?.missedDeadlineFrameCount, 1); +}); + +test('parseAndroidFramePerfSample caps worst windows to Android summary count', () => { + const sample = parseAndroidFramePerfSample( + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 11000 Realtime: 11000', + 'Stats since: 10000000000ns', + 'Total frames rendered: 5', + 'Janky frames: 1 (20.00%)', + 'Profile data in ms:', + 'Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,GpuCompleted', + '0,10000000000,10000000000,0,0,0,0,0,0,0,0,0,0,10018000000,0,0,10018000000', + '0,10016666667,10016666667,0,0,0,0,0,0,0,0,0,0,10036666667,0,0,10036666667', + '0,10033333334,10033333334,0,0,0,0,0,0,0,0,0,0,10063333334,0,0,10063333334', + ].join('\n'), + 'com.example.app', + '2026-04-01T10:00:11.000Z', + ); + + assert.equal(sample.droppedFrameCount, 1); + assert.equal(sample.worstWindows?.[0]?.missedDeadlineFrameCount, 1); + assert.equal(sample.worstWindows?.[0]?.worstFrameMs, 30); +}); + +test('parseAndroidFramePerfSample adds estimated timestamps and worst drop windows', () => { + const sample = parseAndroidFramePerfSample( + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 11000 Realtime: 11000', + '', + 'Stats since: 10000000000ns', + 'Total frames rendered: 5', + 'Janky frames: 2 (40.00%)', + 'Profile data in ms:', + 'Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,GpuCompleted', + '0,10000000000,10000000000,0,0,0,0,0,0,0,0,0,0,10010000000,0,0,10010000000', + '0,10016666667,10016666667,0,0,0,0,0,0,0,0,0,0,10076666667,0,0,10076666667', + '0,10033333334,10033333334,0,0,0,0,0,0,0,0,0,0,10043333334,0,0,10043333334', + '0,10050000001,10050000001,0,0,0,0,0,0,0,0,0,0,10120000001,0,0,10120000001', + '0,10066666668,10066666668,0,0,0,0,0,0,0,0,0,0,10076666668,0,0,10076666668', + ].join('\n'), + 'com.example.app', + '2026-04-01T10:00:11.000Z', + ); + + assert.equal(sample.windowStartedAt, '2026-04-01T10:00:10.000Z'); + assert.equal(sample.windowEndedAt, '2026-04-01T10:00:11.000Z'); + assert.equal(sample.timestampSource, 'estimated-from-device-uptime'); + assert.equal(sample.worstWindows?.length, 1); + assert.equal(sample.worstWindows?.[0]?.startOffsetMs, 17); + assert.equal(sample.worstWindows?.[0]?.endOffsetMs, 120); + assert.equal(sample.worstWindows?.[0]?.missedDeadlineFrameCount, 2); + assert.equal(sample.worstWindows?.[0]?.worstFrameMs, 70); +}); + +test('parseAndroidFramePerfSample treats a reset idle window as an available zero-frame sample', () => { + const sample = parseAndroidFramePerfSample( + [ + 'Applications Graphics Acceleration Info:', + 'Uptime: 165130629 Realtime: 165130629', + 'Stats since: 165111622765012ns', + 'Total frames rendered: 0', + 'Janky frames: 0 (0.00%)', + 'Number Frame deadline missed: 0', + ].join('\n'), + 'host.exp.exponent', + '2026-04-01T10:00:00.000Z', + ); + + assert.equal(sample.droppedFrameCount, 0); + assert.equal(sample.totalFrameCount, 0); + assert.equal(sample.droppedFramePercent, 0); + assert.equal(sample.source, 'android-gfxinfo-summary'); +}); diff --git a/src/platforms/android/perf-frame-analysis.ts b/src/platforms/android/perf-frame-analysis.ts new file mode 100644 index 00000000..bcb79a81 --- /dev/null +++ b/src/platforms/android/perf-frame-analysis.ts @@ -0,0 +1,132 @@ +const MAX_WORST_WINDOWS = 3; +// Dropped frames separated by more than 500ms are reported as separate jank clusters. +const JANK_WINDOW_GAP_NS = 500_000_000; +const MIN_DISPLAY_FRAME_INTERVAL_NS = 4_000_000; +const MAX_DISPLAY_FRAME_INTERVAL_NS = 50_000_000; + +export type AndroidFrameStatsRow = { + intendedVsyncNs: number; + frameCompletedNs: number; + durationNs: number; +}; + +export type AndroidFrameDropWindow = { + startOffsetMs: number; + endOffsetMs: number; + startAt?: string; + endAt?: string; + missedDeadlineFrameCount: number; + worstFrameMs: number; +}; + +export function deriveFrameDeadlineNs(frames: AndroidFrameStatsRow[]): number | undefined { + const intendedVsyncs = uniqueSortedNumbers(frames.map((frame) => frame.intendedVsyncNs)); + const deltas: number[] = []; + for (let index = 1; index < intendedVsyncs.length; index += 1) { + const delta = intendedVsyncs[index]! - intendedVsyncs[index - 1]!; + if (delta >= MIN_DISPLAY_FRAME_INTERVAL_NS && delta <= MAX_DISPLAY_FRAME_INTERVAL_NS) { + deltas.push(delta); + } + } + if (deltas.length === 0) return undefined; + return median(deltas); +} + +export function selectDroppedFrameRows(options: { + frames: AndroidFrameStatsRow[]; + frameDeadlineNs?: number; + summaryDroppedFrameCount?: number; +}): AndroidFrameStatsRow[] { + const { frames, frameDeadlineNs, summaryDroppedFrameCount } = options; + if (summaryDroppedFrameCount !== undefined) { + if (summaryDroppedFrameCount <= 0) return []; + // Android's janky-frame summary is authoritative, but framestats rows do not expose + // the exact summary classification. Use the slowest rows only for approximate attribution. + return [...frames] + .sort((left, right) => right.durationNs - left.durationNs) + .slice(0, summaryDroppedFrameCount) + .sort((left, right) => left.intendedVsyncNs - right.intendedVsyncNs); + } + if (frameDeadlineNs === undefined) return []; + return frames.filter((frame) => frame.durationNs > frameDeadlineNs); +} + +export function buildWorstFrameDropWindows(options: { + frames: AndroidFrameStatsRow[]; + windowStartNs?: number; + measuredAtMs: number; + uptimeMs?: number; +}): AndroidFrameDropWindow[] { + const { frames, windowStartNs, measuredAtMs, uptimeMs } = options; + if (frames.length === 0 || windowStartNs === undefined) return []; + + const windows: AndroidFrameStatsRow[][] = []; + let current: AndroidFrameStatsRow[] = []; + for (const frame of frames) { + const previous = current.at(-1); + if (!previous || frame.intendedVsyncNs - previous.frameCompletedNs <= JANK_WINDOW_GAP_NS) { + current.push(frame); + continue; + } + windows.push(current); + current = [frame]; + } + if (current.length > 0) windows.push(current); + + return windows + .map((windowFrames) => + buildFrameDropWindow({ + frames: windowFrames, + windowStartNs, + measuredAtMs, + uptimeMs, + }), + ) + .sort( + (left, right) => + right.missedDeadlineFrameCount - left.missedDeadlineFrameCount || + right.worstFrameMs - left.worstFrameMs, + ) + .slice(0, MAX_WORST_WINDOWS) + .sort((left, right) => left.startOffsetMs - right.startOffsetMs); +} + +export function roundOneDecimal(value: number): number { + return Math.round(value * 10) / 10; +} + +function buildFrameDropWindow(options: { + frames: AndroidFrameStatsRow[]; + windowStartNs: number; + measuredAtMs: number; + uptimeMs?: number; +}): AndroidFrameDropWindow { + const { frames, windowStartNs, measuredAtMs, uptimeMs } = options; + const startNs = Math.min(...frames.map((frame) => frame.intendedVsyncNs)); + const endNs = Math.max(...frames.map((frame) => frame.frameCompletedNs)); + const startOffsetMs = Math.max(0, Math.round((startNs - windowStartNs) / 1_000_000)); + const endOffsetMs = Math.max(startOffsetMs, Math.round((endNs - windowStartNs) / 1_000_000)); + const base = + uptimeMs !== undefined && Number.isFinite(measuredAtMs) ? measuredAtMs - uptimeMs : undefined; + return { + startOffsetMs, + endOffsetMs, + startAt: base === undefined ? undefined : new Date(base + startNs / 1_000_000).toISOString(), + endAt: base === undefined ? undefined : new Date(base + endNs / 1_000_000).toISOString(), + missedDeadlineFrameCount: frames.length, + worstFrameMs: roundOneDecimal(Math.max(...frames.map((frame) => frame.durationNs)) / 1_000_000), + }; +} + +function uniqueSortedNumbers(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isFinite(value)))].sort( + (left, right) => left - right, + ); +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const midpoint = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[midpoint]!; + return (sorted[midpoint - 1]! + sorted[midpoint]!) / 2; +} diff --git a/src/platforms/android/perf-frame-parser.ts b/src/platforms/android/perf-frame-parser.ts new file mode 100644 index 00000000..5c27e5cb --- /dev/null +++ b/src/platforms/android/perf-frame-parser.ts @@ -0,0 +1,375 @@ +import { AppError } from '../../utils/errors.ts'; +import { roundPercent } from '../perf-utils.ts'; +import { + buildWorstFrameDropWindows, + deriveFrameDeadlineNs, + roundOneDecimal, + selectDroppedFrameRows, + type AndroidFrameDropWindow, + type AndroidFrameStatsRow, +} from './perf-frame-analysis.ts'; + +export type { AndroidFrameDropWindow } from './perf-frame-analysis.ts'; + +export const ANDROID_FRAME_SAMPLE_METHOD = 'adb-shell-dumpsys-gfxinfo-framestats'; +export const ANDROID_FRAME_SAMPLE_DESCRIPTION = + 'Rendered-frame health from the current adb shell dumpsys gfxinfo framestats window. Dropped frames use Android gfxinfo janky-frame/frame-deadline data when available; this is not video recording FPS.'; + +type AndroidFrameSummary = { + droppedFramePercent: number; + droppedFrameCount: number; + totalFrameCount: number; + sampleWindowMs?: number; + uptimeMs?: number; + statsSinceNs?: number; +}; + +type AndroidFrameCounts = { + droppedFramePercent: number; + droppedFrameCount: number; + totalFrameCount: number; +}; + +type AndroidFrameTiming = { + sampleWindowMs?: number; + windowStartNs?: number; + windowStartedAt?: string; + windowEndedAt?: string; + timestampSource?: 'estimated-from-device-uptime'; +}; + +export type AndroidFramePerfSample = { + droppedFramePercent: number; + droppedFrameCount: number; + totalFrameCount: number; + sampleWindowMs?: number; + frameDeadlineMs?: number; + refreshRateHz?: number; + windowStartedAt?: string; + windowEndedAt?: string; + timestampSource?: 'estimated-from-device-uptime'; + measuredAt: string; + method: typeof ANDROID_FRAME_SAMPLE_METHOD; + source: 'android-gfxinfo-summary' | 'framestats-rows'; + worstWindows?: AndroidFrameDropWindow[]; +}; + +export function parseAndroidFramePerfSample( + stdout: string, + packageName: string, + measuredAt: string, +): AndroidFramePerfSample { + assertAndroidGfxInfoProcessFound(stdout, packageName); + const summary = parseAndroidFrameSummary(stdout); + const frames = parseAndroidFrameStatsRows(stdout); + const frameDeadlineNs = readAndroidFrameDeadlineNs(frames, summary, packageName); + const measuredAtMs = Date.parse(measuredAt); + const timing = buildAndroidFrameTiming({ + frames, + measuredAtMs, + summary, + }); + const droppedFrames = selectDroppedFrameRows({ + frames, + frameDeadlineNs, + summaryDroppedFrameCount: summary?.droppedFrameCount, + }); + const sampleWindowMs = + summary?.sampleWindowMs ?? timing.sampleWindowMs ?? computeFrameWindowMs(frames); + const counts = buildAndroidFrameCounts(summary, frames, droppedFrames); + const worstWindows = buildAndroidWorstWindows({ + droppedFrames, + timing, + measuredAtMs, + summary, + }); + + return { + ...counts, + sampleWindowMs, + ...buildAndroidFrameRateFields(frameDeadlineNs), + windowStartedAt: timing.windowStartedAt, + windowEndedAt: timing.windowEndedAt, + timestampSource: timing.timestampSource, + measuredAt, + method: ANDROID_FRAME_SAMPLE_METHOD, + source: summary ? 'android-gfxinfo-summary' : 'framestats-rows', + worstWindows: worstWindows && worstWindows.length > 0 ? worstWindows : undefined, + }; +} + +function assertAndroidGfxInfoProcessFound(stdout: string, packageName: string): void { + if (!/no process found for:/i.test(stdout)) return; + throw new AppError( + 'COMMAND_FAILED', + `Android gfxinfo did not find a running process for ${packageName}`, + { + metric: 'fps', + package: packageName, + hint: 'Run open for this session again to ensure the Android app is active, then retry perf after the interaction you want to inspect.', + }, + ); +} + +function throwFrameParseError(packageName: string): never { + throw new AppError( + 'COMMAND_FAILED', + `Failed to parse Android framestats output for ${packageName}`, + { + metric: 'fps', + package: packageName, + hint: 'Retry perf after exercising the app screen. If the problem persists, capture adb shell dumpsys gfxinfo framestats output for debugging.', + }, + ); +} + +function readAndroidFrameDeadlineNs( + frames: AndroidFrameStatsRow[], + summary: AndroidFrameSummary | undefined, + packageName: string, +): number | undefined { + const frameDeadlineNs = deriveFrameDeadlineNs(frames); + if (!summary && frames.length === 0) { + throwFrameParseError(packageName); + } + if (summary || frameDeadlineNs !== undefined) return frameDeadlineNs; + throw new AppError( + 'COMMAND_FAILED', + `Failed to infer Android frame deadline from framestats output for ${packageName}`, + { + metric: 'fps', + package: packageName, + hint: 'Retry perf after a longer interaction window so consecutive Android frame timestamps are available.', + }, + ); +} + +function buildAndroidFrameCounts( + summary: AndroidFrameSummary | undefined, + frames: AndroidFrameStatsRow[], + droppedFrames: AndroidFrameStatsRow[], +): AndroidFrameCounts { + const totalFrameCount = summary?.totalFrameCount ?? frames.length; + const droppedFrameCount = summary?.droppedFrameCount ?? droppedFrames.length; + return { + totalFrameCount, + droppedFrameCount, + droppedFramePercent: + summary?.droppedFramePercent ?? + (totalFrameCount > 0 ? roundPercent((droppedFrameCount / totalFrameCount) * 100) : 0), + }; +} + +function buildAndroidFrameRateFields( + frameDeadlineNs: number | undefined, +): Pick { + return { + frameDeadlineMs: + frameDeadlineNs === undefined ? undefined : roundOneDecimal(frameDeadlineNs / 1_000_000), + refreshRateHz: + frameDeadlineNs === undefined ? undefined : roundOneDecimal(1_000_000_000 / frameDeadlineNs), + }; +} + +function buildAndroidWorstWindows(options: { + droppedFrames: AndroidFrameStatsRow[]; + timing: AndroidFrameTiming; + measuredAtMs: number; + summary?: AndroidFrameSummary; +}): AndroidFrameDropWindow[] | undefined { + const { droppedFrames, timing, measuredAtMs, summary } = options; + if (droppedFrames.length === 0) return undefined; + const worstWindows = buildWorstFrameDropWindows({ + frames: droppedFrames, + windowStartNs: timing.windowStartNs, + measuredAtMs, + uptimeMs: summary?.uptimeMs, + }); + return worstWindows.length > 0 ? worstWindows : undefined; +} + +function parseAndroidFrameStatsRows(text: string): AndroidFrameStatsRow[] { + const rows: AndroidFrameStatsRow[] = []; + let columnIndex: Map | null = null; + + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (line.length === 0 || line === '---PROFILEDATA---') continue; + + const cells = line.split(',').map((cell) => cell.trim()); + if (isFrameStatsHeader(cells)) { + columnIndex = new Map(cells.map((cell, index) => [cell, index])); + continue; + } + const row = parseFrameStatsDataRow(cells, columnIndex); + if (row) rows.push(row); + } + + return rows.sort((left, right) => left.intendedVsyncNs - right.intendedVsyncNs); +} + +function isFrameStatsHeader(cells: string[]): boolean { + return cells.includes('IntendedVsync') && cells.includes('FrameCompleted'); +} + +function parseFrameStatsDataRow( + cells: string[], + columnIndex: Map | null, +): AndroidFrameStatsRow | undefined { + if (!columnIndex || cells.length < columnIndex.size) return undefined; + const flags = readFrameStatsNumber(cells, columnIndex, 'Flags'); + const intendedVsyncNs = readFrameStatsNumber(cells, columnIndex, 'IntendedVsync'); + const frameCompletedNs = readFrameStatsNumber(cells, columnIndex, 'FrameCompleted'); + if ( + flags !== 0 || + intendedVsyncNs === null || + frameCompletedNs === null || + intendedVsyncNs <= 0 || + frameCompletedNs <= intendedVsyncNs + ) { + return undefined; + } + return { + intendedVsyncNs, + frameCompletedNs, + durationNs: frameCompletedNs - intendedVsyncNs, + }; +} + +function readFrameStatsNumber( + cells: string[], + columnIndex: Map, + column: string, +): number | null { + const index = columnIndex.get(column); + if (index === undefined) return null; + const value = Number(cells[index]); + return Number.isFinite(value) ? value : null; +} + +function parseAndroidFrameSummary(text: string): AndroidFrameSummary | undefined { + const summaryText = text.split(/\nProfile data in ms:\n/i)[0] ?? ''; + const totalFrameCount = matchSummaryInteger(summaryText, 'Total frames rendered'); + const jankyFrameMatch = summaryText.match( + /^\s*Janky frames:\s*([0-9][0-9,]*)\s*\(([0-9.]+)%\)/im, + ); + if (totalFrameCount === undefined || !jankyFrameMatch) return undefined; + + const droppedFrameCount = parseNumericToken(jankyFrameMatch[1]) ?? undefined; + const droppedFramePercent = Number(jankyFrameMatch[2]); + if ( + droppedFrameCount === undefined || + !Number.isFinite(droppedFramePercent) || + totalFrameCount < 0 + ) { + return undefined; + } + + const uptimeMs = matchSummaryInteger(summaryText, 'Uptime'); + const statsSinceNs = matchSummaryInteger(summaryText, 'Stats since'); + return { + droppedFramePercent: roundPercent(droppedFramePercent), + droppedFrameCount, + totalFrameCount, + sampleWindowMs: parseAndroidFrameSummaryWindowMs({ uptimeMs, statsSinceNs }), + uptimeMs, + statsSinceNs, + }; +} + +function parseAndroidFrameSummaryWindowMs(options: { + uptimeMs?: number; + statsSinceNs?: number; +}): number | undefined { + const { uptimeMs, statsSinceNs } = options; + if (uptimeMs === undefined || statsSinceNs === undefined) return undefined; + const windowMs = uptimeMs - Math.round(statsSinceNs / 1_000_000); + return windowMs >= 0 ? windowMs : undefined; +} + +function buildAndroidFrameTiming(options: { + frames: AndroidFrameStatsRow[]; + measuredAtMs: number; + summary?: AndroidFrameSummary; +}): AndroidFrameTiming { + const { frames, measuredAtMs, summary } = options; + const bounds = computeFrameBounds(frames); + const summaryStartNs = summary?.statsSinceNs; + const windowStartNs = summaryStartNs ?? bounds.firstFrameNs; + const rawSampleWindowMs = computeWindowDurationMs(windowStartNs, bounds.lastFrameNs); + const sampleWindowMs = summary?.sampleWindowMs ?? rawSampleWindowMs; + if ( + !Number.isFinite(measuredAtMs) || + summary?.uptimeMs === undefined || + windowStartNs === undefined + ) { + return { sampleWindowMs, windowStartNs }; + } + + const deviceBootWallClockMs = measuredAtMs - summary.uptimeMs; + // Summary windows extend to the dumpsys read. The retained raw rows can end earlier. + return { + sampleWindowMs, + windowStartNs, + windowStartedAt: new Date(deviceBootWallClockMs + windowStartNs / 1_000_000).toISOString(), + windowEndedAt: buildAndroidFrameWindowEnd({ + deviceBootWallClockMs, + measuredAtMs, + summaryStartNs, + lastFrameNs: bounds.lastFrameNs, + }), + timestampSource: 'estimated-from-device-uptime', + }; +} + +function computeFrameBounds(frames: AndroidFrameStatsRow[]): { + firstFrameNs?: number; + lastFrameNs?: number; +} { + if (frames.length === 0) return {}; + return { + firstFrameNs: Math.min(...frames.map((frame) => frame.intendedVsyncNs)), + lastFrameNs: Math.max(...frames.map((frame) => frame.frameCompletedNs)), + }; +} + +function computeWindowDurationMs( + windowStartNs: number | undefined, + windowEndNs: number | undefined, +): number | undefined { + if (windowStartNs === undefined || windowEndNs === undefined) return undefined; + return Math.max(0, Math.round((windowEndNs - windowStartNs) / 1_000_000)); +} + +function buildAndroidFrameWindowEnd(options: { + deviceBootWallClockMs: number; + measuredAtMs: number; + summaryStartNs?: number; + lastFrameNs?: number; +}): string | undefined { + const { deviceBootWallClockMs, measuredAtMs, summaryStartNs, lastFrameNs } = options; + if (summaryStartNs !== undefined) return new Date(measuredAtMs).toISOString(); + return lastFrameNs === undefined + ? undefined + : new Date(deviceBootWallClockMs + lastFrameNs / 1_000_000).toISOString(); +} + +function computeFrameWindowMs(frames: AndroidFrameStatsRow[]): number | undefined { + if (frames.length === 0) return undefined; + const bounds = computeFrameBounds(frames); + return computeWindowDurationMs(bounds.firstFrameNs, bounds.lastFrameNs); +} + +function matchSummaryInteger(text: string, label: string): number | undefined { + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`^\\s*${escapedLabel}:\\s*([0-9][0-9,]*)`, 'im')); + if (!match) return undefined; + return parseNumericToken(match[1]) ?? undefined; +} + +function parseNumericToken(token: string): number | null { + const match = token.replaceAll(',', '').match(/^-?\d+(?:\.\d+)?/); + if (!match) return null; + const value = Number(match[0]); + return Number.isFinite(value) ? value : null; +} diff --git a/src/platforms/android/perf-frame.ts b/src/platforms/android/perf-frame.ts new file mode 100644 index 00000000..4d7cb8db --- /dev/null +++ b/src/platforms/android/perf-frame.ts @@ -0,0 +1,84 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runCmd } from '../../utils/exec.ts'; +import { adbArgs } from './adb.ts'; +import { parseAndroidFramePerfSample, type AndroidFramePerfSample } from './perf-frame-parser.ts'; + +export { + ANDROID_FRAME_SAMPLE_DESCRIPTION, + ANDROID_FRAME_SAMPLE_METHOD, + parseAndroidFramePerfSample, + type AndroidFrameDropWindow, + type AndroidFramePerfSample, +} from './perf-frame-parser.ts'; + +const ANDROID_FRAME_PERF_TIMEOUT_MS = 15_000; +const ANDROID_FRAME_RESET_TIMEOUT_MS = 3_000; + +export async function sampleAndroidFramePerf( + device: DeviceInfo, + packageName: string, +): Promise { + try { + const result = await runCmd( + 'adb', + adbArgs(device, ['shell', 'dumpsys', 'gfxinfo', packageName, 'framestats']), + { timeoutMs: ANDROID_FRAME_PERF_TIMEOUT_MS }, + ); + const sample = parseAndroidFramePerfSample( + result.stdout, + packageName, + new Date().toISOString(), + ); + await resetAndroidFramePerfStats(device, packageName); + return sample; + } catch (error) { + throw annotateAndroidFramePerfSamplingError(packageName, error); + } +} + +export async function resetAndroidFramePerfStats( + device: DeviceInfo, + packageName: string, +): Promise { + try { + await runCmd('adb', adbArgs(device, ['shell', 'dumpsys', 'gfxinfo', packageName, 'reset']), { + allowFailure: true, + timeoutMs: ANDROID_FRAME_RESET_TIMEOUT_MS, + }); + } catch { + // Reset is best-effort; sampling/open should still succeed if adb times out or disappears. + } +} + +function annotateAndroidFramePerfSamplingError(packageName: string, error: unknown): AppError { + if ( + error instanceof AppError && + (error.code === 'TOOL_MISSING' || error.code === 'COMMAND_FAILED') + ) { + return new AppError( + error.code, + error.message, + { + ...(error.details ?? {}), + metric: 'fps', + package: packageName, + }, + error, + ); + } + + if (error instanceof AppError) { + return error; + } + + return new AppError( + 'COMMAND_FAILED', + `Failed to sample Android fps for ${packageName}`, + { + metric: 'fps', + package: packageName, + }, + error, + ); +} diff --git a/src/platforms/android/perf.ts b/src/platforms/android/perf.ts index 5705be2a..cf62f55d 100644 --- a/src/platforms/android/perf.ts +++ b/src/platforms/android/perf.ts @@ -3,6 +3,15 @@ import { AppError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { adbArgs } from './adb.ts'; import { roundPercent } from '../perf-utils.ts'; +export { + ANDROID_FRAME_SAMPLE_DESCRIPTION, + ANDROID_FRAME_SAMPLE_METHOD, + parseAndroidFramePerfSample, + resetAndroidFramePerfStats, + sampleAndroidFramePerf, + type AndroidFrameDropWindow, + type AndroidFramePerfSample, +} from './perf-frame.ts'; export const ANDROID_CPU_SAMPLE_METHOD = 'adb-shell-dumpsys-cpuinfo'; export const ANDROID_CPU_SAMPLE_DESCRIPTION = diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 48e7921a..1c45eb30 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -180,7 +180,7 @@ const AGENT_QUICKSTART_LINES = [ 'Read-only visible/state question: use snapshot/get/is/find; use snapshot -i only when refs are needed.', 'Truncated text/input preview: expand first with snapshot -s @e12, not get text.', 'RN warning/error overlays can block taps: snapshot -i, dismiss/close, then diff snapshot -i.', - 'Expo Go/dev clients: use the provided URL when given; on iOS prefer open "Expo Go" when the host shell is known.', + 'Expo Go/dev clients: use the provided URL when given; on iOS prefer open "Expo Go" ; Android URL opens infer the foreground package for logs/perf when possible.', 'Install flows: install/install-from-source first, then open the installed id with --relaunch.', 'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.', 'Clearing text: do not use fill ""; use a visible clear/reset control or report that clearing is unsupported.', @@ -324,7 +324,7 @@ Validation and evidence: Prefer provided testIDs/ids/selectors for verification; use visible text when no durable selector is provided. If task says snapshot, use snapshot. If it asks visual evidence, use screenshot. Icon/tappable visual proof: screenshot --overlay-refs. Flag is --overlay-refs. - Startup/CPU/memory: perf --json or metrics. Replay maintenance: replay -u ./flow.ad. + Startup/frame health/CPU/memory: perf --json or metrics. Replay maintenance: replay -u ./flow.ad. Recording: record start/stop. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. Stable known flow: batch ./steps.json, not workflow batch. Inline batch JSON example: @@ -349,6 +349,7 @@ React Native dev loop: agent-device open exp://127.0.0.1:8081 --platform ios Android uses the URL target directly; do not write open there: agent-device open exp://127.0.0.1:8081 --platform android + Android URL/deep-link opens infer the foreground package after launch when possible, so logs/perf can remain package-bound. If perf still says no package is associated, open the host package/app id first, then open the URL in the same session. If apps lookup misses the project but shows Expo Go/dev-client and a project URL is available, open the URL/host shell; if no URL is available, ask instead of inventing an app id. Expo Dev Client/development builds: open the installed dev-client app id/name; if a dev-client URL is provided, open that URL next. For Metro setup use metro prepare --kind expo. @@ -1611,7 +1612,7 @@ const COMMAND_SCHEMAS: Record = { allowedFlags: [], }, perf: { - helpDescription: 'Show session performance metrics', + helpDescription: 'Show session performance metrics, including Android frame health', summary: 'Show performance metrics', positionalArgs: [], allowedFlags: [], diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 03f10bb7..58914d6f 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -194,6 +194,8 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` +`client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. On Android, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. It is derived from the current `adb shell dumpsys gfxinfo framestats` window and represents rendered frames that missed Android's frame deadline, not recording FPS. Android frame samples also include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful read resets Android frame stats; `open ` resets the Android frame window too, so agents can call `perf`, perform a transition or gesture, then call `perf` again to inspect that focused window. + `client.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution. ## Batch orchestration for custom transports diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 90d56593..b5c0bf7d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -549,8 +549,10 @@ agent-device metrics --json ``` - `perf` (alias: `metrics`) returns a session-scoped metrics JSON blob. +- Without `--json`, `perf` prints a compact summary: Android frame health when frame data is available, otherwise CPU/memory when those samples are available. - `startup` is sampled from `open-command-roundtrip`: elapsed wall-clock time around each `open` command dispatch for the active session app target. - Android app sessions with an active package also sample: + - `fps` frame health from `adb shell dumpsys gfxinfo framestats`, with `droppedFramePercent` as the primary value and `worstWindows` for dropped-frame clusters - `memory` from `adb shell dumpsys meminfo ` with values reported in kilobytes (`kB`) - `cpu` from `adb shell dumpsys cpuinfo`, aggregated across matching package processes and reported as a recent percentage snapshot - Apple app sessions with an active bundle ID also sample: @@ -559,9 +561,10 @@ agent-device metrics --json - Platform support: - `startup`: iOS simulator, iOS physical device, Android emulator/device - `memory` and `cpu`: Android emulator/device, macOS app sessions, iOS simulators with an active app session (`open ` first), and iOS physical devices with an active app session -- `fps` is still unavailable on all platforms in this release. + - `fps`: Android emulator/device app sessions - If no startup sample exists yet for the session, run `open ` first and retry `perf`. -- If the session has no app package/bundle ID yet, `memory` and `cpu` remain unavailable until you `open `. +- Android URL/deep-link opens infer the foreground package after launch when possible, including Expo Go/dev-client shells. If the session still has no app package/bundle ID, package-bound metrics remain unavailable until you `open `. +- Android frame health is reset after each successful `perf` read and after `open `, so run `perf`, perform the interaction, then run `perf` again for a focused window. - On physical iOS devices, `perf` records a short `xcrun xctrace` Activity Monitor sample. Keep the device unlocked, connected, and the app active in the foreground while sampling. - Interpretation note: this startup metric is command round-trip timing and does not represent true first frame / first interactive app instrumentation. - CPU data is a lightweight process snapshot, so an idle app may legitimately read as `0`. diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 1c3400a7..874d48e5 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -76,9 +76,10 @@ agent-device perf --json agent-device metrics --json ``` -- `perf` returns session-scoped startup and, where supported, CPU and memory samples. +- `perf` returns session-scoped startup and, where supported, CPU, memory, and Android frame-health samples. - Startup is measured around the `open` command; it is not first-frame instrumentation. -- CPU and memory availability depends on platform and whether the active session is bound to an app/package. +- CPU, memory, and Android frame-health availability depend on platform and whether the active session is bound to an app/package. +- On Android, use `metrics.fps.droppedFramePercent` for the health check and `metrics.fps.worstWindows` to line up jank clusters with logs, network activity, or recent actions. ## Where to go deeper diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 14feaa41..fb8faef4 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -10,7 +10,7 @@ title: Introduction - Deterministic interactions (tap, type, scroll) - Session-aware workflows and replay - Session logs and network inspection for debugging broken flows -- Performance snapshots with `perf`/`metrics`, including CPU and memory data where supported +- Performance snapshots with `perf`/`metrics`, including CPU, memory, and Android dropped-frame data where supported If you know `agent-browser`, this is the mobile-native counterpart for iOS/Android UI automation and app-level observability. For agent-oriented operating guidance, start with `agent-device help` or `agent-device help workflow`. Skills are recommended auto-routing helpers when your agent runtime supports them, but agents can operate from CLI help alone. For exploratory QA, use `agent-device help dogfood`. For React Native component trees, props/state/hooks, and render profiling, use `agent-device help react-devtools` and the `agent-device react-devtools` passthrough. @@ -28,7 +28,7 @@ For agent-oriented operating guidance, start with `agent-device help` or `agent- - iOS/tvOS simulator-only: `settings`, `push`, `clipboard`. - Apple simulators and macOS desktop app sessions: `alert`, `pinch`. - Session diagnostics: `logs` and `network dump` are available for debugging active app sessions, with network inspection based on recent HTTP(s) entries captured in the session app log. -- Session performance metrics: `perf`/`metrics` is available on iOS, macOS, and Android. Startup timing comes from `open` command round-trip duration. Android app sessions and Apple app sessions on macOS, iOS simulators, or connected iOS devices also expose CPU and memory snapshots when an app identifier is available in the session. +- Session performance metrics: `perf`/`metrics` is available on iOS, macOS, and Android. Startup timing comes from `open` command round-trip duration. Android app sessions expose CPU, memory, and rendered-frame health; use `metrics.fps.droppedFramePercent` as the primary Android frame-smoothness signal, and `metrics.fps.worstWindows` to correlate jank clusters with logs or recent interactions. Apple app sessions on macOS, iOS simulators, or connected iOS devices expose CPU and memory snapshots when an app identifier is available in the session. Android dropped-frame data comes from the current `dumpsys gfxinfo ... framestats` window, is reset after each successful `perf` read, and is not video recording FPS. - iOS `record` supports simulators and physical devices. - Simulators use native `simctl io ... recordVideo`. - Physical devices use runner screenshot capture (`XCUIScreen.main.screenshot()` frames) stitched into MP4, so FPS is best-effort (not guaranteed 60 even with `--fps 60`).