diff --git a/src/session.ts b/src/session.ts index 98e05940..460358ac 100644 --- a/src/session.ts +++ b/src/session.ts @@ -180,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { const DEFAULT_PTY_COLS = 120; const DEFAULT_PTY_ROWS = 40; const TMUX_DISPLAY_TIMEOUT_MS = 2000; +const IS_TEST_MODE = !!process.env.VITEST; +const TEST_PTY_SCRIPT = 'process.stdin.pipe(process.stdout);'; /** Delay before the in-container Claude CLI version probe (lets the container start). */ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; @@ -1248,16 +1250,23 @@ export class Session extends EventEmitter { // No extra sleep — createSession() already waits for tmux readiness } - // Attach to the mux session via PTY - // Prevent tmux from letting the newest browser attach dictate global window - // size; accepted Codeman resize events update it explicitly below. - mux.setManualWindowSize?.(this._muxSession!.muxName); + // Integration tests need a live input/output transport without attaching to + // the host's tmux server or agent CLI. Production still uses the real mux. + if (!IS_TEST_MODE) { + // Prevent tmux from letting the newest browser attach dictate global window + // size; accepted Codeman resize events update it explicitly below. + mux.setManualWindowSize?.(this._muxSession!.muxName); + } // Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default). // MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the // default server, always fails for our socketed sessions, and silently falls back to 120x40. - const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket); + const { cols: ptyCols, rows: ptyRows } = IS_TEST_MODE + ? { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS } + : queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket); + const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand(); + const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName); try { - this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), { + this.ptyProcess = pty.spawn(attachCommand, attachArgs, { name: 'xterm-256color', cols: ptyCols, rows: ptyRows, @@ -2683,7 +2692,7 @@ export class Session extends EventEmitter { if (this.ptyProcess && (dimsChanged || options.force)) { this._ptyCols = cols; this._ptyRows = rows; - if (this._mux && this._muxSession) { + if (!IS_TEST_MODE && this._mux && this._muxSession) { this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows); } this.ptyProcess.resize(cols, rows); diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts index 330fe5d9..9611a401 100644 --- a/test/quick-start.test.ts +++ b/test/quick-start.test.ts @@ -1,11 +1,28 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { WebServer } from '../src/web/server.js'; -import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import type { WebServer } from '../src/web/server.js'; +import { existsSync, rmSync, mkdirSync, mkdtempSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { tmpdir } from 'node:os'; const TEST_PORT = 3099; -const CASES_DIR = join(homedir(), 'codeman-cases'); +const ORIGINAL_HOME = process.env.HOME; +const TEST_HOME = mkdtempSync(join(tmpdir(), 'codeman-quick-start-')); +const CASES_DIR = join(TEST_HOME, 'codeman-cases'); +let webServerModule: Promise | undefined; + +process.env.HOME = TEST_HOME; + +async function createTestServer(port: number): Promise { + webServerModule ??= import('../src/web/server.js'); + const { WebServer: TestWebServer } = await webServerModule; + return new TestWebServer(port, false, true); +} + +afterAll(() => { + if (ORIGINAL_HOME === undefined) delete process.env.HOME; + else process.env.HOME = ORIGINAL_HOME; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); describe('Quick Start API', () => { let server: WebServer; @@ -13,7 +30,7 @@ describe('Quick Start API', () => { const createdCases: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = await createTestServer(TEST_PORT); await server.start(); baseUrl = `http://localhost:${TEST_PORT}`; }); @@ -147,7 +164,7 @@ describe('Session Management', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = await createTestServer(TEST_PORT + 1); await server.start(); baseUrl = `http://localhost:${TEST_PORT + 1}`; }); @@ -206,7 +223,7 @@ describe('Case Management', () => { const createdCases: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT + 2, false, true); + server = await createTestServer(TEST_PORT + 2); await server.start(); baseUrl = `http://localhost:${TEST_PORT + 2}`; }); diff --git a/test/setup.ts b/test/setup.ts index 160a1f7c..59881fd6 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,16 +1,36 @@ /** * @fileoverview Global test setup for Codeman tests * - * SAFETY: TmuxManager has built-in test mode detection - * (via process.env.VITEST) that makes ALL shell commands no-ops. - * This means tests CANNOT kill, create, or interact with real tmux - * sessions regardless of what the test code does. + * SAFETY: The suite gets a temporary HOME and explicitly enables runtime test + * mode before application modules load. Tests therefore cannot touch the real + * Codeman state/cases tree or launch external tmux-backed agent sessions. * * This setup file strips shell-level auth configuration that can leak from a * running Codeman instance, then handles mock/timer cleanup between tests. */ -import { afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, vi } from 'vitest'; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalVitest = process.env.VITEST; +const originalPlaywrightBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; +const testHome = mkdtempSync(join(tmpdir(), 'codeman-vitest-')); + +if (originalPlaywrightBrowsersPath === undefined && originalHome) { + process.env.PLAYWRIGHT_BROWSERS_PATH = + process.platform === 'darwin' + ? join(originalHome, 'Library', 'Caches', 'ms-playwright') + : process.platform === 'win32' + ? join(process.env.LOCALAPPDATA || originalHome, 'ms-playwright') + : join(originalHome, '.cache', 'ms-playwright'); +} +process.env.HOME = testHome; +process.env.USERPROFILE = testHome; +process.env.VITEST = 'true'; delete process.env.CODEMAN_PASSWORD; delete process.env.CODEMAN_USERNAME; @@ -23,3 +43,19 @@ afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); + +afterAll(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + + if (originalVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = originalVitest; + + if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH; + else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath; + + rmSync(testHome, { recursive: true, force: true }); +});