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
120 changes: 118 additions & 2 deletions src/__tests__/cli-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,122 @@ test('perf frames sample forwards explicit sample action to daemon', async () =>
assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']);
});

test('perf cpu profile start forwards xctrace options to daemon positionals', async () => {
const result = await runCliCapture(
[
'perf',
'cpu',
'profile',
'start',
'--kind',
'xctrace',
'--template',
'Time Profiler',
'--out',
'app.trace',
'--json',
],
async () => ({
ok: true,
data: {
perf: 'started',
kind: 'xctrace',
mode: 'cpu-profile',
outPath: '/tmp/app.trace',
},
}),
);

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, [
'cpu',
'profile',
'start',
'xctrace',
'Time Profiler',
'app.trace',
]);
});

test('perf trace stop forwards xctrace trace artifact path', async () => {
const result = await runCliCapture(
['perf', 'trace', 'stop', '--kind', 'xctrace', '--out', 'hitches.trace', '--json'],
async () => ({
ok: true,
data: {
perf: 'stopped',
kind: 'xctrace',
mode: 'trace',
outPath: '/tmp/hitches.trace',
},
}),
);

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['trace', 'stop', 'xctrace', '', 'hitches.trace']);
});

test('perf cpu profile report preserves the report out path when template is omitted', async () => {
const result = await runCliCapture(
[
'perf',
'cpu',
'profile',
'report',
'--kind',
'xctrace',
'--out',
'app-profile.json',
'--json',
],
async () => ({
ok: true,
data: {
perf: 'reported',
kind: 'xctrace',
mode: 'cpu-profile',
reportPath: '/tmp/app-profile.json',
},
}),
);

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, [
'cpu',
'profile',
'report',
'xctrace',
'',
'app-profile.json',
]);
});

test('perf xctrace output prints only compact artifact metadata by default', async () => {
const result = await runCliCapture(
['perf', 'cpu', 'profile', 'report', '--kind', 'xctrace', '--out', 'app-profile.json'],
async () => ({
ok: true,
data: {
perf: 'reported',
kind: 'xctrace',
mode: 'cpu-profile',
reportPath: '/tmp/app-profile.json',
tracePath: '/tmp/app.trace',
summary: {
tableSchemas: ['time-profile'],
},
},
}),
);

assert.equal(result.code, null);
assert.equal(result.stdout, '/tmp/app-profile.json\nPerf cpu-profile: reported\n');
assert.doesNotMatch(result.stdout, /time-profile|app\.trace/);
});

