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
82 changes: 82 additions & 0 deletions src/__tests__/cli-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,88 @@ test('perf rejects incomplete native CLI area before daemon dispatch', async ()
assert.match(payload.error.message, /perf cpu requires profile/i);
});

test('perf rejects unknown CLI area before daemon dispatch', async () => {
const result = await runCliCapture(['perf', 'gpu', '--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, frames, memory, cpu, or trace/i);
});

test('perf cpu profile start forwards simpleperf kind and out path', async () => {
const result = await runCliCapture(
['perf', 'cpu', 'profile', 'start', '--kind', 'simpleperf', '--out', 'cpu.perf.data', '--json'],
async () => ({
ok: true,
data: {
action: 'start',
type: 'cpu-profile',
kind: 'simpleperf',
state: 'running',
},
}),
);

assert.equal(result.code, null);
const call = result.calls[0];
assert.ok(call);
assert.equal(call.command, 'perf');
assert.deepEqual(call.positionals, [
'cpu',
'profile',
'start',
'simpleperf',
'',
'cpu.perf.data',
]);
assert.ok(call.flags);
assert.equal(call.flags.out, 'cpu.perf.data');
});

test('perf trace stop forwards perfetto kind and prints compact artifact summary', async () => {
const result = await runCliCapture(
['perf', 'trace', 'stop', '--kind', 'perfetto', '--out', 'app.perfetto-trace'],
async () => ({
ok: true,
data: {
action: 'stop',
type: 'trace',
kind: 'perfetto',
state: 'stopped',
outPath: '/tmp/app.perfetto-trace',
sizeBytes: 2048,
summary: {
frameHealth: {
available: true,
droppedFramePercent: 12.5,
droppedFrameCount: 3,
totalFrameCount: 24,
},
},
},
}),
);

assert.equal(result.code, null);
assert.equal(result.calls[0]?.command, 'perf');
assert.deepEqual(result.calls[0]?.positionals, [
'trace',
'stop',
'perfetto',
'',
'app.perfetto-trace',
]);
assert.equal(
result.stdout,
'Perf stop: perfetto trace state=stopped\n/tmp/app.perfetto-trace (2.0KB)\nTrace frame health: dropped 12.5% (3/24 frames)\n',
);
});

test('perf prints unavailable frame health reason by default', async () => {
const result = await runCliCapture(['perf'], async () => ({
ok: true,
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,47 @@ test('observability.perf projects memory snapshot options to daemon flags', asyn
assert.equal(setup.calls[0]?.flags?.out, 'app.memgraph');
});

test('observability.perf projects structured Android native profile input to daemon positionals', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'perf') {
return {
ok: true,
data: {
action: 'start',
type: 'cpu-profile',
kind: 'simpleperf',
state: 'running',
},
};
}
throw new Error(`Unexpected command: ${req.command}`);
});
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

await client.observability.perf({
area: 'cpu',
subject: 'profile',
action: 'start',
kind: 'simpleperf',
out: 'cpu.perf.data',
});

assert.equal(setup.calls.length, 1);
const call = setup.calls[0];
assert.ok(call);
assert.equal(call.command, 'perf');
assert.deepEqual(call.positionals, [
'cpu',
'profile',
'start',
'simpleperf',
'',
'cpu.perf.data',
]);
assert.ok(call.flags);
assert.equal(call.flags.out, 'cpu.perf.data');
});

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
66 changes: 62 additions & 4 deletions src/commands/runtime-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ function formatMemoryArtifactSummary(artifact: Record<string, unknown>): string
}

function formatNativePerfOutput(data: Record<string, unknown>): string | undefined {
if (data.kind === 'xctrace') return formatAppleNativePerfOutput(data);
return formatAndroidNativePerfOutput(data);
}

function formatAppleNativePerfOutput(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;
Expand All @@ -216,6 +221,58 @@ function formatNativePerfLines(
return lines.join('\n');
}

function formatAndroidNativePerfOutput(data: Record<string, unknown>): string | undefined {
const summary = readNativePerfSummary(data);
if (!summary) return undefined;
return `Perf ${summary.action}: ${summary.kind} ${summary.type}${formatNativePerfState(
data,
)}${formatNativePerfArtifact(data)}${formatNativePerfFrameHealth(data)}`;
}

function readNativePerfSummary(
data: Record<string, unknown>,
): { action: string; kind: string; type: string } | undefined {
const action = readString(data.action);
const kind = readString(data.kind);
const type = readString(data.type);
return action && kind && type ? { action, kind, type } : undefined;
}

function formatNativePerfState(data: Record<string, unknown>): string {
const state = readString(data.state);
return state ? ` state=${state}` : '';
}

function formatNativePerfArtifact(data: Record<string, unknown>): string {
const outPath = readString(data.outPath);
if (!outPath) return '';
const sizeBytes = readFiniteNumber(data.sizeBytes);
return `\n${outPath}${sizeBytes !== undefined ? ` (${formatBytes(sizeBytes)})` : ''}`;
}

function readString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

function formatNativePerfFrameHealth(data: Record<string, unknown>): string {
const summary = readRecord(data.summary);
const frameHealth = readRecord(summary?.frameHealth);
if (!frameHealth || frameHealth.available !== true) return '';
const droppedFramePercent = readFiniteNumber(frameHealth.droppedFramePercent);
const droppedFrameCount = readFiniteNumber(frameHealth.droppedFrameCount);
const totalFrameCount = readFiniteNumber(frameHealth.totalFrameCount);
if (
droppedFramePercent === undefined ||
droppedFrameCount === undefined ||
totalFrameCount === undefined
) {
return '';
}
return `\nTrace frame health: dropped ${formatPercent(droppedFramePercent)} (${Math.round(
droppedFrameCount,
)}/${Math.round(totalFrameCount)} frames)`;
}

function formatPerfUnavailable(resourceSummary: string | undefined, reason: string): string {
return resourceSummary
? `Performance: ${resourceSummary}`
Expand Down Expand Up @@ -333,8 +390,9 @@ function formatMemoryKb(value: number): string {
}

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`;
if (value < 1024) return `${Math.round(value)}B`;
const kib = value / 1024;
if (kib < 1024) return `${kib >= 10 ? Math.round(kib) : kib.toFixed(1)}KB`;
const mib = kib / 1024;
return `${mib >= 10 ? Math.round(mib) : mib.toFixed(1)}MB`;
}
87 changes: 87 additions & 0 deletions src/daemon/handlers/__tests__/session-close-shutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/perf-xctrace.ts')>();
return { ...actual, cleanupAppleXctracePerfCapture: vi.fn(async () => ({})) };
});
vi.mock('../../../platforms/android/perf.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/android/perf.ts')>();
return { ...actual, cleanupAndroidNativePerfSession: vi.fn(async () => {}) };
});
vi.mock('../../../platforms/ios/macos-helper.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/macos-helper.ts')>();
return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) };
Expand All @@ -44,10 +48,12 @@ import { teardownSessionResources } from '../session-close.ts';
import { shutdownSimulator } from '../../../platforms/ios/simulator.ts';
import { runCmd } from '../../../utils/exec.ts';
import { cleanupAppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts';
import { cleanupAndroidNativePerfSession } from '../../../platforms/android/perf.ts';

const mockShutdownSimulator = vi.mocked(shutdownSimulator);
const mockRunCmd = vi.mocked(runCmd);
const mockCleanupAppleXctracePerfCapture = vi.mocked(cleanupAppleXctracePerfCapture);
const mockCleanupAndroidNativePerfSession = vi.mocked(cleanupAndroidNativePerfSession);

const noopInvoke = async (_req: DaemonRequest): Promise<DaemonResponse> => ({ ok: true, data: {} });

Expand Down Expand Up @@ -291,6 +297,87 @@ test('daemon session teardown stops active Apple xctrace perf capture', async ()
expect(session.applePerf?.active).toBeUndefined();
});

test('close stops active Android native perf capture before deleting session', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-active-native-perf-session';
const activeCapture = {
type: 'trace',
kind: 'perfetto',
packageName: 'com.example.app',
appPid: '1234',
profilerPid: '5678',
remotePath: '/data/misc/perfetto-traces/app.perfetto-trace',
outPath: '/tmp/app.perfetto-trace',
startedAt: Date.now(),
state: 'running',
};
const session = {
...makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
}),
appBundleId: 'com.example.app',
nativePerf: {
android: activeCapture,
},
} as unknown as SessionState;
sessionStore.set(sessionName, session);

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'close',
positionals: [],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response?.ok).toBe(true);
expect(mockCleanupAndroidNativePerfSession).toHaveBeenCalledWith(session.device, activeCapture);
expect(sessionStore.get(sessionName)).toBeUndefined();
});

test('daemon session teardown stops active Android native perf capture', async () => {
const sessionName = 'android-active-native-perf-teardown-session';
const activeCapture = {
type: 'cpu-profile',
kind: 'simpleperf',
packageName: 'com.example.app',
appPid: '1234',
profilerPid: '5678',
remotePath: '/data/local/tmp/cpu.perf.data',
outPath: '/tmp/cpu.perf.data',
startedAt: Date.now(),
state: 'running',
};
const session = {
...makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
}),
appBundleId: 'com.example.app',
nativePerf: {
android: activeCapture,
},
} as unknown as SessionState;

await teardownSessionResources(session, sessionName);

expect(mockCleanupAndroidNativePerfSession).toHaveBeenCalledWith(session.device, activeCapture);
expect(session.nativePerf?.android).toBeUndefined();
});

test('close --shutdown is ignored for Android devices', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-device-shutdown-session';
Expand Down
Loading
Loading