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

test('perf memory sample forwards memory area and prints compact memory summary', async () => {
const result = await runCliCapture(['perf', 'memory', 'sample'], async () => ({
ok: true,
data: {
metrics: {
memory: {
available: true,
totalPssKb: 216524,
topConsumers: [{ name: 'Dalvik Heap', pssKb: 120000 }],
},
},
},
}));

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['memory', 'sample']);
assert.equal(result.stdout, 'Performance: memory 211MB\n');
});

test('perf memory snapshot forwards kind and output path and prints artifact summary', async () => {
const result = await runCliCapture(
['perf', 'memory', 'snapshot', '--kind', 'android-hprof', '--out', 'heap.hprof'],
async () => ({
ok: true,
data: {
artifact: {
available: true,
kind: 'android-hprof',
path: '/tmp/heap.hprof',
sizeBytes: 2_500_000,
},
},
}),
);

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['memory', 'snapshot']);
assert.equal(result.calls[0]?.flags?.kind, 'android-hprof');
assert.equal(result.calls[0]?.flags?.out, 'heap.hprof');
assert.equal(result.stdout, 'Memory artifact (android-hprof): /tmp/heap.hprof (2.4MB)\n');
});

test('perf forwards shared perf kind values through CLI parsing', async () => {
const result = await runCliCapture(
['perf', 'memory', 'snapshot', '--kind', 'perfetto', '--json'],
async () => ({
ok: false,
error: {
code: 'INVALID_ARGS',
message: 'perf memory snapshot --kind must be android-hprof or memgraph',
},
}),
);

assert.equal(result.code, 1);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, ['memory', 'snapshot']);
assert.equal(result.calls[0]?.flags?.kind, 'perfetto');
const payload = JSON.parse(result.stdout);
assert.equal(payload.error.code, 'INVALID_ARGS');
});

test('perf sample defaults to metrics sample', async () => {
const result = await runCliCapture(['perf', 'sample', '--json'], async () => ({
ok: true,
Expand Down Expand Up @@ -152,7 +216,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 area must be metrics, frames, or memory/i);
});

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

test('observability.perf projects memory snapshot options to daemon flags', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'perf') {
return {
ok: true,
data: {
artifact: {
available: true,
kind: 'memgraph',
path: '/tmp/app.memgraph',
},
},
};
}
throw new Error(`Unexpected command: ${req.command}`);
});
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

await client.observability.perf({
area: 'memory',
action: 'snapshot',
kind: 'memgraph',
out: 'app.memgraph',
});

