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
116 changes: 113 additions & 3 deletions src/__tests__/cli-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,123 @@ test('perf prints compact platform-independent frame health summary by default',
assert.doesNotMatch(result.stdout, /android|Pixel|memory|cpu|gfxinfo/i);
});

test('perf metrics forwards explicit metrics area to daemon', async () => {
const result = await runCliCapture(['perf', 'metrics', '--json'], async () => ({
ok: true,
data: {
metrics: {
fps: {
available: false,
reason: 'No frame data.',
},
},
},
}));

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

test('perf frames forwards frames area and prints focused frame summary', async () => {
const result = await runCliCapture(['perf', 'frames'], async () => ({
ok: true,
data: {
metrics: {
fps: {
available: true,
droppedFramePercent: 3.1,
droppedFrameCount: 12,
totalFrameCount: 390,
sampleWindowMs: 12_000,
worstWindows: [],
},
},
},
}));

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['frames']);
assert.equal(result.stdout, 'Frame health: dropped 3.1% (12/390 frames) window 12s\n');
});

test('perf frames sample forwards explicit sample action to daemon', async () => {
const result = await runCliCapture(['perf', 'frames', 'sample', '--json'], async () => ({
ok: true,
data: {
metrics: {
fps: {
available: false,
reason: 'No frame data.',
},
},
},
}));

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']);
});

test('perf sample defaults to metrics sample', async () => {
const result = await runCliCapture(['perf', 'sample', '--json'], async () => ({
ok: true,
data: {
metrics: {
fps: {
available: false,
reason: 'No frame data.',
},
},
},
}));

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['metrics', 'sample']);
});

test('perf area and action positionals are case-insensitive', async () => {
const result = await runCliCapture(['perf', 'FRAMES', 'SAMPLE', '--json'], async () => ({
ok: true,
data: {
metrics: {
fps: {
available: false,
reason: 'No frame data.',
},
},
},
}));

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']);
});

test('perf rejects unknown CLI area before daemon dispatch', async () => {
const result = await runCliCapture(['perf', 'cpu', '--json'], async () => ({
ok: true,
data: {},
}));

assert.equal(result.code, 1);
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);
});

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.',
reason:
'Dropped-frame sampling is currently available only on Android app sessions and connected iOS device app sessions.',
},
},
},
Expand All @@ -63,7 +172,7 @@ test('perf prints unavailable frame health reason by default', async () => {
assert.equal(result.code, null);
assert.equal(
result.stdout,
'Frame health: unavailable - Dropped-frame sampling is currently available only on Android.\n',
'Frame health: unavailable - Dropped-frame sampling is currently available only on Android app sessions and connected iOS device app sessions.\n',
);
});

Expand All @@ -74,7 +183,8 @@ test('perf prints compact CPU and memory summary when frame health is unavailabl
metrics: {
fps: {
available: false,
reason: 'Dropped-frame sampling is currently available only on Android.',
reason:
'Dropped-frame sampling is currently available only on Android app sessions and connected iOS device app sessions.',
},
memory: {
available: true,
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,32 @@ test('apps.open forwards explicit runtime hints through the daemon request', asy
});
});

test('observability.perf projects structured frame area to daemon positionals', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'perf') {
return {
ok: true,
data: {
metrics: {
fps: {
available: false,
reason: 'No frame data.',
},
},
},
};
}
throw new Error(`Unexpected command: ${req.command}`);
});
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

await client.observability.perf({ area: 'frames', action: 'sample' });

assert.equal(setup.calls.length, 1);
assert.equal(setup.calls[0]?.command, 'perf');
assert.deepEqual(setup.calls[0]?.positionals, ['frames', 'sample']);
});

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
6 changes: 5 additions & 1 deletion src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts';
import type { AppsFilter } from './commands/app-inventory-contract.ts';
import type { ScreenshotRequestFlags } from './commands/capture-screenshot-options.ts';
import type { PerfAction, PerfArea } from './commands/perf-command-contract.ts';
import type { DaemonBatchStep } from './core/batch.ts';
import type { AlertInfo } from './alert-contract.ts';

Expand Down Expand Up @@ -728,7 +729,10 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & {
out?: string;
};

export type PerfOptions = ClientCommandBaseOptions;
export type PerfOptions = ClientCommandBaseOptions & {
area?: PerfArea;
action?: PerfAction;
};

