diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index dd380a793..0bd732989 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -852,3 +852,27 @@ test('capture.screenshot normalizes the default-level result (unchanged)', async assert.equal(result.path, '/tmp/shot.png'); assert.deepEqual(result.identifiers, { session: 'qa' }); }); + +test('capture.snapshot passes a digest (non-default level) payload through unnormalized', async () => { + const digest = { + nodeCount: 3, + refs: [{ ref: 'e1', label: 'Login' }], + truncated: false, + }; + const setup = createTransport(async (req) => { + assert.equal(req.command, 'snapshot'); + assert.equal(req.meta?.responseLevel, 'digest'); // the level reached the daemon + return { ok: true, data: digest }; + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot({ responseLevel: 'digest' }); + + // The digest SURVIVES the client path: nodeCount/refs are preserved and the + // default normalizer (which expects `nodes` and would yield an empty snapshot + // plus `identifiers`) is skipped. + const asRecord = result as Record; + assert.deepEqual(asRecord, digest); + assert.equal(asRecord.nodeCount, 3); + assert.ok(!('identifiers' in asRecord)); +}); diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts new file mode 100644 index 000000000..3873e3009 --- /dev/null +++ b/src/cli/commands/__tests__/generic.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { createAgentDeviceClient } from '../../../client.ts'; +import type { DaemonResponse } from '../../../contracts.ts'; +import type { CliFlags } from '../../../utils/cli-flags.ts'; +import type { ClientBackedCliCommandName } from '../../../command-catalog.ts'; +import { runGenericClientBackedCommand } from '../generic.ts'; + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await fn(); + } finally { + process.stdout.write = original; + } + return chunks.join(''); +} + +test('snapshot --level digest --json preserves the digest through the generic CLI path', async () => { + const digest = { nodeCount: 3, refs: [{ ref: 'e1', label: 'Login' }], truncated: false }; + const client = createAgentDeviceClient( + { session: 'qa', responseLevel: 'digest' }, + { + transport: async (req): Promise => { + assert.equal(req.command, 'snapshot'); + return { ok: true, data: digest }; + }, + }, + ); + const flags = { json: true, responseLevel: 'digest' } as CliFlags; + + const out = await captureStdout(() => + runGenericClientBackedCommand({ + command: 'snapshot' as ClientBackedCliCommandName, + positionals: [], + flags, + client, + }), + ); + const parsed = JSON.parse(out) as { success: boolean; data: Record }; + + assert.equal(parsed.success, true); + // nodeCount/refs — the digest fields — are preserved, not collapsed by the + // snapshot formatter that expects `nodes`. + assert.deepEqual(parsed.data, digest); +}); diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 82665dd1d..aa5dc3758 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -6,6 +6,7 @@ import type { CliOutput } from '../../commands/command-contract.ts'; import type { ReplaySuiteResult } from '../../daemon/types.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; +import { isNonDefaultResponseLevel } from '../../contracts.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientBackedCliCommandName } from '../../command-catalog.ts'; import type { ClientCommandParams } from './router-types.ts'; @@ -23,6 +24,14 @@ export async function runGenericClientBackedCommand({ positionals, flags, }); + // A non-default responseLevel returns a leveled payload (e.g. the snapshot + // digest { nodeCount, refs }) that the per-command CLI formatters assume away — + // they serialize the default shape and drop the digest fields. Emit the leveled + // payload verbatim instead. + if (isNonDefaultResponseLevel(flags.responseLevel)) { + writeCommandOutput(flags, result, () => JSON.stringify(result, null, 2)); + return true; + } if (cliOutput) { writeCliOutput(flags, cliOutput); } else { diff --git a/src/client.ts b/src/client.ts index 089de6a09..30076d7ee 100644 --- a/src/client.ts +++ b/src/client.ts @@ -274,6 +274,12 @@ export function createAgentDeviceClient( snapshot: async (options: CaptureSnapshotOptions = {}) => { const session = resolveRequestSession(options); const data = await executeCommand>('snapshot', options); + // A non-default responseLevel returns the leveled snapshot digest + // ({ nodeCount, refs, … }); normalizeSnapshotResult expects the full + // `nodes` tree and would collapse it to an empty snapshot. Pass the + // leveled payload through verbatim. (Mirrors capture.screenshot; the + // caller opted into the level, so the runtime value is the leveled shape.) + if (isLeveledResponse(options)) return data as unknown as CaptureSnapshotResult; return normalizeSnapshotResult(data, session); }, screenshot: async (options: CaptureScreenshotOptions = {}) => {