test('perf sample defaults to metrics sample', async () => {
const result = await runCliCapture(['perf', 'sample', '--json'], async () => ({
ok: true,
Expand Down Expand Up @@ -142,7 +258,7 @@ test('perf area and action positionals are case-insensitive', async () => {
assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']);
});

test('perf rejects unknown CLI area before daemon dispatch', async () => {
test('perf rejects incomplete native CLI area before daemon dispatch', async () => {
const result = await runCliCapture(['perf', 'cpu', '--json'], async () => ({
ok: true,
data: {},
Expand All @@ -152,7 +268,7 @@ 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 cpu requires profile/i);
});

test('perf prints unavailable frame health reason by default', async () => {
Expand Down
7 changes: 6 additions & 1 deletion 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, PerfArea, PerfKind, PerfSubject } from './contracts/perf.ts';
import type { DaemonBatchStep } from './core/batch.ts';
import type { AlertAction, AlertInfo } from './alert-contract.ts';

Expand Down Expand Up @@ -741,7 +741,12 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & {

export type PerfOptions = DeviceCommandBaseOptions & {
area?: PerfArea;
subject?: PerfSubject;
action?: PerfAction;
kind?: PerfKind;
template?: string;
out?: string;
tracePath?: string;
};

export type LogsOptions = AgentDeviceRequestOverrides & {
Expand Down
87 changes: 84 additions & 3 deletions src/commands/cli-grammar/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts';
import {
isPerfAction,
isPerfArea,
isPerfKind,
isPerfSubject,
PERF_ACTION_ERROR_MESSAGE,
PERF_AREA_ERROR_MESSAGE,
PERF_KIND_ERROR_MESSAGE,
PERF_SUBJECT_ERROR_MESSAGE,
type PerfAction,
type PerfArea,
type PerfKind,
type PerfSubject,
} from '../perf-command-contract.ts';
import {
commonInputFromFlags,
Expand All @@ -30,7 +36,11 @@ import type { CliReader, DaemonWriter } from './types.ts';
export const observabilityCliReaders = {
perf: (positionals, flags) => ({
...commonInputFromFlags(flags),
...readPerfPositionals(positionals),
...readPerfPositionals(positionals, {
kind: readPerfKindFlag(flags.perfKind),
template: flags.perfTemplate,
out: flags.out,
}),
}),
logs: (positionals, flags) => ({
...commonInputFromFlags(flags),
Expand Down Expand Up @@ -73,16 +83,70 @@ export const observabilityDaemonWriters = {

function perfPositionals(input: PerfOptions): string[] {
const area = input.area ?? (input.action ? 'metrics' : undefined);
if (area === 'cpu') {
return nativePerfPositionals(
[
...optionalString(area),
...optionalString(input.subject),
...optionalString(input.action),
...optionalString(input.kind),
],
input,
);
}
if (area === 'trace') {
return nativePerfPositionals(
[...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)],
input,
);
}
return [...optionalString(area), ...optionalString(input.action)];
}

function readPerfPositionals(positionals: string[]): Pick<PerfOptions, 'area' | 'action'> {
function nativePerfPositionals(base: string[], input: PerfOptions): string[] {
const positionals = [...base];
if (input.template || input.out || input.tracePath) {
positionals.push(input.template ?? '');
}
if (input.out || input.tracePath) {
positionals.push(input.out ?? '');
}
if (input.tracePath) {
positionals.push(input.tracePath);
}
return positionals;
}

function readPerfPositionals(
positionals: string[],
flags: Pick<PerfOptions, 'kind' | 'template' | 'out'> = {},
): Pick<PerfOptions, 'area' | 'subject' | 'action' | 'kind' | 'template' | 'out'> {
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,
subject: readPerfSubject(positionals[1]),
action: readPerfAction(positionals[2]),
kind: readPerfKind(flags.kind),
template: flags.template,
out: flags.out,
};
}
if (area === 'trace') {
return {
area,
action: readPerfAction(positionals[1]),
kind: readPerfKind(flags.kind),
template: flags.template,
out: flags.out,
};
}
return {
area: readPerfArea(positionals[0]),
area,
action: readPerfAction(positionals[1]),
};
}
Expand Down Expand Up @@ -122,6 +186,23 @@ function readPerfAction(
throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE);
}

function readPerfSubject(value: string | undefined): PerfSubject {
const normalized = value?.toLowerCase();
if (normalized !== undefined && isPerfSubject(normalized)) return normalized;
throw new AppError('INVALID_ARGS', PERF_SUBJECT_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 readPerfKindFlag(value: unknown): PerfKind | undefined {
return typeof value === 'string' ? readPerfKind(value) : undefined;
}

function readLogsAction(value: string | undefined): LogAction | undefined {
if (value === undefined) return undefined;
return parseStringMember(LOG_ACTION_VALUES, value, {
Expand Down
12 changes: 11 additions & 1 deletion src/commands/client-command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ 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_KIND_VALUES,
PERF_SUBJECT_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 @@ -181,7 +186,12 @@ export const clientCommandMetadata = [
}),
defineClientCommandMetadata('perf', {
area: enumField(PERF_AREA_VALUES),
subject: enumField(PERF_SUBJECT_VALUES),
action: enumField(PERF_ACTION_VALUES),
kind: enumField(PERF_KIND_VALUES),
template: stringField('xctrace template name, for example Time Profiler.'),
out: stringField('Output artifact path.'),
tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'),
}),
defineClientCommandMetadata('logs', {
action: enumField(LOG_ACTION_VALUES),
Expand Down
26 changes: 26 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 nativeOutput = formatNativePerfOutput(data);
if (nativeOutput) return nativeOutput;
const metrics = readRecord(data.metrics);
const fps = readRecord(metrics?.fps);
const resourceSummary = buildResourcePerfSummary(metrics);
Expand All @@ -169,6 +171,30 @@ function formatPerfCliOutput(data: Record<string, unknown>): string {
return lines.join('\n');
}

function formatNativePerfOutput(data: Record<string, unknown>): string | undefined {
const state = typeof data.perf === 'string' ? data.perf : undefined;
const outPath = readNativePerfArtifactPath(data);
if (!state || !outPath || data.kind !== 'xctrace') return undefined;
const mode = typeof data.mode === 'string' ? data.mode : 'capture';
return formatNativePerfLines(outPath, mode, state, data.template);
}

function readNativePerfArtifactPath(data: Record<string, unknown>): string | undefined {
if (typeof data.outPath === 'string') return data.outPath;
return typeof data.reportPath === 'string' ? data.reportPath : undefined;
}

function formatNativePerfLines(
outPath: string,
mode: string,
state: string,
template: unknown,
): string {
const lines = [outPath, `Perf ${mode}: ${state}`];
if (typeof template === 'string') lines.push(`Template: ${template}`);
return lines.join('\n');
}

function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string {
return resourceSummary
? `Performance: ${resourceSummary}`
Expand Down
20 changes: 16 additions & 4 deletions src/contracts/perf.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { defineStringEnum } from '../utils/string-enum.ts';

export const PERF_AREA_VALUES = ['metrics', 'frames'] as const;
export const PERF_ACTION_VALUES = ['sample'] as const;
export const PERF_AREA_VALUES = ['metrics', 'frames', 'cpu', 'trace'] as const;
export const PERF_ACTION_VALUES = ['sample', 'start', 'stop', 'report'] as const;
export const PERF_SUBJECT_VALUES = ['profile'] as const;
export const PERF_KIND_VALUES = ['xctrace'] as const;
const PERF_AREAS = defineStringEnum(PERF_AREA_VALUES);
const PERF_ACTIONS = defineStringEnum(PERF_ACTION_VALUES);
const PERF_SUBJECTS = defineStringEnum(PERF_SUBJECT_VALUES);
const PERF_KINDS = defineStringEnum(PERF_KIND_VALUES);

export type PerfArea = (typeof PERF_AREA_VALUES)[number];
export type PerfAction = (typeof PERF_ACTION_VALUES)[number];
export type PerfSubject = (typeof PERF_SUBJECT_VALUES)[number];
export type PerfKind = (typeof PERF_KIND_VALUES)[number];

export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics or frames';
export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample';
export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics, frames, cpu, or trace';
export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample, start, stop, or report';
export const PERF_SUBJECT_ERROR_MESSAGE = 'perf cpu requires profile';
export const PERF_KIND_ERROR_MESSAGE = 'perf native collection currently supports --kind xctrace';

export const isPerfArea = PERF_AREAS.is;

export const isPerfAction = PERF_ACTIONS.is;

export const isPerfSubject = PERF_SUBJECTS.is;

export const isPerfKind = PERF_KINDS.is;
Loading
Loading