From 70755ce3c0133effd44c1737e0166c3ec60f3e52 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 12:53:05 +0000 Subject: [PATCH 1/4] refactor: share snapshot capture persistence --- CONTEXT.md | 18 ++ src/cli/commands/snapshot.ts | 100 +--------- src/host/hostMain.ts | 72 +------ src/snapshot/capture.ts | 179 +++++++++++++++++ test/unit/commands/snapshot.test.ts | 48 +---- test/unit/snapshot/capture.test.ts | 286 ++++++++++++++++++++++++++++ 6 files changed, 501 insertions(+), 202 deletions(-) create mode 100644 src/snapshot/capture.ts create mode 100644 test/unit/snapshot/capture.test.ts 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..ffd7e26 100644 --- a/src/cli/commands/snapshot.ts +++ b/src/cli/commands/snapshot.ts @@ -13,20 +13,9 @@ 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'; +import { captureSnapshotResult } 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, @@ -106,77 +95,6 @@ function parseSnapshotResult(rawResult: unknown): SnapshotResult { 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 +125,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..9822370 --- /dev/null +++ b/src/snapshot/capture.ts @@ -0,0 +1,179 @@ +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'; + +type SnapshotFormat = NonNullable; + +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; +} + +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); +} + +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) { + invariant( + options.snapshot.sessionId === options.expectedSessionId, + 'renderer snapshot sessionId must match expected sessionId', + ); + } + + 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/unit/commands/snapshot.test.ts b/test/unit/commands/snapshot.test.ts index 133b6e8..9d75ca6 100644 --- a/test/unit/commands/snapshot.test.ts +++ b/test/unit/commands/snapshot.test.ts @@ -444,7 +444,7 @@ describe('snapshot command', () => { }); }); - it('uses offline replay for exited sessions and persists the artifact', async () => { + it('uses offline replay for exited sessions', async () => { const snapshot = createOfflineSemanticSnapshot(); const result = { format: 'structured' as const, @@ -467,50 +467,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, @@ -577,7 +533,7 @@ 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( (options as { includeCells?: boolean } | undefined)?.includeCells diff --git a/test/unit/snapshot/capture.test.ts b/test/unit/snapshot/capture.test.ts new file mode 100644 index 0000000..f2d0a18 --- /dev/null +++ b/test/unit/snapshot/capture.test.ts @@ -0,0 +1,286 @@ +import { + access, + mkdir, + mkdtemp, + readFile, + realpath, + rm, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { SemanticSnapshot } from '../../../src/renderer/types.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'; + +function createSemanticSnapshot( + overrides: Partial = {}, +): SemanticSnapshot { + return { + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 1, + cursorCol: 2, + isAltScreen: false, + visibleLines: [{ row: 0, text: 'visible output' }], + ...overrides, + }; +} + +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-snapshot-capture-'))); + temporaryDirectories.push(home); + return join(home, sessionId); +} + +describe('snapshot capture', () => { + it('creates structured snapshot results without changing the semantic snapshot shape', () => { + const snapshot = createSemanticSnapshot({ + 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 = createSemanticSnapshot({ + rows: 0, + }); + + await expect( + captureSnapshotResult({ + sessionDir: sessionDirectory, + format: 'structured', + snapshot: invalidSnapshot, + rendererBackend: 'test-backend', + expectedSessionId: 'session-01', + }), + ).rejects.toMatchObject({ + code: 'PROTOCOL_ERROR', + 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: createSemanticSnapshot(), + rendererBackend: 'test-backend', + expectedSessionId: 'other-session', + }), + ).rejects.toThrow( + /renderer snapshot sessionId must match expected sessionId/u, + ); + + 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 = createSemanticSnapshot(); + 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 = createSemanticSnapshot(); + 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: createSemanticSnapshot(), + 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 = createSemanticSnapshot({ + 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: 1, + cursorCol: 2, + }); + }); + + it('captures text snapshot results and persists matching artifacts with scrollback metadata', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createSemanticSnapshot({ + 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: 1, + cursorCol: 2, + 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: 1, + cursorCol: 2, + scrollbackLineCount: 2, + }, + }); + }); +}); From 4b01a3abc8e1a44b5275800d5614e59071a1ebc3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 13:22:52 +0000 Subject: [PATCH 2/4] fix: address snapshot capture review --- src/cli/commands/snapshot.ts | 26 ++----- src/snapshot/capture.ts | 28 +++++-- test/helpers.ts | 24 +++++- test/unit/snapshot/capture.test.ts | 91 +++++++++++++++-------- test/unit/storage/artifactHealth.test.ts | 25 ++----- test/unit/storage/artifactStorage.test.ts | 30 ++------ 6 files changed, 123 insertions(+), 101 deletions(-) diff --git a/src/cli/commands/snapshot.ts b/src/cli/commands/snapshot.ts index ffd7e26..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,10 +8,12 @@ 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 { captureSnapshotResult } from '../../snapshot/capture.js'; +import { + captureSnapshotResult, + parseSnapshotResult, +} from '../../snapshot/capture.js'; import { invariant } from '../../util/assert.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { @@ -24,8 +24,6 @@ import { const DEFAULT_SNAPSHOT_FORMAT = 'structured'; -type SnapshotFormat = NonNullable; - interface CommandOptions { context: CommandContext; json: boolean; @@ -83,18 +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; -} - async function runRpcSnapshot( sessionDirectory: string, rendererName: CommandContext['rendererDefault'], diff --git a/src/snapshot/capture.ts b/src/snapshot/capture.ts index 9822370..1f87871 100644 --- a/src/snapshot/capture.ts +++ b/src/snapshot/capture.ts @@ -15,13 +15,16 @@ import { import { writeTextFileAtomic } from '../storage/manifests.js'; import { invariant } from '../util/assert.js'; -type SnapshotFormat = NonNullable; +export type SnapshotFormat = NonNullable; -function parseSnapshotResult(rawResult: unknown): SnapshotResult { +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: 'Unexpected response from host', + message, details: { issues: parsedResult.error.issues }, }); } @@ -52,7 +55,10 @@ export function createSnapshotResult( text: textLines.join('\n'), }; - return parseSnapshotResult(snapshotResult); + return parseSnapshotResult( + snapshotResult, + 'Snapshot result validation failed.', + ); } export interface PersistSnapshotArtifactOptions { @@ -160,10 +166,16 @@ export async function captureSnapshotResult( options: CaptureSnapshotResultOptions, ): Promise { if (options.expectedSessionId !== undefined) { - invariant( - options.snapshot.sessionId === options.expectedSessionId, - 'renderer snapshot sessionId must match expected sessionId', - ); + 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); diff --git a/test/helpers.ts b/test/helpers.ts index 1ad4ea2..0da2ff5 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,9 +1,20 @@ 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 { 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 +65,15 @@ export interface WaitResult { timedOut: boolean; } +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/snapshot/capture.test.ts b/test/unit/snapshot/capture.test.ts index f2d0a18..51eca9b 100644 --- a/test/unit/snapshot/capture.test.ts +++ b/test/unit/snapshot/capture.test.ts @@ -1,15 +1,8 @@ -import { - access, - mkdir, - mkdtemp, - readFile, - realpath, - rm, -} from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, describe, expect, it } from 'vitest'; +import { access, mkdir, readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { createTemporarySessionDir } from '../../helpers.js'; import type { SemanticSnapshot } from '../../../src/renderer/types.js'; @@ -40,21 +33,11 @@ function createSemanticSnapshot( }; } -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-snapshot-capture-'))); - temporaryDirectories.push(home); - return join(home, sessionId); + return await createTemporarySessionDir( + 'agent-tty-snapshot-capture-', + sessionId, + ); } describe('snapshot capture', () => { @@ -114,9 +97,14 @@ describe('snapshot capture', () => { rendererBackend: 'test-backend', expectedSessionId: 'other-session', }), - ).rejects.toThrow( - /renderer snapshot sessionId must match expected sessionId/u, - ); + ).rejects.toMatchObject({ + code: 'PROTOCOL_ERROR', + message: 'Snapshot sessionId mismatch.', + details: { + expectedSessionId: 'other-session', + actualSessionId: 'session-01', + }, + }); await expect( access(artifactPath(sessionDirectory, snapshotFilename(5, 'structured'))), @@ -231,6 +219,51 @@ describe('snapshot capture', () => { }); }); + it('captures text snapshot results without scrollback metadata when scrollback is absent', async () => { + const sessionDirectory = await createSessionDir(); + const snapshot = createSemanticSnapshot({ + 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: 1, + cursorCol: 2, + 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: 1, + cursorCol: 2, + }); + }); + it('captures text snapshot results and persists matching artifacts with scrollback metadata', async () => { const sessionDirectory = await createSessionDir(); const snapshot = createSemanticSnapshot({ 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( From 3dd38142b9fc796a9074f0022e8db3ec3d1a53e8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 13:55:46 +0000 Subject: [PATCH 3/4] test: share semantic snapshot fixture --- test/helpers.ts | 18 +++++++++ test/unit/commands/snapshot.test.ts | 46 ++++------------------ test/unit/commands/wait.test.ts | 26 ++---------- test/unit/snapshot/capture.test.ts | 61 +++++++++++------------------ 4 files changed, 51 insertions(+), 100 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index 0da2ff5..a26b1f2 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; +import type { SemanticSnapshot } from '../src/renderer/types.js'; + import { afterEach, expect } from 'vitest'; const temporaryDirectories: string[] = []; @@ -65,6 +67,22 @@ 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', diff --git a/test/unit/commands/snapshot.test.ts b/test/unit/commands/snapshot.test.ts index 9d75ca6..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) => { @@ -445,7 +413,7 @@ describe('snapshot command', () => { }); it('uses offline replay for exited sessions', async () => { - const snapshot = createOfflineSemanticSnapshot(); + const snapshot = createTestSemanticSnapshot(); const result = { format: 'structured' as const, ...snapshot, @@ -486,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 ? { @@ -535,7 +503,7 @@ describe('snapshot command', () => { 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: [ @@ -586,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 index 51eca9b..b766106 100644 --- a/test/unit/snapshot/capture.test.ts +++ b/test/unit/snapshot/capture.test.ts @@ -2,9 +2,10 @@ import { access, mkdir, readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; -import { createTemporarySessionDir } from '../../helpers.js'; - -import type { SemanticSnapshot } from '../../../src/renderer/types.js'; +import { + createTemporarySessionDir, + createTestSemanticSnapshot, +} from '../../helpers.js'; import { captureSnapshotResult, @@ -17,22 +18,6 @@ import { snapshotFilename, } from '../../../src/storage/artifactPaths.js'; -function createSemanticSnapshot( - overrides: Partial = {}, -): SemanticSnapshot { - return { - sessionId: 'session-01', - capturedAtSeq: 5, - cols: 80, - rows: 24, - cursorRow: 1, - cursorCol: 2, - isAltScreen: false, - visibleLines: [{ row: 0, text: 'visible output' }], - ...overrides, - }; -} - async function createSessionDir(sessionId = 'session-01'): Promise { return await createTemporarySessionDir( 'agent-tty-snapshot-capture-', @@ -42,7 +27,7 @@ async function createSessionDir(sessionId = 'session-01'): Promise { describe('snapshot capture', () => { it('creates structured snapshot results without changing the semantic snapshot shape', () => { - const snapshot = createSemanticSnapshot({ + const snapshot = createTestSemanticSnapshot({ cells: [ { lineNumber: 0, @@ -59,7 +44,7 @@ describe('snapshot capture', () => { it('fails validation before writing snapshot artifacts', async () => { const sessionDirectory = await createSessionDir(); - const invalidSnapshot = createSemanticSnapshot({ + const invalidSnapshot = createTestSemanticSnapshot({ rows: 0, }); @@ -93,7 +78,7 @@ describe('snapshot capture', () => { captureSnapshotResult({ sessionDir: sessionDirectory, format: 'structured', - snapshot: createSemanticSnapshot(), + snapshot: createTestSemanticSnapshot(), rendererBackend: 'test-backend', expectedSessionId: 'other-session', }), @@ -118,7 +103,7 @@ describe('snapshot capture', () => { it('rejects inconsistent artifact persistence inputs before writing', async () => { const sessionDirectory = await createSessionDir(); - const snapshot = createSemanticSnapshot(); + const snapshot = createTestSemanticSnapshot(); const result = createSnapshotResult(snapshot, 'structured'); await expect( @@ -138,7 +123,7 @@ describe('snapshot capture', () => { it('does not append a manifest entry when artifact writing fails', async () => { const sessionDirectory = await createSessionDir(); - const snapshot = createSemanticSnapshot(); + const snapshot = createTestSemanticSnapshot(); const result = createSnapshotResult(snapshot, 'structured'); const filename = snapshotFilename(5, 'structured'); await mkdir(artifactPath(sessionDirectory, filename), { recursive: true }); @@ -167,7 +152,7 @@ describe('snapshot capture', () => { captureSnapshotResult({ sessionDir: sessionDirectory, format: 'structured', - snapshot: createSemanticSnapshot(), + snapshot: createTestSemanticSnapshot(), rendererBackend: '', }), ).rejects.toThrow(/rendererBackend must be a non-empty string/u); @@ -181,7 +166,7 @@ describe('snapshot capture', () => { it('persists structured cells and omits scrollback metadata when scrollback is absent', async () => { const sessionDirectory = await createSessionDir(); - const snapshot = createSemanticSnapshot({ + const snapshot = createTestSemanticSnapshot({ cells: [ { lineNumber: 0, @@ -214,14 +199,14 @@ describe('snapshot capture', () => { rendererBackend: 'test-backend', cols: 80, rows: 24, - cursorRow: 1, - cursorCol: 2, + cursorRow: 0, + cursorCol: 0, }); }); it('captures text snapshot results without scrollback metadata when scrollback is absent', async () => { const sessionDirectory = await createSessionDir(); - const snapshot = createSemanticSnapshot({ + const snapshot = createTestSemanticSnapshot({ visibleLines: [ { row: 0, text: 'first visible line' }, { row: 1, text: 'second visible line' }, @@ -242,8 +227,8 @@ describe('snapshot capture', () => { capturedAtSeq: 5, cols: 80, rows: 24, - cursorRow: 1, - cursorCol: 2, + cursorRow: 0, + cursorCol: 0, text: 'first visible line\nsecond visible line', }); @@ -259,14 +244,14 @@ describe('snapshot capture', () => { rendererBackend: 'test-backend', cols: 80, rows: 24, - cursorRow: 1, - cursorCol: 2, + cursorRow: 0, + cursorCol: 0, }); }); it('captures text snapshot results and persists matching artifacts with scrollback metadata', async () => { const sessionDirectory = await createSessionDir(); - const snapshot = createSemanticSnapshot({ + const snapshot = createTestSemanticSnapshot({ scrollbackLines: [ { row: 0, text: 'scrolled' }, { row: 1, text: 'away' }, @@ -288,8 +273,8 @@ describe('snapshot capture', () => { capturedAtSeq: 5, cols: 80, rows: 24, - cursorRow: 1, - cursorCol: 2, + cursorRow: 0, + cursorCol: 0, text: 'scrolled\naway\nvisible output', }); @@ -310,8 +295,8 @@ describe('snapshot capture', () => { rendererBackend: 'test-backend', cols: 80, rows: 24, - cursorRow: 1, - cursorCol: 2, + cursorRow: 0, + cursorCol: 0, scrollbackLineCount: 2, }, }); From efa981852a17e389f5e99340acff4749d45239c7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 29 Apr 2026 14:17:58 +0000 Subject: [PATCH 4/4] test: assert snapshot validation message --- test/unit/snapshot/capture.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/snapshot/capture.test.ts b/test/unit/snapshot/capture.test.ts index b766106..0c9b343 100644 --- a/test/unit/snapshot/capture.test.ts +++ b/test/unit/snapshot/capture.test.ts @@ -58,6 +58,7 @@ describe('snapshot capture', () => { }), ).rejects.toMatchObject({ code: 'PROTOCOL_ERROR', + message: 'Snapshot result validation failed.', details: { issues: expect.any(Array) as unknown }, });