export type LogsOptions = AgentDeviceRequestOverrides & {
action?: 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear';
Expand Down
56 changes: 53 additions & 3 deletions src/commands/cli-grammar/observability.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import type { LogsOptions, NetworkOptions, RecordOptions } from '../../client-types.ts';
import type {
LogsOptions,
NetworkOptions,
PerfOptions,
RecordOptions,
} from '../../client-types.ts';
import { AppError } from '../../utils/errors.ts';
import {
isPerfAction,
isPerfArea,
PERF_ACTION_ERROR_MESSAGE,
PERF_AREA_ERROR_MESSAGE,
type PerfAction,
type PerfArea,
} from '../perf-command-contract.ts';
import {
commonInputFromFlags,
direct,
Expand All @@ -12,7 +25,10 @@ import {
import type { CliReader, DaemonWriter } from './types.ts';

export const observabilityCliReaders = {
perf: (_positionals, flags) => commonInputFromFlags(flags),
perf: (positionals, flags) => ({
...commonInputFromFlags(flags),
...readPerfPositionals(positionals),
}),
logs: (positionals, flags) => ({
...commonInputFromFlags(flags),
action: readLogsAction(positionals[0]),
Expand Down Expand Up @@ -41,7 +57,7 @@ export const observabilityCliReaders = {
} satisfies Record<string, CliReader>;

export const observabilityDaemonWriters = {
perf: direct(PUBLIC_COMMANDS.perf),
perf: direct(PUBLIC_COMMANDS.perf, (input) => perfPositionals(input as PerfOptions)),
logs: direct(PUBLIC_COMMANDS.logs, (input) => logsPositionals(input as LogsOptions)),
network: (input) =>
request(PUBLIC_COMMANDS.network, networkPositionals(input as NetworkOptions), {
Expand All @@ -52,6 +68,22 @@ export const observabilityDaemonWriters = {
trace: direct(PUBLIC_COMMANDS.trace, (input) => recordingPositionals(input as RecordOptions)),
} satisfies Record<string, DaemonWriter>;

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

function readPerfPositionals(positionals: string[]): Pick<PerfOptions, 'area' | 'action'> {
if (positionals[0] !== undefined && positionals[1] === undefined) {
const action = readPerfAction(positionals[0], { allowUndefined: true });
if (action) return { action };
}
return {
area: readPerfArea(positionals[0]),
action: readPerfAction(positionals[1]),
};
}

function logsPositionals(input: { action?: string; message?: string }): string[] {
return [input.action ?? 'path', ...optionalString(input.message)];
}
Expand All @@ -69,6 +101,24 @@ 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 {
if (value === undefined) return undefined;
const normalized = value.toLowerCase();
if (isPerfArea(normalized)) return normalized;
throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE);
}

function readPerfAction(
value: string | undefined,
options: { allowUndefined?: boolean } = {},
): PerfAction | undefined {
if (value === undefined) return undefined;
const normalized = value.toLowerCase();
if (isPerfAction(normalized)) return normalized;
if (options.allowUndefined) return undefined;
throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE);
}

function readLogsAction(
value: string | undefined,
): 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear' | undefined {
Expand Down
6 changes: 5 additions & 1 deletion src/commands/client-command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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';

const SURFACE_VALUES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const;
const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const;
Expand Down Expand Up @@ -177,7 +178,10 @@ export const clientCommandMetadata = [
artifactsDir: stringField(),
reportJunit: stringField(),
}),
defineClientCommandMetadata('perf', {}),
defineClientCommandMetadata('perf', {
area: enumField(PERF_AREA_VALUES),
action: enumField(PERF_ACTION_VALUES),
}),
defineClientCommandMetadata('logs', {
action: enumField(LOG_ACTION_VALUES),
message: stringField(),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/command-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const COMMAND_DESCRIPTIONS = {
'react-native': 'Run supported React Native app automation helpers.',
replay: 'Replay a recorded session.',
test: 'Run one or more replay scripts.',
perf: 'Show session performance metrics.',
perf: 'Show session performance metrics and frame health.',
logs: 'Manage session app logs.',
network: 'Show recent HTTP traffic.',
record: 'Start or stop screen recording.',
Expand Down
16 changes: 16 additions & 0 deletions src/commands/perf-command-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const PERF_AREA_VALUES = ['metrics', 'frames'] as const;
export const PERF_ACTION_VALUES = ['sample'] as const;

export type PerfArea = (typeof PERF_AREA_VALUES)[number];
export type PerfAction = (typeof PERF_ACTION_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 function isPerfArea(value: string): value is PerfArea {
return (PERF_AREA_VALUES as readonly string[]).includes(value);
}

export function isPerfAction(value: string): value is PerfAction {
return (PERF_ACTION_VALUES as readonly string[]).includes(value);
}
Loading
Loading