Skip to content

Commit 81448c8

Browse files
authored
feat: add perf metrics and frames commands (#703)
* feat: add perf metrics and frames commands * fix: tighten perf command ergonomics * test: move perf area coverage to provider integration
1 parent 8697199 commit 81448c8

18 files changed

Lines changed: 537 additions & 121 deletions

src/__tests__/cli-perf.test.ts

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,123 @@ test('perf prints compact platform-independent frame health summary by default',
4747
assert.doesNotMatch(result.stdout, /android|Pixel|memory|cpu|gfxinfo/i);
4848
});
4949

50+
test('perf metrics forwards explicit metrics area to daemon', async () => {
51+
const result = await runCliCapture(['perf', 'metrics', '--json'], async () => ({
52+
ok: true,
53+
data: {
54+
metrics: {
55+
fps: {
56+
available: false,
57+
reason: 'No frame data.',
58+
},
59+
},
60+
},
61+
}));
62+
63+
assert.equal(result.code, null);
64+
assert.equal(result.calls[0]?.command, 'perf');
65+
assert.deepEqual(result.calls[0]?.positionals, ['metrics']);
66+
});
67+
68+
test('perf frames forwards frames area and prints focused frame summary', async () => {
69+
const result = await runCliCapture(['perf', 'frames'], async () => ({
70+
ok: true,
71+
data: {
72+
metrics: {
73+
fps: {
74+
available: true,
75+
droppedFramePercent: 3.1,
76+
droppedFrameCount: 12,
77+
totalFrameCount: 390,
78+
sampleWindowMs: 12_000,
79+
worstWindows: [],
80+
},
81+
},
82+
},
83+
}));
84+
85+
assert.equal(result.code, null);
86+
assert.equal(result.calls[0]?.command, 'perf');
87+
assert.deepEqual(result.calls[0]?.positionals, ['frames']);
88+
assert.equal(result.stdout, 'Frame health: dropped 3.1% (12/390 frames) window 12s\n');
89+
});
90+
91+
test('perf frames sample forwards explicit sample action to daemon', async () => {
92+
const result = await runCliCapture(['perf', 'frames', 'sample', '--json'], async () => ({
93+
ok: true,
94+
data: {
95+
metrics: {
96+
fps: {
97+
available: false,
98+
reason: 'No frame data.',
99+
},
100+
},
101+
},
102+
}));
103+
104+
assert.equal(result.code, null);
105+
assert.equal(result.calls[0]?.command, 'perf');
106+
assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']);
107+
});
108+
109+
test('perf sample defaults to metrics sample', async () => {
110+
const result = await runCliCapture(['perf', 'sample', '--json'], async () => ({
111+
ok: true,
112+
data: {
113+
metrics: {
114+
fps: {
115+
available: false,
116+
reason: 'No frame data.',
117+
},
118+
},
119+
},
120+
}));
121+
122+
assert.equal(result.code, null);
123+
assert.equal(result.calls[0]?.command, 'perf');
124+
assert.deepEqual(result.calls[0]?.positionals, ['metrics', 'sample']);
125+
});
126+
127+
test('perf area and action positionals are case-insensitive', async () => {
128+
const result = await runCliCapture(['perf', 'FRAMES', 'SAMPLE', '--json'], async () => ({
129+
ok: true,
130+
data: {
131+
metrics: {
132+
fps: {
133+
available: false,
134+
reason: 'No frame data.',
135+
},
136+
},
137+
},
138+
}));
139+
140+
assert.equal(result.code, null);
141+
assert.equal(result.calls[0]?.command, 'perf');
142+
assert.deepEqual(result.calls[0]?.positionals, ['frames', 'sample']);
143+
});
144+
145+
test('perf rejects unknown CLI area before daemon dispatch', async () => {
146+
const result = await runCliCapture(['perf', 'cpu', '--json'], async () => ({
147+
ok: true,
148+
data: {},
149+
}));
150+
151+
assert.equal(result.code, 1);
152+
assert.equal(result.calls.length, 0);
153+
const payload = JSON.parse(result.stdout);
154+
assert.equal(payload.error.code, 'INVALID_ARGS');
155+
assert.match(payload.error.message, /perf area must be metrics or frames/i);
156+
});
157+
50158
test('perf prints unavailable frame health reason by default', async () => {
51159
const result = await runCliCapture(['perf'], async () => ({
52160
ok: true,
53161
data: {
54162
metrics: {
55163
fps: {
56164
available: false,
57-
reason: 'Dropped-frame sampling is currently available only on Android.',
165+
reason:
166+
'Dropped-frame sampling is currently available only on Android app sessions and connected iOS device app sessions.',
58167
},
59168
},
60169
},
@@ -63,7 +172,7 @@ test('perf prints unavailable frame health reason by default', async () => {
63172
assert.equal(result.code, null);
64173
assert.equal(
65174
result.stdout,
66-
'Frame health: unavailable - Dropped-frame sampling is currently available only on Android.\n',
175+
'Frame health: unavailable - Dropped-frame sampling is currently available only on Android app sessions and connected iOS device app sessions.\n',
67176
);
68177
});
69178

@@ -74,7 +183,8 @@ test('perf prints compact CPU and memory summary when frame health is unavailabl
74183
metrics: {
75184
fps: {
76185
available: false,
77-
reason: 'Dropped-frame sampling is currently available only on Android.',
186+
reason:
187+
'Dropped-frame sampling is currently available only on Android app sessions and connected iOS device app sessions.',
78188
},
79189
memory: {
80190
available: true,

src/__tests__/client.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,32 @@ test('apps.open forwards explicit runtime hints through the daemon request', asy
119119
});
120120
});
121121

122+
test('observability.perf projects structured frame area to daemon positionals', async () => {
123+
const setup = createTransport(async (req) => {
124+
if (req.command === 'perf') {
125+
return {
126+
ok: true,
127+
data: {
128+
metrics: {
129+
fps: {
130+
available: false,
131+
reason: 'No frame data.',
132+
},
133+
},
134+
},
135+
};
136+
}
137+
throw new Error(`Unexpected command: ${req.command}`);
138+
});
139+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
140+
141+
await client.observability.perf({ area: 'frames', action: 'sample' });
142+
143+
assert.equal(setup.calls.length, 1);
144+
assert.equal(setup.calls[0]?.command, 'perf');
145+
assert.deepEqual(setup.calls[0]?.positionals, ['frames', 'sample']);
146+
});
147+
122148
test('structured command input accepts target as deviceTarget alias when no UI target exists', async () => {
123149
const setup = createTransport(async (req) => {
124150
if (req.command === 'open') {

src/client-types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
import type { MetroBridgeScope } from './client-companion-tunnel-contract.ts';
2525
import type { AppsFilter } from './commands/app-inventory-contract.ts';
2626
import type { ScreenshotRequestFlags } from './commands/capture-screenshot-options.ts';
27+
import type { PerfAction, PerfArea } from './commands/perf-command-contract.ts';
2728
import type { DaemonBatchStep } from './core/batch.ts';
2829
import type { AlertInfo } from './alert-contract.ts';
2930

@@ -732,7 +733,10 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & {
732733
out?: string;
733734
};
734735

735-
export type PerfOptions = ClientCommandBaseOptions;
736+
export type PerfOptions = ClientCommandBaseOptions & {
737+
area?: PerfArea;
738+
action?: PerfAction;
739+
};
736740

737741
export type LogsOptions = AgentDeviceRequestOverrides & {
738742
action?: 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear';

src/commands/cli-grammar/observability.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
2-
import type { LogsOptions, NetworkOptions, RecordOptions } from '../../client-types.ts';
2+
import type {
3+
LogsOptions,
4+
NetworkOptions,
5+
PerfOptions,
6+
RecordOptions,
7+
} from '../../client-types.ts';
38
import { AppError } from '../../utils/errors.ts';
9+
import {
10+
isPerfAction,
11+
isPerfArea,
12+
PERF_ACTION_ERROR_MESSAGE,
13+
PERF_AREA_ERROR_MESSAGE,
14+
type PerfAction,
15+
type PerfArea,
16+
} from '../perf-command-contract.ts';
417
import {
518
commonInputFromFlags,
619
direct,
@@ -12,7 +25,10 @@ import {
1225
import type { CliReader, DaemonWriter } from './types.ts';
1326

1427
export const observabilityCliReaders = {
15-
perf: (_positionals, flags) => commonInputFromFlags(flags),
28+
perf: (positionals, flags) => ({
29+
...commonInputFromFlags(flags),
30+
...readPerfPositionals(positionals),
31+
}),
1632
logs: (positionals, flags) => ({
1733
...commonInputFromFlags(flags),
1834
action: readLogsAction(positionals[0]),
@@ -41,7 +57,7 @@ export const observabilityCliReaders = {
4157
} satisfies Record<string, CliReader>;
4258

4359
export const observabilityDaemonWriters = {
44-
perf: direct(PUBLIC_COMMANDS.perf),
60+
perf: direct(PUBLIC_COMMANDS.perf, (input) => perfPositionals(input as PerfOptions)),
4561
logs: direct(PUBLIC_COMMANDS.logs, (input) => logsPositionals(input as LogsOptions)),
4662
network: (input) =>
4763
request(PUBLIC_COMMANDS.network, networkPositionals(input as NetworkOptions), {
@@ -52,6 +68,22 @@ export const observabilityDaemonWriters = {
5268
trace: direct(PUBLIC_COMMANDS.trace, (input) => recordingPositionals(input as RecordOptions)),
5369
} satisfies Record<string, DaemonWriter>;
5470

71+
function perfPositionals(input: PerfOptions): string[] {
72+
const area = input.area ?? (input.action ? 'metrics' : undefined);
73+
return [...optionalString(area), ...optionalString(input.action)];
74+
}
75+
76+
function readPerfPositionals(positionals: string[]): Pick<PerfOptions, 'area' | 'action'> {
77+
if (positionals[0] !== undefined && positionals[1] === undefined) {
78+
const action = readPerfAction(positionals[0], { allowUndefined: true });
79+
if (action) return { action };
80+
}
81+
return {
82+
area: readPerfArea(positionals[0]),
83+
action: readPerfAction(positionals[1]),
84+
};
85+
}
86+
5587
function logsPositionals(input: { action?: string; message?: string }): string[] {
5688
return [input.action ?? 'path', ...optionalString(input.message)];
5789
}
@@ -69,6 +101,24 @@ function readStartStop(value: string | undefined, command: string): 'start' | 's
69101
throw new AppError('INVALID_ARGS', `${command} requires start|stop`);
70102
}
71103

104+
function readPerfArea(value: string | undefined): PerfArea | undefined {
105+
if (value === undefined) return undefined;
106+
const normalized = value.toLowerCase();
107+
if (isPerfArea(normalized)) return normalized;
108+
throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE);
109+
}
110+
111+
function readPerfAction(
112+
value: string | undefined,
113+
options: { allowUndefined?: boolean } = {},
114+
): PerfAction | undefined {
115+
if (value === undefined) return undefined;
116+
const normalized = value.toLowerCase();
117+
if (isPerfAction(normalized)) return normalized;
118+
if (options.allowUndefined) return undefined;
119+
throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE);
120+
}
121+
72122
function readLogsAction(
73123
value: string | undefined,
74124
): 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear' | undefined {

src/commands/client-command-metadata.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type CommandFieldMap,
1919
} from './command-input.ts';
2020
import { defineFieldCommandMetadata } from './field-command-contract.ts';
21+
import { PERF_ACTION_VALUES, PERF_AREA_VALUES } from './perf-command-contract.ts';
2122

2223
const SURFACE_VALUES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const;
2324
const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const;
@@ -177,7 +178,10 @@ export const clientCommandMetadata = [
177178
artifactsDir: stringField(),
178179
reportJunit: stringField(),
179180
}),
180-
defineClientCommandMetadata('perf', {}),
181+
defineClientCommandMetadata('perf', {
182+
area: enumField(PERF_AREA_VALUES),
183+
action: enumField(PERF_ACTION_VALUES),
184+
}),
181185
defineClientCommandMetadata('logs', {
182186
action: enumField(LOG_ACTION_VALUES),
183187
message: stringField(),

src/commands/command-descriptions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const COMMAND_DESCRIPTIONS = {
2626
'react-native': 'Run supported React Native app automation helpers.',
2727
replay: 'Replay a recorded session.',
2828
test: 'Run one or more replay scripts.',
29-
perf: 'Show session performance metrics.',
29+
perf: 'Show session performance metrics and frame health.',
3030
logs: 'Manage session app logs.',
3131
network: 'Show recent HTTP traffic.',
3232
record: 'Start or stop screen recording.',
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const PERF_AREA_VALUES = ['metrics', 'frames'] as const;
2+
export const PERF_ACTION_VALUES = ['sample'] as const;
3+
4+
export type PerfArea = (typeof PERF_AREA_VALUES)[number];
5+
export type PerfAction = (typeof PERF_ACTION_VALUES)[number];
6+
7+
export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics or frames';
8+
export const PERF_ACTION_ERROR_MESSAGE = 'perf action must be sample';
9+
10+
export function isPerfArea(value: string): value is PerfArea {
11+
return (PERF_AREA_VALUES as readonly string[]).includes(value);
12+
}
13+
14+
export function isPerfAction(value: string): value is PerfAction {
15+
return (PERF_ACTION_VALUES as readonly string[]).includes(value);
16+
}

0 commit comments

Comments
 (0)