From 29a17a662fc91160f8f5973ca2c22d07ee2fbd89 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 11 May 2026 11:37:49 +0200 Subject: [PATCH 1/2] refactor(test): consolidate createFakeBackend factories into shared helper Introduces test/helpers/fakeBackend.ts exporting a single createFakeBackend() with the union of capabilities needed across all four test files (bootImplementation, resultOverrides, writePng, fail, onScreenshot). Migrates all four call sites to the shared helper, removing ~300 lines of duplicated stub code. Closes #81 Change-Id: Ie0f6ec7dded45bf11bae732612cb4b581b64a9e9 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Thomas Kosiewski --- test/helpers/fakeBackend.ts | 138 ++++++++++++++++++ test/unit/host/renderer.test.ts | 104 +------------ .../unit/renderer/libghosttyVtBackend.test.ts | 98 +++---------- test/unit/renderer/registry.test.ts | 54 +------ test/unit/screenshot/capture.test.ts | 87 +---------- 5 files changed, 174 insertions(+), 307 deletions(-) create mode 100644 test/helpers/fakeBackend.ts 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..f86a766 100644 --- a/test/unit/host/renderer.test.ts +++ b/test/unit/host/renderer.test.ts @@ -6,26 +6,12 @@ import { setImmediate as setImmediatePromise } from 'node:timers/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HostRendererManager } from '../../../src/host/renderer.js'; -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 type { RenderProfileConfig, ReplayInput } from '../../../src/renderer/types.js'; + +import { + createFakeBackend, + type FakeRendererBackend, +} from '../../helpers/fakeBackend.js'; function createProfile(name = 'default'): RenderProfileConfig { return { @@ -57,84 +43,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..fa01557 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,74 +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(), @@ -357,7 +287,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 +323,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..113cc5f 100644 --- a/test/unit/renderer/registry.test.ts +++ b/test/unit/renderer/registry.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { RendererBackend } from '../../../src/renderer/backend.js'; import { DEFAULT_RENDERER_NAME, createRendererBackend, @@ -8,12 +7,10 @@ import { } from '../../../src/renderer/index.js'; import type { RenderProfileConfig, - ReplayInput, - ReplayState, - ScreenshotResult, - SemanticSnapshot, } from '../../../src/renderer/types.js'; +import { createFakeBackend } from '../../helpers/fakeBackend.js'; + function createProfile(): RenderProfileConfig { return { name: 'reference-dark', @@ -26,51 +23,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 +52,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..5cabe63 100644 --- a/test/unit/screenshot/capture.test.ts +++ b/test/unit/screenshot/capture.test.ts @@ -1,18 +1,10 @@ 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 { captureScreenshotResult, parseScreenshotResult, @@ -23,6 +15,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 +24,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-', From 275f512589a1bd37853f18fb490c5c5a3cfcac7f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 11 May 2026 11:45:49 +0200 Subject: [PATCH 2/2] fix(test): restore missing imports dropped during consolidation Re-add RendererBackend import in host/renderer.test.ts (used by the local BackendFactory type alias) and ScreenshotResult import in capture.test.ts (used by the it.each type casts). Also apply oxfmt formatting fixes to the three files flagged by CI. Change-Id: Iaf9d99786edf4858827c6e930e67563227fe5fa3 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Thomas Kosiewski --- test/unit/host/renderer.test.ts | 6 +++++- test/unit/renderer/libghosttyVtBackend.test.ts | 1 - test/unit/renderer/registry.test.ts | 4 +--- test/unit/screenshot/capture.test.ts | 2 ++ 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/test/unit/host/renderer.test.ts b/test/unit/host/renderer.test.ts index f86a766..2c15bd0 100644 --- a/test/unit/host/renderer.test.ts +++ b/test/unit/host/renderer.test.ts @@ -6,7 +6,11 @@ import { setImmediate as setImmediatePromise } from 'node:timers/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HostRendererManager } from '../../../src/host/renderer.js'; -import type { RenderProfileConfig, ReplayInput } from '../../../src/renderer/types.js'; +import type { RendererBackend } from '../../../src/renderer/backend.js'; +import type { + RenderProfileConfig, + ReplayInput, +} from '../../../src/renderer/types.js'; import { createFakeBackend, diff --git a/test/unit/renderer/libghosttyVtBackend.test.ts b/test/unit/renderer/libghosttyVtBackend.test.ts index fa01557..6f1145e 100644 --- a/test/unit/renderer/libghosttyVtBackend.test.ts +++ b/test/unit/renderer/libghosttyVtBackend.test.ts @@ -124,7 +124,6 @@ function createNativeFixture(options: { visibleText?: string } = {}) { }; } - function createBackend( fixture = createNativeFixture(), options: Partial[2]> = {}, diff --git a/test/unit/renderer/registry.test.ts b/test/unit/renderer/registry.test.ts index 113cc5f..ab06cfe 100644 --- a/test/unit/renderer/registry.test.ts +++ b/test/unit/renderer/registry.test.ts @@ -5,9 +5,7 @@ import { createRendererBackend, resolveRendererName, } from '../../../src/renderer/index.js'; -import type { - RenderProfileConfig, -} from '../../../src/renderer/types.js'; +import type { RenderProfileConfig } from '../../../src/renderer/types.js'; import { createFakeBackend } from '../../helpers/fakeBackend.js'; diff --git a/test/unit/screenshot/capture.test.ts b/test/unit/screenshot/capture.test.ts index 5cabe63..816e460 100644 --- a/test/unit/screenshot/capture.test.ts +++ b/test/unit/screenshot/capture.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'; import { createTemporarySessionDir } from '../../helpers.js'; +import type { ScreenshotResult } from '../../../src/renderer/types.js'; + import { captureScreenshotResult, parseScreenshotResult,