Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/__tests__/cli-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ test('perf area and action positionals are case-insensitive', async () => {
});

test('perf rejects unknown CLI area before daemon dispatch', async () => {
const result = await runCliCapture(['perf', 'cpu', '--json'], async () => ({
const result = await runCliCapture(['perf', 'gpu', '--json'], async () => ({
ok: true,
data: {},
}));
Expand All @@ -152,7 +152,55 @@ test('perf rejects unknown CLI area before daemon dispatch', async () => {
assert.equal(result.calls.length, 0);
const payload = JSON.parse(result.stdout);
assert.equal(payload.error.code, 'INVALID_ARGS');
assert.match(payload.error.message, /perf area must be metrics or frames/i);
assert.match(payload.error.message, /perf area must be metrics, frames, cpu, or trace/i);
});

test('perf cpu profile start forwards simpleperf kind and out path', async () => {
const result = await runCliCapture(
['perf', 'cpu', 'profile', 'start', '--kind', 'simpleperf', '--out', 'cpu.perf.data', '--json'],
async () => ({
ok: true,
data: {
action: 'start',
type: 'cpu-profile',
kind: 'simpleperf',
state: 'running',
},
}),
);

assert.equal(result.code, null);
const call = result.calls[0];
assert.ok(call);
assert.equal(call.command, 'perf');
assert.deepEqual(call.positionals, ['cpu', 'profile', 'start', 'simpleperf']);
assert.ok(call.flags);
assert.equal(call.flags.out, 'cpu.perf.data');
});

test('perf trace stop forwards perfetto kind and prints compact artifact summary', async () => {
const result = await runCliCapture(
['perf', 'trace', 'stop', '--kind', 'perfetto', '--out', 'app.perfetto-trace'],
async () => ({
ok: true,
data: {
action: 'stop',
type: 'trace',
kind: 'perfetto',
state: 'stopped',
outPath: '/tmp/app.perfetto-trace',
sizeBytes: 2048,
},
}),
);

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['trace', 'stop', 'perfetto']);
assert.equal(
result.stdout,
'Perf stop: perfetto trace state=stopped\n/tmp/app.perfetto-trace (2.0KB)\n',
);
});

test('perf prints unavailable frame health reason by default', async () => {
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,40 @@ test('observability.perf projects structured frame area to daemon positionals',
assert.deepEqual(setup.calls[0]?.positionals, ['frames', 'sample']);
});

test('observability.perf projects structured Android native profile input to daemon positionals', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'perf') {
return {
ok: true,
data: {
action: 'start',
type: 'cpu-profile',
kind: 'simpleperf',
state: 'running',
},
};
}
throw new Error(`Unexpected command: ${req.command}`);
});
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

await client.observability.perf({
area: 'cpu',
mode: 'profile',
action: 'start',
kind: 'simpleperf',
out: 'cpu.perf.data',
});

assert.equal(setup.calls.length, 1);
const call = setup.calls[0];
assert.ok(call);
assert.equal(call.command, 'perf');
assert.deepEqual(call.positionals, ['cpu', 'profile', 'start', 'simpleperf']);
assert.ok(call.flags);
assert.equal(call.flags.out, 'cpu.perf.data');
});

test('structured command input accepts target as deviceTarget alias when no UI target exists', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'open') {
Expand Down
7 changes: 5 additions & 2 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import type {
import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts';
import type { AppsFilter } from './contracts/app-inventory.ts';
import type { ScreenshotRequestFlags } from './contracts/screenshot.ts';
import type { PerfAction, PerfArea } from './contracts/perf.ts';
import type { PerfAction, PerfCommandArea, PerfCpuMode, PerfKind } from './contracts/perf.ts';
import type { DaemonBatchStep } from './core/batch.ts';
import type { AlertAction, AlertInfo } from './alert-contract.ts';

Expand Down Expand Up @@ -740,8 +740,11 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & {
};

export type PerfOptions = DeviceCommandBaseOptions & {
area?: PerfArea;
area?: PerfCommandArea;
mode?: PerfCpuMode;
action?: PerfAction;
kind?: PerfKind;
out?: string;
};

export type LogsOptions = AgentDeviceRequestOverrides & {
Expand Down
10 changes: 9 additions & 1 deletion src/commands/cli-grammar/metro.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AppError } from '../../utils/errors.ts';
import type { CliReader } from './types.ts';

const METRO_PREPARE_KINDS = ['auto', 'react-native', 'expo'] as const;

export const metroCliReaders = {
metro: metroInputFromCli,
} satisfies Record<string, CliReader>;
Expand Down Expand Up @@ -29,7 +31,7 @@ function metroInputFromCli(positionals: string[], flags: Parameters<CliReader>[1
return {
action,
projectRoot: flags.metroProjectRoot,
kind: flags.metroKind,
kind: readMetroPrepareKind(flags.metroKind),
port: flags.metroPreparePort,
listenHost: flags.metroListenHost,
statusHost: flags.metroStatusHost,
Expand All @@ -51,3 +53,9 @@ function metroInputFromCli(positionals: string[], flags: Parameters<CliReader>[1
runtimeFilePath: flags.metroRuntimeFile,
};
}

function readMetroPrepareKind(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
if ((METRO_PREPARE_KINDS as readonly string[]).includes(value)) return value;
throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo');
}
78 changes: 72 additions & 6 deletions src/commands/cli-grammar/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ import { parseStringMember } from '../../utils/string-enum.ts';
import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts';
import {
isPerfAction,
isPerfArea,
isPerfCommandArea,
isPerfCpuMode,
isPerfKind,
PERF_ACTION_ERROR_MESSAGE,
PERF_AREA_ERROR_MESSAGE,
PERF_CPU_MODE_ERROR_MESSAGE,
PERF_KIND_ERROR_MESSAGE,
type PerfAction,
type PerfArea,
type PerfCommandArea,
type PerfCpuMode,
type PerfKind,
} from '../perf-command-contract.ts';
import {
commonInputFromFlags,
Expand All @@ -31,6 +37,8 @@ export const observabilityCliReaders = {
perf: (positionals, flags) => ({
...commonInputFromFlags(flags),
...readPerfPositionals(positionals),
kind: readPerfKind(flags.perfKind),
out: flags.out,
}),
logs: (positionals, flags) => ({
...commonInputFromFlags(flags),
Expand Down Expand Up @@ -73,16 +81,45 @@ export const observabilityDaemonWriters = {

function perfPositionals(input: PerfOptions): string[] {
const area = input.area ?? (input.action ? 'metrics' : undefined);
if (area === 'cpu') {
return [
'cpu',
requiredPerfCpuMode(input.mode),
requiredPerfAction(input.action, 'perf cpu profile'),
requiredPerfKind(input.kind, 'perf cpu profile'),
];
}
if (area === 'trace') {
return [
'trace',
requiredPerfAction(input.action, 'perf trace'),
requiredPerfKind(input.kind, 'perf trace'),
];
}
return [...optionalString(area), ...optionalString(input.action)];
}

function readPerfPositionals(positionals: string[]): Pick<PerfOptions, 'area' | 'action'> {
function readPerfPositionals(positionals: string[]): Pick<PerfOptions, 'area' | 'mode' | 'action'> {
if (positionals[0] !== undefined && positionals[1] === undefined) {
const action = readPerfAction(positionals[0], { allowUndefined: true });
if (action) return { action };
}
const area = readPerfArea(positionals[0]);
if (area === 'cpu') {
return {
area,
mode: readPerfCpuMode(positionals[1]),
action: readPerfAction(positionals[2]),
};
}
if (area === 'trace') {
return {
area,
action: readPerfAction(positionals[1]),
};
}
return {
area: readPerfArea(positionals[0]),
area,
action: readPerfAction(positionals[1]),
};
}
Expand All @@ -104,10 +141,10 @@ function readStartStop(value: string | undefined, command: string): 'start' | 's
throw new AppError('INVALID_ARGS', `${command} requires start|stop`);
}

function readPerfArea(value: string | undefined): PerfArea | undefined {
function readPerfArea(value: string | undefined): PerfCommandArea | undefined {
if (value === undefined) return undefined;
const normalized = value.toLowerCase();
if (isPerfArea(normalized)) return normalized;
if (isPerfCommandArea(normalized)) return normalized;
throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE);
}

Expand All @@ -122,6 +159,35 @@ function readPerfAction(
throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE);
}

function readPerfCpuMode(value: string | undefined): PerfCpuMode | undefined {
if (value === undefined) return undefined;
const normalized = value.toLowerCase();
if (isPerfCpuMode(normalized)) return normalized;
throw new AppError('INVALID_ARGS', PERF_CPU_MODE_ERROR_MESSAGE);
}

function readPerfKind(value: string | undefined): PerfKind | undefined {
if (value === undefined) return undefined;
const normalized = value.toLowerCase();
if (isPerfKind(normalized)) return normalized;
throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE);
}

function requiredPerfCpuMode(value: PerfOptions['mode'], command: string = 'perf cpu'): string {
if (value === 'profile') return value;
throw new AppError('INVALID_ARGS', `${command} requires profile`);
}

function requiredPerfAction(value: PerfOptions['action'], command: string): string {
if (value) return value;
throw new AppError('INVALID_ARGS', `${command} requires an action`);
}

function requiredPerfKind(value: PerfOptions['kind'], command: string): string {
if (value) return value;
throw new AppError('INVALID_ARGS', `${command} requires --kind`);
}

function readLogsAction(value: string | undefined): LogAction | undefined {
if (value === undefined) return undefined;
return parseStringMember(LOG_ACTION_VALUES, value, {
Expand Down
13 changes: 11 additions & 2 deletions src/commands/client-command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import {
type CommandFieldMap,
} from './command-input.ts';
import { defineFieldCommandMetadata } from './field-command-contract.ts';
import { PERF_ACTION_VALUES, PERF_AREA_VALUES } from './perf-command-contract.ts';
import {
PERF_ACTION_VALUES,
PERF_AREA_VALUES,
PERF_CPU_MODE_VALUES,
PERF_KIND_VALUES,
PERF_NATIVE_AREA_VALUES,
} from './perf-command-contract.ts';
import { WAIT_KIND_VALUES } from './wait-command-contract.ts';

const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const;
Expand Down Expand Up @@ -180,8 +186,11 @@ export const clientCommandMetadata = [
shardSplit: integerField(),
}),
defineClientCommandMetadata('perf', {
area: enumField(PERF_AREA_VALUES),
area: enumField([...PERF_AREA_VALUES, ...PERF_NATIVE_AREA_VALUES]),
mode: enumField(PERF_CPU_MODE_VALUES),
action: enumField(PERF_ACTION_VALUES),
kind: enumField(PERF_KIND_VALUES),
out: stringField(),
}),
defineClientCommandMetadata('logs', {
action: enumField(LOG_ACTION_VALUES),
Expand Down
43 changes: 43 additions & 0 deletions src/commands/runtime-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ function joinDefinedLines(lines: Array<string | undefined>): string | undefined
}

function formatPerfCliOutput(data: Record<string, unknown>): string {
const nativeSummary = formatNativePerfOutput(data);
if (nativeSummary) return nativeSummary;
const metrics = readRecord(data.metrics);
const fps = readRecord(metrics?.fps);
const resourceSummary = buildResourcePerfSummary(metrics);
Expand All @@ -169,6 +171,39 @@ function formatPerfCliOutput(data: Record<string, unknown>): string {
return lines.join('\n');
}

function formatNativePerfOutput(data: Record<string, unknown>): string | undefined {
const summary = readNativePerfSummary(data);
if (!summary) return undefined;
return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState(
data,
)}${formatNativePerfArtifact(data)}`;
}

function readNativePerfSummary(
data: Record<string, unknown>,
): { action: string; kind: string; type: string } | undefined {
const action = readString(data.action);
const kind = readString(data.kind);
const type = readString(data.type);
return action && kind && type ? { action, kind, type } : undefined;
}

function formatNativePerfState(data: Record<string, unknown>): string {
const state = readString(data.state);
return state ? ` state=${state}` : '';
}

function formatNativePerfArtifact(data: Record<string, unknown>): string {
const outPath = readString(data.outPath);
if (!outPath) return '';
const sizeBytes = readFiniteNumber(data.sizeBytes);
return `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}`;
}

function readString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string {
return resourceSummary
? `Performance: ${resourceSummary}`
Expand Down Expand Up @@ -284,3 +319,11 @@ function formatMemoryKb(value: number): string {
const megabytes = value / 1024;
return `${megabytes >= 10 ? Math.round(megabytes) : megabytes.toFixed(1)}MB`;
}

function formatBytes(value: number): string {
if (value < 1024) return `${Math.round(value)}B`;
const kib = value / 1024;
if (kib < 1024) return `${kib >= 10 ? Math.round(kib) : kib.toFixed(1)}KB`;
const mib = kib / 1024;
return `${mib >= 10 ? Math.round(mib) : mib.toFixed(1)}MB`;
}
Loading
Loading