assert.equal(setup.calls.length, 1);
assert.equal(setup.calls[0]?.command, 'perf');
assert.deepEqual(setup.calls[0]?.positionals, ['memory', 'snapshot']);
assert.equal(setup.calls[0]?.flags?.kind, 'memgraph');
assert.equal(setup.calls[0]?.flags?.out, 'app.memgraph');
});

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
26 changes: 25 additions & 1 deletion src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
connectionCommand,
disconnectCommand,
} from '../cli/commands/connection.ts';
import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts';
import {
hasDeferredMetroConfig,
materializeRemoteConnectionForCommand,
} from '../cli/commands/connection-runtime.ts';
import { stopMetroCompanion } from '../client-metro-companion.ts';
import { AppError } from '../utils/errors.ts';
import {
Expand All @@ -37,6 +40,27 @@ const unexpectedCommandCall = async (): Promise<never> => {
throw new Error('unexpected call');
};

test('deferred Metro config ignores perf-style kind values', () => {
assert.equal(
hasDeferredMetroConfig({
json: true,
help: false,
version: false,
kind: 'memgraph',
}),
false,
);
assert.equal(
hasDeferredMetroConfig({
json: true,
help: false,
version: false,
metroKind: 'expo',
}),
true,
);
});

function createThrowingMethodGroup<T extends object>(methods: Partial<T> = {}): T {
return new Proxy(methods, {
get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall,
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const DEFAULT_CLI_DEPS: CliDeps = {

const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set<FlagKey>([
'launchUrl',
'kind',
'metroBearerToken',
'metroKind',
'metroListenHost',
Expand Down
15 changes: 10 additions & 5 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AppError } from '../../utils/errors.ts';
import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts';
import type { CliFlags } from '../../utils/cli-flags.ts';
import type { AgentDeviceClient, Lease } from '../../client.ts';
import type { MetroPrepareKind } from '../../client-metro.ts';

const leaseDeferredCommands = new Set([
'connect',
Expand Down Expand Up @@ -195,7 +196,7 @@ async function prepareConnectedMetro(
}
const prepared = await client.metro.prepare({
projectRoot: flags.metroProjectRoot,
kind: flags.metroKind,
kind: readDeferredMetroKind(flags.metroKind),
publicBaseUrl: flags.metroPublicBaseUrl,
proxyBaseUrl: flags.metroProxyBaseUrl,
bearerToken: flags.metroBearerToken,
Expand Down Expand Up @@ -304,11 +305,9 @@ function shouldPrepareRuntimeForCommand(command: string, batchSteps?: BatchStep[
}

export function hasDeferredMetroConfig(flags: CliFlags): boolean {
const metroKind = flags.metroKind;
return Boolean(
flags.metroPublicBaseUrl ||
flags.metroProxyBaseUrl ||
flags.metroProjectRoot ||
flags.metroKind,
flags.metroPublicBaseUrl || flags.metroProxyBaseUrl || flags.metroProjectRoot || metroKind,
);
}

Expand All @@ -322,6 +321,12 @@ function isRuntimeCompatibleWithPlatform(
return runtime.platform === platform;
}

function readDeferredMetroKind(value: string | undefined): MetroPrepareKind | undefined {
if (value === undefined) return undefined;
if (value === 'auto' || value === 'react-native' || value === 'expo') return value;
throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo');
}

function isSameMetroCleanup(
left: RemoteConnectionState['metro'] | undefined,
right: RemoteConnectionState['metro'] | undefined,
Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
snapshotForceFull: options.forceFull,
...screenshotFlagsFromOptions(options),
appsFilter: options.appsFilter,
kind: options.kind,
out: options.out,
count: options.count,
fps: options.fps,
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 } from './contracts/perf.ts';
import type { PerfAction, PerfArea, PerfKind } from './contracts/perf.ts';
import type { DaemonBatchStep } from './core/batch.ts';
import type { AlertAction, AlertInfo } from './alert-contract.ts';

Expand Down Expand Up @@ -742,6 +742,8 @@ export type BatchRunOptions = AgentDeviceRequestOverrides & {
export type PerfOptions = DeviceCommandBaseOptions & {
area?: PerfArea;
action?: PerfAction;
kind?: PerfKind;
out?: string;
};

export type LogsOptions = AgentDeviceRequestOverrides & {
Expand Down Expand Up @@ -830,6 +832,7 @@ export type SettingsUpdateOptions =

type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
positionals?: string[];
kind?: string;
out?: string;
interactiveOnly?: boolean;
compact?: boolean;
Expand Down
9 changes: 8 additions & 1 deletion src/commands/cli-grammar/metro.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppError } from '../../utils/errors.ts';
import type { MetroPrepareKind } from '../../client-metro.ts';
import type { CliReader } from './types.ts';

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

function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined {
if (value === undefined) return undefined;
if (value === 'auto' || value === 'react-native' || value === 'expo') return value;
throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo');
}
11 changes: 11 additions & 0 deletions src/commands/cli-grammar/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts';
import {
isPerfAction,
isPerfArea,
isPerfKind,
PERF_ACTION_ERROR_MESSAGE,
PERF_AREA_ERROR_MESSAGE,
PERF_KIND_ERROR_MESSAGE,
type PerfAction,
type PerfArea,
type PerfKind,
} from '../perf-command-contract.ts';
import {
commonInputFromFlags,
Expand All @@ -31,6 +34,8 @@ export const observabilityCliReaders = {
perf: (positionals, flags) => ({
...commonInputFromFlags(flags),
...readPerfPositionals(positionals),
kind: readPerfKind(flags.kind),
out: flags.out,
}),
logs: (positionals, flags) => ({
...commonInputFromFlags(flags),
Expand Down Expand Up @@ -87,6 +92,12 @@ function readPerfPositionals(positionals: string[]): Pick<PerfOptions, 'area' |
};
}

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
4 changes: 3 additions & 1 deletion src/commands/client-command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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';
import { PERF_ACTION_VALUES, PERF_AREA_VALUES, PERF_KIND_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 @@ -185,6 +185,8 @@ export const clientCommandMetadata = [
defineClientCommandMetadata('perf', {
area: enumField(PERF_AREA_VALUES),
action: enumField(PERF_ACTION_VALUES),
kind: enumField(PERF_KIND_VALUES),
out: stringField(),
}),
defineClientCommandMetadata('logs', {
action: enumField(LOG_ACTION_VALUES),
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 @@ -27,7 +27,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 and frame health.',
perf: 'Show session performance, frame health, and memory diagnostics.',
logs: 'Manage session app logs.',
network: 'Show recent HTTP traffic.',
record: 'Start or stop screen recording.',
Expand Down
28 changes: 28 additions & 0 deletions src/commands/runtime-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ function joinDefinedLines(lines: Array<string | undefined>): string | undefined
}

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

function formatMemoryArtifactSummary(artifact: Record<string, unknown>): string {
const kind = typeof artifact.kind === 'string' ? artifact.kind : 'memory';
if (artifact.available === false) {
const reason =
typeof artifact.reason === 'string' && artifact.reason.length > 0
? artifact.reason
: 'not available';
return `Memory artifact (${kind}): unavailable - ${reason}`;
}
const artifactPath = typeof artifact.path === 'string' ? artifact.path : undefined;
const sizeBytes = readFiniteNumber(artifact.sizeBytes);
const sizeText = sizeBytes === undefined ? '' : ` (${formatBytes(sizeBytes)})`;
return artifactPath
? `Memory artifact (${kind}): ${artifactPath}${sizeText}`
: `Memory artifact (${kind}): captured${sizeText}`;
}

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

function formatBytes(value: number): string {
const megabytes = value / 1024 / 1024;
if (megabytes >= 10) return `${Math.round(megabytes)}MB`;
if (megabytes >= 1) return `${megabytes.toFixed(1)}MB`;
return `${Math.max(1, Math.round(value / 1024))}KB`;
}
Loading
Loading