Skip to content
Merged
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 @@ -170,6 +170,122 @@ test('perf forwards shared perf kind values through CLI parsing', async () => {
assert.equal(payload.error.code, 'INVALID_ARGS');
});

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 @@ -206,7 +322,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 @@ -216,7 +332,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, frames, or memory/i);
assert.match(payload.error.message, /perf cpu requires profile/i);
});

test('perf prints unavailable frame health reason by default', async () => {
Expand Down
5 changes: 4 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, PerfKind } 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,9 +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
96 changes: 84 additions & 12 deletions src/commands/cli-grammar/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ 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 @@ -33,9 +36,11 @@ import type { CliReader, DaemonWriter } from './types.ts';
export const observabilityCliReaders = {
perf: (positionals, flags) => ({
...commonInputFromFlags(flags),
...readPerfPositionals(positionals),
kind: readPerfKind(flags.kind),
out: flags.out,
...readPerfPositionals(positionals, {
kind: readPerfKindFlag(flags.kind),
template: flags.perfTemplate,
out: flags.out,
}),
}),
logs: (positionals, flags) => ({
...commonInputFromFlags(flags),
Expand Down Expand Up @@ -78,26 +83,76 @@ 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 };
if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out };
}
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]),
kind: readPerfKind(flags.kind),
out: flags.out,
};
}

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

function logsPositionals(input: { action?: string; message?: string }): string[] {
return [input.action ?? 'path', ...optionalString(input.message)];
}
Expand Down Expand Up @@ -133,6 +188,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: 10 additions & 2 deletions 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, PERF_KIND_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 @@ -182,9 +187,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),
out: stringField(),
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 artifact = readRecord(data.artifact);
if (artifact) {
return formatMemoryArtifactSummary(artifact);
Expand Down Expand Up @@ -190,6 +192,30 @@ function formatMemoryArtifactSummary(artifact: Record<string, unknown>): string
: `Memory artifact (${kind}): captured${sizeText}`;
}

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
Loading
Loading