diff --git a/CONTEXT.md b/CONTEXT.md index d56b9ed..207ca20 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,6 +36,19 @@ _Avoid_: Deletable session **Destroyed Status Check**: A convenience policy predicate for the single `destroyed` **Session Status** value. It is not a separate lifecycle classification. +**Semantic Snapshot**: +A renderer-produced semantic description of a **Session** at a captured event-log sequence. + +**Snapshot Result**: +A public snapshot payload returned to a caller after deriving structured or text output from a **Semantic Snapshot**. + +**Snapshot Artifact**: +A persisted JSON artifact containing exactly the **Snapshot Result** returned to the caller. + +**Snapshot Capture**: +The operation that derives a **Snapshot Result** from a **Semantic Snapshot** and records the matching **Snapshot Artifact**. +_Avoid_: Renderer capture + ## Relationships - A **Session** has exactly one **Session Status** at a time. @@ -46,12 +59,17 @@ A convenience policy predicate for the single `destroyed` **Session Status** val - A **Session** has one **Event Log**. - An **Offline Replay Eligible Session** is reconstructed from its persisted **Event Log** and manifest. +- A **Snapshot Result** is derived from exactly one **Semantic Snapshot**. +- A **Snapshot Artifact** contains exactly the **Snapshot Result** emitted to the caller. ## Example dialogue > **Dev:** "Can garbage collection remove a **destroying** **Session**?" > **Domain expert:** "No. It is still an **Active Session**, even though renderer inspection should use **Offline Replay** instead of the live host." +> **Dev:** "Does **Snapshot Capture** ask the renderer for terminal state?" +> **Domain expert:** "No — the renderer first produces a **Semantic Snapshot**; **Snapshot Capture** derives and records the public result from that snapshot." + ## Flagged ambiguities - "Active" and "offline replay eligible" are independent classifications: `destroying` is both **Active** and **Offline Replay Eligible**. diff --git a/src/cli/commands/snapshot.ts b/src/cli/commands/snapshot.ts index 0ca01c8..cc4a9ec 100644 --- a/src/cli/commands/snapshot.ts +++ b/src/cli/commands/snapshot.ts @@ -1,7 +1,5 @@ -import type { - SnapshotParams, - SnapshotResult, -} from '../../protocol/messages.js'; +import type { SnapshotResult } from '../../protocol/messages.js'; +import type { SnapshotFormat } from '../../snapshot/capture.js'; import type { SemanticSnapshot } from '../../renderer/types.js'; import { CliError } from '../../cli/errors.js'; @@ -10,23 +8,14 @@ import type { CommandContext } from '../context.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { SnapshotParamsSchema } from '../../protocol/messages.js'; -import { SnapshotResultSchema } from '../../protocol/schemas.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import { withOfflineReplayRenderer } from '../../replay/offlineReplay.js'; import { - appendArtifact, - createArtifactEntry, -} from '../../storage/artifactManifest.js'; + captureSnapshotResult, + parseSnapshotResult, +} from '../../snapshot/capture.js'; import { invariant } from '../../util/assert.js'; -import { - readManifestIfExists, - writeTextFileAtomic, -} from '../../storage/manifests.js'; -import { - artifactPath, - ensureArtifactsDir, - snapshotFilename, -} from '../../storage/artifactPaths.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; import { manifestPath, sessionDir, @@ -35,8 +24,6 @@ import { const DEFAULT_SNAPSHOT_FORMAT = 'structured'; -type SnapshotFormat = NonNullable; - interface CommandOptions { context: CommandContext; json: boolean; @@ -94,89 +81,6 @@ function resolveIncludeCells(includeCells: boolean | undefined): boolean { return effectiveIncludeCells; } -function parseSnapshotResult(rawResult: unknown): SnapshotResult { - const parsedResult = SnapshotResultSchema.safeParse(rawResult); - if (!parsedResult.success) { - throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { - message: 'Unexpected response from host', - details: { issues: parsedResult.error.issues }, - }); - } - - return parsedResult.data; -} - -function createSnapshotResult( - snapshot: SemanticSnapshot, - format: SnapshotFormat, -): SnapshotResult { - const textLines = [ - ...(snapshot.scrollbackLines ?? []).map((line) => line.text), - ...snapshot.visibleLines.map((line) => line.text), - ]; - - const snapshotResult: SnapshotResult = - format === 'structured' - ? { format: 'structured' as const, ...snapshot } - : { - format: 'text' as const, - sessionId: snapshot.sessionId, - capturedAtSeq: snapshot.capturedAtSeq, - cols: snapshot.cols, - rows: snapshot.rows, - cursorRow: snapshot.cursorRow, - cursorCol: snapshot.cursorCol, - text: textLines.join('\n'), - }; - - return parseSnapshotResult(snapshotResult); -} - -async function persistSnapshotArtifact( - sessionDirectory: string, - format: SnapshotFormat, - snapshot: SemanticSnapshot, - snapshotResult: SnapshotResult, - rendererBackend?: string, -): Promise { - invariant( - rendererBackend === undefined || rendererBackend.length > 0, - 'rendererBackend must be a non-empty string when provided', - ); - - await ensureArtifactsDir(sessionDirectory); - const filename = snapshotFilename(snapshot.capturedAtSeq, format); - const snapshotArtifactPath = artifactPath(sessionDirectory, filename); - - await writeTextFileAtomic({ - path: snapshotArtifactPath, - pathLabel: 'snapshot artifact path', - contents: `${JSON.stringify(snapshotResult, null, 2)}\n`, - writeErrorMessage: `Failed to write snapshot artifact at ${snapshotArtifactPath}.`, - }); - - await appendArtifact( - sessionDirectory, - createArtifactEntry({ - kind: 'snapshot', - filename, - sessionId: snapshot.sessionId, - capturedAtSeq: snapshot.capturedAtSeq, - metadata: { - format, - cols: snapshot.cols, - rows: snapshot.rows, - cursorRow: snapshot.cursorRow, - cursorCol: snapshot.cursorCol, - ...(rendererBackend === undefined ? {} : { rendererBackend }), - ...(snapshot.scrollbackLines === undefined - ? {} - : { scrollbackLineCount: snapshot.scrollbackLines.length }), - }, - }), - ); -} - async function runRpcSnapshot( sessionDirectory: string, rendererName: CommandContext['rendererDefault'], @@ -207,20 +111,18 @@ async function runOfflineSnapshot( ): Promise { return withOfflineReplayRenderer( { sessionDir: sessionDirectory, rendererName }, - async ({ backend }) => { + async ({ backend, manifest }) => { const snapshot: SemanticSnapshot = await backend.snapshot({ includeScrollback, includeCells, }); - const snapshotResult = createSnapshotResult(snapshot, format); - await persistSnapshotArtifact( - sessionDirectory, + return await captureSnapshotResult({ + sessionDir: sessionDirectory, format, snapshot, - snapshotResult, - backend.rendererBackend, - ); - return snapshotResult; + rendererBackend: backend.rendererBackend, + expectedSessionId: manifest.sessionId, + }); }, ); } diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index a30cc18..32e9575 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -44,6 +44,7 @@ import { } from '../renderer/names.js'; import { resolveProfile } from '../renderer/profiles.js'; import { createRendererBackend } from '../renderer/registry.js'; +import { captureSnapshotResult } from '../snapshot/capture.js'; import { appendArtifact, createArtifactEntry, @@ -52,14 +53,9 @@ import { artifactPath, ensureArtifactsDir, screenshotFilename, - snapshotFilename, } from '../storage/artifactPaths.js'; import { resolveHome } from '../storage/home.js'; -import { - readManifest, - writeManifest, - writeTextFileAtomic, -} from '../storage/manifests.js'; +import { readManifest, writeManifest } from '../storage/manifests.js'; import { eventLogPath, manifestPath, @@ -739,65 +735,13 @@ export async function runHost(sessionId: string): Promise { includeScrollback, includeCells, }); - const snapshotText = [ - ...(snapshot.scrollbackLines ?? []), - ...snapshot.visibleLines, - ] - .map((line) => line.text) - .join('\n'); - - invariant( - snapshot.sessionId === sessionId, - 'renderer snapshot sessionId must match host sessionId', - ); - - const snapshotResult = - format === 'structured' - ? { format: 'structured' as const, ...snapshot } - : { - format: 'text' as const, - sessionId: snapshot.sessionId, - capturedAtSeq: snapshot.capturedAtSeq, - cols: snapshot.cols, - rows: snapshot.rows, - cursorRow: snapshot.cursorRow, - cursorCol: snapshot.cursorCol, - text: snapshotText, - }; - - await ensureArtifactsDir(sessDir); - const filename = snapshotFilename(snapshot.capturedAtSeq, format); - const snapshotArtifactPath = artifactPath(sessDir, filename); - - await writeTextFileAtomic({ - path: snapshotArtifactPath, - pathLabel: 'snapshot artifact path', - contents: `${JSON.stringify(snapshotResult, null, 2)}\n`, - writeErrorMessage: `Failed to write snapshot artifact at ${snapshotArtifactPath}.`, + return await captureSnapshotResult({ + sessionDir: sessDir, + format, + snapshot, + rendererBackend: backend.rendererBackend, + expectedSessionId: sessionId, }); - - await appendArtifact( - sessDir, - createArtifactEntry({ - kind: 'snapshot', - filename, - sessionId: snapshot.sessionId, - capturedAtSeq: snapshot.capturedAtSeq, - metadata: { - format, - rendererBackend: backend.rendererBackend, - cols: snapshot.cols, - rows: snapshot.rows, - cursorRow: snapshot.cursorRow, - cursorCol: snapshot.cursorCol, - ...(snapshot.scrollbackLines === undefined - ? {} - : { scrollbackLineCount: snapshot.scrollbackLines.length }), - }, - }), - ); - - return snapshotResult; }, screenshot: async (params: unknown) => { const { diff --git a/src/snapshot/capture.ts b/src/snapshot/capture.ts new file mode 100644 index 0000000..1f87871 --- /dev/null +++ b/src/snapshot/capture.ts @@ -0,0 +1,191 @@ +import type { SnapshotParams, SnapshotResult } from '../protocol/messages.js'; +import type { SemanticSnapshot } from '../renderer/types.js'; + +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import { SnapshotResultSchema } from '../protocol/schemas.js'; +import { + appendArtifact, + createArtifactEntry, +} from '../storage/artifactManifest.js'; +import { + artifactPath, + ensureArtifactsDir, + snapshotFilename, +} from '../storage/artifactPaths.js'; +import { writeTextFileAtomic } from '../storage/manifests.js'; +import { invariant } from '../util/assert.js'; + +export type SnapshotFormat = NonNullable; + +export function parseSnapshotResult( + rawResult: unknown, + message = 'Unexpected response from host', +): SnapshotResult { + const parsedResult = SnapshotResultSchema.safeParse(rawResult); + if (!parsedResult.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message, + details: { issues: parsedResult.error.issues }, + }); + } + + return parsedResult.data; +} + +export function createSnapshotResult( + snapshot: SemanticSnapshot, + format: SnapshotFormat, +): SnapshotResult { + const textLines = [ + ...(snapshot.scrollbackLines ?? []).map((line) => line.text), + ...snapshot.visibleLines.map((line) => line.text), + ]; + + const snapshotResult: SnapshotResult = + format === 'structured' + ? { format: 'structured' as const, ...snapshot } + : { + format: 'text' as const, + sessionId: snapshot.sessionId, + capturedAtSeq: snapshot.capturedAtSeq, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + text: textLines.join('\n'), + }; + + return parseSnapshotResult( + snapshotResult, + 'Snapshot result validation failed.', + ); +} + +export interface PersistSnapshotArtifactOptions { + sessionDir: string; + format: SnapshotFormat; + snapshot: SemanticSnapshot; + result: SnapshotResult; + rendererBackend: string; +} + +export interface CaptureSnapshotResultOptions { + sessionDir: string; + format: SnapshotFormat; + snapshot: SemanticSnapshot; + rendererBackend: string; + expectedSessionId?: string; +} + +function assertSnapshotResultMatchesSource( + format: SnapshotFormat, + snapshot: SemanticSnapshot, + result: SnapshotResult, +): void { + invariant( + result.format === format, + 'snapshot result format must match format', + ); + invariant( + result.sessionId === snapshot.sessionId, + 'snapshot result sessionId must match snapshot sessionId', + ); + invariant( + result.capturedAtSeq === snapshot.capturedAtSeq, + 'snapshot result capturedAtSeq must match snapshot capturedAtSeq', + ); + invariant( + result.cols === snapshot.cols, + 'snapshot result cols must match snapshot cols', + ); + invariant( + result.rows === snapshot.rows, + 'snapshot result rows must match snapshot rows', + ); + invariant( + result.cursorRow === snapshot.cursorRow, + 'snapshot result cursorRow must match snapshot cursorRow', + ); + invariant( + result.cursorCol === snapshot.cursorCol, + 'snapshot result cursorCol must match snapshot cursorCol', + ); +} + +export async function persistSnapshotArtifact( + options: PersistSnapshotArtifactOptions, +): Promise { + invariant(options.sessionDir.length > 0, 'sessionDir must be non-empty'); + invariant( + options.rendererBackend.length > 0, + 'rendererBackend must be a non-empty string', + ); + assertSnapshotResultMatchesSource( + options.format, + options.snapshot, + options.result, + ); + + await ensureArtifactsDir(options.sessionDir); + const filename = snapshotFilename( + options.snapshot.capturedAtSeq, + options.format, + ); + const snapshotArtifactPath = artifactPath(options.sessionDir, filename); + + await writeTextFileAtomic({ + path: snapshotArtifactPath, + pathLabel: 'snapshot artifact path', + contents: `${JSON.stringify(options.result, null, 2)}\n`, + writeErrorMessage: `Failed to write snapshot artifact at ${snapshotArtifactPath}.`, + }); + + await appendArtifact( + options.sessionDir, + createArtifactEntry({ + kind: 'snapshot', + filename, + sessionId: options.snapshot.sessionId, + capturedAtSeq: options.snapshot.capturedAtSeq, + metadata: { + format: options.format, + rendererBackend: options.rendererBackend, + cols: options.snapshot.cols, + rows: options.snapshot.rows, + cursorRow: options.snapshot.cursorRow, + cursorCol: options.snapshot.cursorCol, + ...(options.snapshot.scrollbackLines === undefined + ? {} + : { scrollbackLineCount: options.snapshot.scrollbackLines.length }), + }, + }), + ); +} + +export async function captureSnapshotResult( + options: CaptureSnapshotResultOptions, +): Promise { + if (options.expectedSessionId !== undefined) { + const actualSessionId = options.snapshot.sessionId; + if (actualSessionId !== options.expectedSessionId) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Snapshot sessionId mismatch.', + details: { + expectedSessionId: options.expectedSessionId, + actualSessionId, + }, + }); + } + } + + const result = createSnapshotResult(options.snapshot, options.format); + await persistSnapshotArtifact({ + sessionDir: options.sessionDir, + format: options.format, + snapshot: options.snapshot, + result, + rendererBackend: options.rendererBackend, + }); + + return result; +} diff --git a/test/helpers.ts b/test/helpers.ts index 1ad4ea2..a26b1f2 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,9 +1,22 @@ import { spawnSync } from 'node:child_process'; -import { readFile, readdir, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; -import { expect } from 'vitest'; +import type { SemanticSnapshot } from '../src/renderer/types.js'; + +import { afterEach, expect } from 'vitest'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); const DEFAULT_CLI_TIMEOUT_MS = 30_000; @@ -54,6 +67,31 @@ export interface WaitResult { timedOut: boolean; } +export function createTestSemanticSnapshot( + overrides: Partial = {}, +): SemanticSnapshot { + return { + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + isAltScreen: false, + visibleLines: [{ row: 0, text: 'offline output' }], + ...overrides, + }; +} + +export async function createTemporarySessionDir( + prefix: string, + sessionId = 'session-01', +): Promise { + const home = await realpath(await mkdtemp(join(tmpdir(), prefix))); + temporaryDirectories.push(home); + return join(home, sessionId); +} + export function runCli( args: string[], env: Record = {}, diff --git a/test/unit/commands/snapshot.test.ts b/test/unit/commands/snapshot.test.ts index 133b6e8..7ca7c6d 100644 --- a/test/unit/commands/snapshot.test.ts +++ b/test/unit/commands/snapshot.test.ts @@ -58,6 +58,7 @@ vi.mock('../../../src/storage/sessionPaths.js', () => ({ socketPath: mocks.socketPath, })); +import { createTestSemanticSnapshot } from '../../helpers.js'; import { runSnapshotCommand } from '../../../src/cli/commands/snapshot.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -107,44 +108,11 @@ function getLastEmitSuccessPayload(): unknown { return mocks.emitSuccess.mock.calls.at(-1)?.[0] as unknown; } -function createOfflineSemanticSnapshot( - options: { - scrollbackLines?: { row: number; text: string }[]; - cells?: { - lineNumber: number; - cells: { - char: string; - fg?: string; - bg?: string; - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - }[]; - }[]; - } = {}, -) { - return { - sessionId: 'session-01', - capturedAtSeq: 5, - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - isAltScreen: false, - visibleLines: [{ row: 0, text: 'offline output' }], - ...(options.scrollbackLines === undefined - ? {} - : { scrollbackLines: options.scrollbackLines }), - ...(options.cells === undefined ? {} : { cells: options.cells }), - }; -} - function installOfflineReplaySuccessMock( snapshotImpl: ( options?: unknown, - ) => MaybePromise> = () => - createOfflineSemanticSnapshot(), + ) => MaybePromise> = () => + createTestSemanticSnapshot(), ) { mocks.withOfflineReplayRenderer.mockImplementation( async (_options: unknown, run: (ctx: unknown) => Promise) => { @@ -444,8 +412,8 @@ describe('snapshot command', () => { }); }); - it('uses offline replay for exited sessions and persists the artifact', async () => { - const snapshot = createOfflineSemanticSnapshot(); + it('uses offline replay for exited sessions', async () => { + const snapshot = createTestSemanticSnapshot(); const result = { format: 'structured' as const, ...snapshot, @@ -467,50 +435,6 @@ describe('snapshot command', () => { }, expect.any(Function), ); - expect(mocks.ensureArtifactsDir).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01', - ); - expect(mocks.snapshotFilename).toHaveBeenCalledWith(5, 'structured'); - expect(mocks.writeTextFileAtomic).toHaveBeenCalledWith({ - path: '/artifacts/snapshot-5-structured.json', - pathLabel: 'snapshot artifact path', - contents: `${JSON.stringify(result, null, 2)}\n`, - writeErrorMessage: - 'Failed to write snapshot artifact at /artifacts/snapshot-5-structured.json.', - }); - expect(mocks.createArtifactEntry).toHaveBeenCalledWith({ - kind: 'snapshot', - filename: 'snapshot-5-structured.json', - sessionId: 'session-01', - capturedAtSeq: 5, - metadata: { - format: 'structured', - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - rendererBackend: 'mock-backend', - }, - }); - expect(mocks.appendArtifact).toHaveBeenCalledWith( - '/tmp/agent-tty/sessions/session-01', - { - kind: 'snapshot', - filename: 'snapshot-5-structured.json', - sessionId: 'session-01', - capturedAtSeq: 5, - metadata: { - format: 'structured', - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - rendererBackend: 'mock-backend', - }, - id: 'artifact-01', - createdAt: '2026-03-19T12:00:02.000Z', - }, - ); expect(mocks.emitSuccess).toHaveBeenCalledWith({ command: 'snapshot', json: false, @@ -530,7 +454,7 @@ describe('snapshot command', () => { it('uses offline replay for exited sessions and includes scrollback when requested', async () => { const snapshotMock = vi.fn((options?: unknown) => - createOfflineSemanticSnapshot( + createTestSemanticSnapshot( (options as { includeScrollback?: boolean } | undefined) ?.includeScrollback ? { @@ -577,9 +501,9 @@ describe('snapshot command', () => { ); }); - it('threads includeCells through offline replay snapshots and persists cells', async () => { + it('threads includeCells through offline replay snapshots and returns cells', async () => { const snapshotMock = vi.fn((options?: unknown) => - createOfflineSemanticSnapshot( + createTestSemanticSnapshot( (options as { includeCells?: boolean } | undefined)?.includeCells ? { cells: [ @@ -630,7 +554,7 @@ describe('snapshot command', () => { it('defaults offline snapshots to omitting scrollbackLines', async () => { const snapshotMock = vi.fn((options?: unknown) => - createOfflineSemanticSnapshot( + createTestSemanticSnapshot( (options as { includeScrollback?: boolean } | undefined) ?.includeScrollback ? { diff --git a/test/unit/commands/wait.test.ts b/test/unit/commands/wait.test.ts index 8507e16..7360ed5 100644 --- a/test/unit/commands/wait.test.ts +++ b/test/unit/commands/wait.test.ts @@ -39,6 +39,7 @@ vi.mock('../../../src/storage/sessionPaths.js', () => ({ socketPath: mocks.socketPath, })); +import { createTestSemanticSnapshot } from '../../helpers.js'; import { runWaitCommand } from '../../../src/cli/commands/wait.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -93,29 +94,8 @@ function createOptions( }; } -function createOfflineSemanticSnapshot( - overrides: Partial<{ - capturedAtSeq: number; - cursorRow: number; - cursorCol: number; - visibleLines: { row: number; text: string }[]; - }> = {}, -) { - return { - sessionId: 'session-01', - capturedAtSeq: 5, - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - isAltScreen: false, - visibleLines: [{ row: 0, text: 'offline output' }], - ...overrides, - }; -} - function mockOfflineReplaySnapshot( - snapshotOverrides: Parameters[0] = {}, + snapshotOverrides: Parameters[0] = {}, ): void { mocks.withOfflineReplayRenderer.mockImplementation( async ( @@ -130,7 +110,7 @@ function mockOfflineReplaySnapshot( ) => { const mockBackend = { snapshot: vi.fn(() => - Promise.resolve(createOfflineSemanticSnapshot(snapshotOverrides)), + Promise.resolve(createTestSemanticSnapshot(snapshotOverrides)), ), }; diff --git a/test/unit/snapshot/capture.test.ts b/test/unit/snapshot/capture.test.ts new file mode 100644 index 0000000..0c9b343 --- /dev/null +++ b/test/unit/snapshot/capture.test.ts @@ -0,0 +1,305 @@ +import { access, mkdir, readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { + createTemporarySessionDir, + createTestSemanticSnapshot, +} from '../../helpers.js'; + +import { + captureSnapshotResult, + createSnapshotResult, + persistSnapshotArtifact, +} from '../../../src/snapshot/capture.js'; +import { readArtifactManifest } from '../../../src/storage/artifactManifest.js'; +import { + artifactPath, + snapshotFilename, +} from '../../../src/storage/artifactPaths.js'; + +async function createSessionDir(sessionId = 'session-01'): Promise { + return await createTemporarySessionDir( + 'agent-tty-snapshot-capture-', + sessionId, + ); +} + +describe('snapshot capture', () => { + it('creates structured snapshot results without changing the semantic snapshot shape', () => { + const snapshot = createTestSemanticSnapshot({ + cells: [ + { + lineNumber: 0, + cells: [{ char: 'v', fg: '#ffffff', bg: '#000000' }], + }, + ], + }); + + expect(createSnapshotResult(snapshot, 'structured')).toEqual({ + format: 'structured', + ...snapshot, + }); + }); + + it('fails validation before writing snapshot artifacts', async () => { + const sessionDirectory = await createSessionDir(); + const invalidSnapshot = createTestSemanticSnapshot({ + rows: 0, + }); + + await expect( + captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'structured', + snapshot: invalidSnapshot, + rendererBackend: 'test-backend', + expectedSessionId: 'session-01', + }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_ERROR', + message: 'Snapshot result validation failed.', + details: { issues: expect.any(Array) as unknown }, + }); + + await expect( + access(artifactPath(sessionDirectory, snapshotFilename(5, 'structured'))), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readArtifactManifest(sessionDirectory)).resolves.toEqual({ + version: 1, + sessionId: 'session-01', + artifacts: [], + }); + }); + + it('fails expected session id mismatches before writing snapshot artifacts', async () => { + const sessionDirectory = await createSessionDir(); + + await expect( + captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'structured', + snapshot: createTestSemanticSnapshot(), + rendererBackend: 'test-backend', + expectedSessionId: 'other-session', + }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_ERROR', + message: 'Snapshot sessionId mismatch.', + details: { + expectedSessionId: 'other-session', + actualSessionId: 'session-01', + }, + }); + + await expect( + access(artifactPath(sessionDirectory, snapshotFilename(5, 'structured'))), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readArtifactManifest(sessionDirectory)).resolves.toEqual({ + version: 1, + sessionId: 'session-01', + artifacts: [], + }); + }); + + it('rejects inconsistent artifact persistence inputs before writing', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createTestSemanticSnapshot(); + const result = createSnapshotResult(snapshot, 'structured'); + + await expect( + persistSnapshotArtifact({ + sessionDir: sessionDirectory, + format: 'text', + snapshot, + result, + rendererBackend: 'test-backend', + }), + ).rejects.toThrow(/snapshot result format must match format/u); + + await expect( + access(artifactPath(sessionDirectory, snapshotFilename(5, 'text'))), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not append a manifest entry when artifact writing fails', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createTestSemanticSnapshot(); + const result = createSnapshotResult(snapshot, 'structured'); + const filename = snapshotFilename(5, 'structured'); + await mkdir(artifactPath(sessionDirectory, filename), { recursive: true }); + + await expect( + persistSnapshotArtifact({ + sessionDir: sessionDirectory, + format: 'structured', + snapshot, + result, + rendererBackend: 'test-backend', + }), + ).rejects.toMatchObject({ code: 'STORAGE_WRITE_ERROR' }); + + await expect(readArtifactManifest(sessionDirectory)).resolves.toEqual({ + version: 1, + sessionId: 'session-01', + artifacts: [], + }); + }); + + it('requires renderer backend metadata before writing artifacts', async () => { + const sessionDirectory = await createSessionDir(); + + await expect( + captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'structured', + snapshot: createTestSemanticSnapshot(), + rendererBackend: '', + }), + ).rejects.toThrow(/rendererBackend must be a non-empty string/u); + + await expect(readArtifactManifest(sessionDirectory)).resolves.toEqual({ + version: 1, + sessionId: 'session-01', + artifacts: [], + }); + }); + + it('persists structured cells and omits scrollback metadata when scrollback is absent', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createTestSemanticSnapshot({ + cells: [ + { + lineNumber: 0, + cells: [ + { char: 'o', fg: '#ffffff', bg: '#000000' }, + { char: 'k', fg: '#00ff00', bg: '#000000', bold: true }, + ], + }, + ], + }); + + const result = await captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'structured', + snapshot, + rendererBackend: 'test-backend', + }); + + expect(result).toEqual({ format: 'structured', ...snapshot }); + const filename = snapshotFilename(5, 'structured'); + expect( + JSON.parse( + await readFile(artifactPath(sessionDirectory, filename), 'utf8'), + ), + ).toEqual(result); + + const manifest = await readArtifactManifest(sessionDirectory); + expect(manifest.artifacts[0]?.metadata).toEqual({ + format: 'structured', + rendererBackend: 'test-backend', + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + }); + }); + + it('captures text snapshot results without scrollback metadata when scrollback is absent', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createTestSemanticSnapshot({ + visibleLines: [ + { row: 0, text: 'first visible line' }, + { row: 1, text: 'second visible line' }, + ], + }); + + const result = await captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'text', + snapshot, + rendererBackend: 'test-backend', + expectedSessionId: 'session-01', + }); + + expect(result).toEqual({ + format: 'text', + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + text: 'first visible line\nsecond visible line', + }); + + const filename = snapshotFilename(5, 'text'); + await expect( + readFile(artifactPath(sessionDirectory, filename), 'utf8'), + ).resolves.toBe(`${JSON.stringify(result, null, 2)}\n`); + + const manifest = await readArtifactManifest(sessionDirectory); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]?.metadata).toEqual({ + format: 'text', + rendererBackend: 'test-backend', + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + }); + }); + + it('captures text snapshot results and persists matching artifacts with scrollback metadata', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createTestSemanticSnapshot({ + scrollbackLines: [ + { row: 0, text: 'scrolled' }, + { row: 1, text: 'away' }, + ], + visibleLines: [{ row: 2, text: 'visible output' }], + }); + + const result = await captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'text', + snapshot, + rendererBackend: 'test-backend', + expectedSessionId: 'session-01', + }); + + expect(result).toEqual({ + format: 'text', + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + text: 'scrolled\naway\nvisible output', + }); + + const filename = snapshotFilename(5, 'text'); + await expect( + readFile(artifactPath(sessionDirectory, filename), 'utf8'), + ).resolves.toBe(`${JSON.stringify(result, null, 2)}\n`); + + const manifest = await readArtifactManifest(sessionDirectory); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'snapshot', + filename, + sessionId: 'session-01', + capturedAtSeq: 5, + metadata: { + format: 'text', + rendererBackend: 'test-backend', + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + scrollbackLineCount: 2, + }, + }); + }); +}); diff --git a/test/unit/storage/artifactHealth.test.ts b/test/unit/storage/artifactHealth.test.ts index 1c81203..639a61c 100644 --- a/test/unit/storage/artifactHealth.test.ts +++ b/test/unit/storage/artifactHealth.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { writeFile } from 'node:fs/promises'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTemporarySessionDir } from '../../helpers.js'; import type { ArtifactEntry, @@ -16,22 +16,11 @@ import { ensureArtifactsDir, } from '../../../src/storage/artifactPaths.js'; -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - async function createSessionDir(sessionId = 'session-01'): Promise { - const home = await realpath( - await mkdtemp(join(tmpdir(), 'agent-tty-artifact-health-')), + return await createTemporarySessionDir( + 'agent-tty-artifact-health-', + sessionId, ); - temporaryDirectories.push(home); - return join(home, sessionId); } function createArtifactEntry( diff --git a/test/unit/storage/artifactStorage.test.ts b/test/unit/storage/artifactStorage.test.ts index 5da456b..4bf7114 100644 --- a/test/unit/storage/artifactStorage.test.ts +++ b/test/unit/storage/artifactStorage.test.ts @@ -1,15 +1,10 @@ -import { - access, - mkdtemp, - readFile, - realpath, - rm, - writeFile, -} from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { access, readFile, writeFile } from 'node:fs/promises'; + import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; + +import { createTemporarySessionDir } from '../../helpers.js'; import type { ArtifactEntry, @@ -30,21 +25,8 @@ import { videoFilename, } from '../../../src/storage/artifactPaths.js'; -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - async function createSessionDir(sessionId = 'session-01'): Promise { - // prettier-ignore - const home = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-artifacts-'))); - temporaryDirectories.push(home); - return join(home, sessionId); + return await createTemporarySessionDir('agent-tty-artifacts-', sessionId); } function createArtifactEntry(