diff --git a/test/helpers/fakeBackend.ts b/test/helpers/fakeBackend.ts new file mode 100644 index 0000000..4b1a18b --- /dev/null +++ b/test/helpers/fakeBackend.ts @@ -0,0 +1,138 @@ +import { writeFile } from 'node:fs/promises'; + +import { vi } from 'vitest'; + +import type { RendererBackend } from '../../src/renderer/backend.js'; +import type { + ReplayInput, + ReplayState, + ScreenshotResult, + SemanticSnapshot, +} from '../../src/renderer/types.js'; + +type MockFn = ReturnType; + +const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +export interface FakeBackendOptions { + rendererBackend?: string; + bootImplementation?: () => Promise; + resultOverrides?: Partial; + writePng?: boolean; + fail?: Error; + onScreenshot?: ( + outputPath: string, + options?: { showCursor?: boolean }, + ) => void; +} + +export type FakeRendererBackend = RendererBackend & { + bootMock: MockFn; + replayToMock: MockFn; + snapshotMock: MockFn; + screenshotMock: MockFn; + getVisibleTextMock: MockFn; + disposeMock: MockFn; + setBooted: (value: boolean) => void; +}; + +export function createFakeBackend( + options: FakeBackendOptions = {}, +): FakeRendererBackend { + const rendererBackend = options.rendererBackend ?? 'fake-renderer'; + const writePng = options.writePng ?? true; + let booted = false; + + const bootMock = vi.fn((): Promise => { + if (options.bootImplementation !== undefined) { + return options.bootImplementation(); + } + booted = true; + return Promise.resolve(); + }); + + const replayToMock = vi.fn( + (input: ReplayInput): Promise => + Promise.resolve({ + lastSeq: input.targetSeq, + cols: input.initialCols, + rows: input.initialRows, + cursorRow: 0, + cursorCol: 0, + }), + ); + + const snapshotMock = vi.fn( + (): Promise => + Promise.resolve({ + sessionId: 'session-01', + capturedAtSeq: 0, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + isAltScreen: false, + visibleLines: [], + }), + ); + + const screenshotMock = vi.fn( + async ( + outputPath: string, + screenshotOptions?: { showCursor?: boolean }, + ): Promise => { + options.onScreenshot?.(outputPath, screenshotOptions); + if (options.fail !== undefined) { + throw options.fail; + } + if (writePng) { + await writeFile(outputPath, PNG_HEADER); + } + return { + sessionId: 'session-01', + capturedAtSeq: 5, + profileName: 'reference-dark', + cols: 80, + rows: 24, + artifactPath: outputPath, + pngSizeBytes: 4, + cursorVisible: screenshotOptions?.showCursor === true, + rendererBackend, + pixelWidth: 800, + pixelHeight: 600, + sha256: 'a'.repeat(64), + renderProfileHash: 'b'.repeat(64), + ...options.resultOverrides, + }; + }, + ); + + const getVisibleTextMock = vi.fn((): Promise => Promise.resolve('')); + + const disposeMock = vi.fn((): Promise => { + booted = false; + return Promise.resolve(); + }); + + return { + rendererBackend, + get isBooted() { + return booted; + }, + setBooted(value: boolean) { + booted = value; + }, + boot: bootMock, + bootMock, + replayTo: replayToMock, + replayToMock, + snapshot: snapshotMock, + snapshotMock, + screenshot: screenshotMock, + screenshotMock, + getVisibleText: getVisibleTextMock, + getVisibleTextMock, + dispose: disposeMock, + disposeMock, + }; +} diff --git a/test/unit/host/renderer.test.ts b/test/unit/host/renderer.test.ts index 7b9dae1..2c15bd0 100644 --- a/test/unit/host/renderer.test.ts +++ b/test/unit/host/renderer.test.ts @@ -10,22 +10,12 @@ import type { RendererBackend } from '../../../src/renderer/backend.js'; import type { RenderProfileConfig, ReplayInput, - ReplayState, - ScreenshotResult, - SemanticSnapshot, } from '../../../src/renderer/types.js'; -type MockFn = ReturnType; - -type FakeRendererBackend = RendererBackend & { - bootMock: MockFn; - replayToMock: MockFn; - snapshotMock: MockFn; - screenshotMock: MockFn; - getVisibleTextMock: MockFn; - disposeMock: MockFn; - setBooted: (value: boolean) => void; -}; +import { + createFakeBackend, + type FakeRendererBackend, +} from '../../helpers/fakeBackend.js'; function createProfile(name = 'default'): RenderProfileConfig { return { @@ -57,84 +47,6 @@ function createReplayInput(overrides: Partial = {}): ReplayInput { }; } -function createFakeBackend( - options: { - bootImplementation?: () => Promise; - } = {}, -): FakeRendererBackend { - let booted = false; - const bootMock = vi.fn((): Promise => { - if (options.bootImplementation !== undefined) { - return options.bootImplementation(); - } - - booted = true; - return Promise.resolve(); - }); - const replayToMock = vi.fn( - (input: ReplayInput): Promise => - Promise.resolve({ - lastSeq: input.targetSeq, - cols: input.initialCols, - rows: input.initialRows, - cursorRow: 0, - cursorCol: 0, - }), - ); - const snapshotMock = vi.fn( - (): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 0, - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - isAltScreen: false, - visibleLines: [], - }), - ); - const screenshotMock = vi.fn( - (outputPath: string): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 0, - profileName: 'default', - cols: 80, - rows: 24, - artifactPath: outputPath, - pngSizeBytes: 1, - }), - ); - const getVisibleTextMock = vi.fn((): Promise => Promise.resolve('')); - const disposeMock = vi.fn((): Promise => { - booted = false; - return Promise.resolve(); - }); - - return { - rendererBackend: 'fake-renderer', - boot: bootMock, - bootMock, - replayTo: replayToMock, - replayToMock, - snapshot: snapshotMock, - snapshotMock, - screenshot: screenshotMock, - screenshotMock, - getVisibleText: getVisibleTextMock, - getVisibleTextMock, - dispose: disposeMock, - disposeMock, - get isBooted() { - return booted; - }, - setBooted(value: boolean) { - booted = value; - }, - }; -} - function createDeferred(): { promise: Promise; resolve: (value: T | PromiseLike) => void; diff --git a/test/unit/renderer/libghosttyVtBackend.test.ts b/test/unit/renderer/libghosttyVtBackend.test.ts index 1de9c7c..6f1145e 100644 --- a/test/unit/renderer/libghosttyVtBackend.test.ts +++ b/test/unit/renderer/libghosttyVtBackend.test.ts @@ -1,17 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import type { RendererBackend } from '../../../src/renderer/backend.js'; import { LibghosttyVtBackend } from '../../../src/renderer/libghosttyVt/backend.js'; import type { LibghosttyVtNativeModule } from '../../../src/renderer/libghosttyVt/backend.js'; import type { RenderProfileConfig, ReplayInput, - ReplayState, - ScreenshotResult, - SemanticSnapshot, } from '../../../src/renderer/types.js'; import { createLogger } from '../../../src/util/logger.js'; +import { createFakeBackend } from '../../helpers/fakeBackend.js'; + function createProfile(): RenderProfileConfig { return { name: 'reference-dark', @@ -126,75 +124,6 @@ function createNativeFixture(options: { visibleText?: string } = {}) { }; } -function createFallbackBackend(): RendererBackend & { - bootMock: ReturnType; - replayToMock: ReturnType; - screenshotMock: ReturnType; - disposeMock: ReturnType; -} { - let booted = false; - const bootMock = vi.fn(() => { - booted = true; - return Promise.resolve(); - }); - const replayToMock = vi.fn( - (input: ReplayInput): Promise => - Promise.resolve({ - lastSeq: input.targetSeq, - cols: input.initialCols, - rows: input.initialRows, - cursorRow: 0, - cursorCol: 0, - }), - ); - const screenshotMock = vi.fn( - (outputPath: string): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 2, - profileName: 'reference-dark', - cols: 12, - rows: 5, - artifactPath: outputPath, - pngSizeBytes: 123, - rendererBackend: 'ghostty-web', - }), - ); - const disposeMock = vi.fn(() => { - booted = false; - return Promise.resolve(); - }); - - return { - rendererBackend: 'ghostty-web', - get isBooted() { - return booted; - }, - boot: bootMock, - bootMock, - replayTo: replayToMock, - replayToMock, - snapshot: vi.fn( - (): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 2, - cols: 12, - rows: 5, - cursorRow: 0, - cursorCol: 0, - isAltScreen: false, - visibleLines: [], - }), - ), - screenshot: screenshotMock, - screenshotMock, - getVisibleText: vi.fn().mockResolvedValue(''), - dispose: disposeMock, - disposeMock, - }; -} - function createBackend( fixture = createNativeFixture(), options: Partial[2]> = {}, @@ -357,7 +286,17 @@ describe('LibghosttyVtBackend', () => { it('uses ghostty-web fallback for screenshots and preserves fallback metadata', async () => { const fixture = createNativeFixture(); - const fallback = createFallbackBackend(); + const fallback = createFakeBackend({ + rendererBackend: 'ghostty-web', + writePng: false, + resultOverrides: { + capturedAtSeq: 2, + cols: 12, + rows: 5, + pngSizeBytes: 123, + rendererBackend: 'ghostty-web', + }, + }); const backend = createBackend(fixture, { fallbackFactory: () => fallback, }); @@ -383,7 +322,17 @@ describe('LibghosttyVtBackend', () => { it('disposes native and fallback resources idempotently', async () => { const fixture = createNativeFixture(); - const fallback = createFallbackBackend(); + const fallback = createFakeBackend({ + rendererBackend: 'ghostty-web', + writePng: false, + resultOverrides: { + capturedAtSeq: 2, + cols: 12, + rows: 5, + pngSizeBytes: 123, + rendererBackend: 'ghostty-web', + }, + }); const backend = createBackend(fixture, { fallbackFactory: () => fallback, }); diff --git a/test/unit/renderer/registry.test.ts b/test/unit/renderer/registry.test.ts index 4e114b8..ab06cfe 100644 --- a/test/unit/renderer/registry.test.ts +++ b/test/unit/renderer/registry.test.ts @@ -1,18 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; -import type { RendererBackend } from '../../../src/renderer/backend.js'; import { DEFAULT_RENDERER_NAME, createRendererBackend, resolveRendererName, } from '../../../src/renderer/index.js'; -import type { - RenderProfileConfig, - ReplayInput, - ReplayState, - ScreenshotResult, - SemanticSnapshot, -} from '../../../src/renderer/types.js'; +import type { RenderProfileConfig } from '../../../src/renderer/types.js'; + +import { createFakeBackend } from '../../helpers/fakeBackend.js'; function createProfile(): RenderProfileConfig { return { @@ -26,51 +21,6 @@ function createProfile(): RenderProfileConfig { }; } -function createFakeBackend(rendererBackend: string): RendererBackend { - return { - rendererBackend, - isBooted: false, - boot: vi.fn().mockResolvedValue(undefined), - replayTo: vi.fn( - (input: ReplayInput): Promise => - Promise.resolve({ - lastSeq: input.targetSeq, - cols: input.initialCols, - rows: input.initialRows, - cursorRow: 0, - cursorCol: 0, - }), - ), - snapshot: vi.fn( - (): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 0, - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - isAltScreen: false, - visibleLines: [], - }), - ), - screenshot: vi.fn( - (outputPath: string): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 0, - profileName: 'reference-dark', - cols: 80, - rows: 24, - artifactPath: outputPath, - pngSizeBytes: 1, - }), - ), - getVisibleText: vi.fn().mockResolvedValue(''), - dispose: vi.fn().mockResolvedValue(undefined), - }; -} - describe('renderer registry', () => { it('resolves the default renderer name', () => { expect(DEFAULT_RENDERER_NAME).toBe('ghostty-web'); @@ -100,7 +50,7 @@ describe('renderer registry', () => { }); it('lazy-loads the native backend module for libghostty-vt only', async () => { - const fakeBackend = createFakeBackend('libghostty-vt'); + const fakeBackend = createFakeBackend({ rendererBackend: 'libghostty-vt' }); const LibghosttyVtBackend = vi.fn(function FakeLibghosttyVtBackend() { return fakeBackend; }); diff --git a/test/unit/screenshot/capture.test.ts b/test/unit/screenshot/capture.test.ts index b52ae8e..816e460 100644 --- a/test/unit/screenshot/capture.test.ts +++ b/test/unit/screenshot/capture.test.ts @@ -1,17 +1,11 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { createTemporarySessionDir } from '../../helpers.js'; -import type { RendererBackend } from '../../../src/renderer/backend.js'; -import type { ScreenshotResult } from '../../../src/protocol/messages.js'; -import type { - ReplayInput, - ReplayState, - SemanticSnapshot, -} from '../../../src/renderer/types.js'; +import type { ScreenshotResult } from '../../../src/renderer/types.js'; import { captureScreenshotResult, @@ -23,6 +17,8 @@ import { screenshotFilename, } from '../../../src/storage/artifactPaths.js'; +import { createFakeBackend } from '../../helpers/fakeBackend.js'; + const TEST_SHA256 = 'a'.repeat(64); const TEST_RENDER_PROFILE_HASH = 'b'.repeat(64); // First four bytes of the PNG magic signature (0x89 'P' 'N' 'G'). Enough to @@ -30,81 +26,6 @@ const TEST_RENDER_PROFILE_HASH = 'b'.repeat(64); // produces a real PNG, but these unit tests do not validate the format. const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47]); -interface FakeBackendOptions { - resultOverrides?: Partial; - writePng?: boolean; - fail?: Error; - onScreenshot?: ( - outputPath: string, - options?: { showCursor?: boolean }, - ) => void; -} - -function createFakeBackend(options: FakeBackendOptions = {}): RendererBackend { - const writePng = options.writePng ?? true; - - return { - rendererBackend: 'fake-renderer', - isBooted: false, - boot: vi.fn().mockResolvedValue(undefined), - replayTo: vi.fn( - (input: ReplayInput): Promise => - Promise.resolve({ - lastSeq: input.targetSeq, - cols: input.initialCols, - rows: input.initialRows, - cursorRow: 0, - cursorCol: 0, - }), - ), - snapshot: vi.fn( - (): Promise => - Promise.resolve({ - sessionId: 'session-01', - capturedAtSeq: 0, - cols: 80, - rows: 24, - cursorRow: 0, - cursorCol: 0, - isAltScreen: false, - visibleLines: [], - }), - ), - screenshot: vi.fn( - async ( - outputPath: string, - screenshotOptions?: { showCursor?: boolean }, - ): Promise => { - options.onScreenshot?.(outputPath, screenshotOptions); - if (options.fail !== undefined) { - throw options.fail; - } - if (writePng) { - await writeFile(outputPath, PNG_HEADER); - } - return { - sessionId: 'session-01', - capturedAtSeq: 5, - profileName: 'reference-dark', - cols: 80, - rows: 24, - artifactPath: outputPath, - pngSizeBytes: 4, - cursorVisible: screenshotOptions?.showCursor === true, - rendererBackend: 'fake-renderer', - pixelWidth: 800, - pixelHeight: 600, - sha256: TEST_SHA256, - renderProfileHash: TEST_RENDER_PROFILE_HASH, - ...options.resultOverrides, - }; - }, - ), - getVisibleText: vi.fn().mockResolvedValue(''), - dispose: vi.fn().mockResolvedValue(undefined), - }; -} - async function createSessionDir(sessionId = 'session-01'): Promise { return await createTemporarySessionDir( 'agent-tty-screenshot-capture-',