import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; import { expect } from "vitest"; import type { TerminalManagerShape } from "../Services/Manager.ts"; import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError, } from "../Services/PTY.ts"; import { makeTerminalManagerWithOptions } from "./Manager.ts"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; readonly pid: number; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; constructor(pid: number) { this.pid = pid; } write(data: string): void { this.writes.push(data); } resize(cols: number, rows: number): void { this.resizeCalls.push({ cols, rows }); } kill(signal?: string): void { this.killed = true; this.killSignals.push(signal); } onData(callback: (data: string) => void): () => void { this.dataListeners.add(callback); return () => { this.dataListeners.delete(callback); }; } onExit(callback: (event: PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); }; } emitData(data: string): void { for (const listener of this.dataListeners) { listener(data); } } emitExit(event: PtyExitEvent): void { for (const listener of this.exitListeners) { listener(event); } } } class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; private readonly mode: "sync" | "async"; private nextPid = 9000; constructor(mode: "sync" | "async" = "sync") { this.mode = mode; } spawn(input: PtySpawnInput): Effect.Effect { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { return Effect.fail( new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process", cause: failure, }), ); } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ try: async () => process, catch: (cause) => new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process", cause, }), }); } return Effect.succeed(process); } } const waitFor = ( predicate: Effect.Effect, timeout: Duration.Input = 800, ): Effect.Effect => predicate.pipe( Effect.filter((done) => done), Effect.retry(Schedule.spaced("15 millis")), Effect.timeout(timeout), ); function openInput(overrides: Partial = {}): TerminalOpenInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } function restartInput(overrides: Partial = {}): TerminalRestartInput { return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: process.cwd(), cols: 100, rows: 24, ...overrides, }; } function historyLogName(threadId: string): string { return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; } function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; if (terminalId === DEFAULT_TERMINAL_ID) { return `${threadPart}.log`; } return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; } function historyLogPath(logsDir: string, threadId = "thread-1"): string { return joinPath(logsDir, historyLogName(threadId)); } function multiTerminalHistoryLogPath( logsDir: string, threadId = "thread-1", terminalId = "default", ): string { return joinPath(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); } function joinPath(...segments: ReadonlyArray): string { return segments.join("/"); } interface CreateManagerOptions { shellResolver?: () => string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; subprocessChecker?: (terminalPid: number) => Effect.Effect; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; ptyAdapter?: FakePtyAdapter; } interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; readonly manager: TerminalManagerShape; readonly getEvents: Effect.Effect>; } const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {}, ): Effect.Effect< ManagerFixture, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path | Scope.Scope > => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { const pathService = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); const logsDir = pathService.join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); const manager = yield* makeTerminalManagerWithOptions({ logsDir, historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), processKillGraceMs: options.processKillGraceMs ?? 1, ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), }); const eventsRef = yield* Ref.make>([]); const scope = yield* Effect.scope; const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), ); yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); return { baseDir, logsDir, ptyAdapter, manager, getEvents: Ref.get(eventsRef), }; }), ); it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => { it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); const [first, second] = yield* Effect.all( [manager.open(openInput()), manager.open(openInput())], { concurrency: "unbounded" }, ); const third = yield* manager.open(openInput()); assert.equal(first.threadId, "thread-1"); assert.equal(first.terminalId, "default"); assert.equal(second.threadId, "thread-1"); assert.equal(third.threadId, "thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); const makeDirectory = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.makeDirectory(filePath, { recursive: true }), ); const chmod = (filePath: string, mode: number) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); const readFileString = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); const writeFileString = (filePath: string, contents: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.writeFileString(filePath, contents), ); it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if (process.platform === "win32") return; const { manager, baseDir } = yield* createManager(); const blockedRoot = joinPath(baseDir, "blocked-root"); const blockedCwd = joinPath(blockedRoot, "cwd"); yield* makeDirectory(blockedCwd); yield* chmod(blockedRoot, 0o000); const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), ); expect(error).toMatchObject({ _tag: "TerminalCwdError", cwd: blockedCwd, reason: "statFailed", }); }), ); it.effect("supports asynchronous PTY spawn effects", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { ptyAdapter: new FakePtyAdapter("async"), }); const snapshot = yield* manager.open(openInput()); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(1); expect(ptyAdapter.processes).toHaveLength(1); }), ); it.effect("forwards write and resize to active pty process", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; yield* manager.write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "ls\n", }); yield* manager.resize({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 120, rows: 30, }); expect(process.writes).toEqual(["ls\n"]); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); }), ); it.effect("resizes running terminal on open when a different size is requested", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput({ cols: 100, rows: 24 })); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); assert.equal(reopened.status, "running"); expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); }), ); it.effect("supports multiple terminals per thread independently", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput({ terminalId: "default" })); yield* manager.open(openInput({ terminalId: "term-2" })); const first = ptyAdapter.processes[0]; const second = ptyAdapter.processes[1]; expect(first).toBeDefined(); expect(second).toBeDefined(); if (!first || !second) return; yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); expect(first.writes).toEqual(["pwd\n"]); expect(second.writes).toEqual(["ls\n"]); expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); it.effect("clears transcript and emits cleared event", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); const events = yield* getEvents; expect(events.some((event) => event.type === "cleared")).toBe(true); expect( events.some( (event) => event.type === "cleared" && event.threadId === "thread-1" && event.terminalId === "default", ), ).toBe(true); }), ); it.effect("restarts terminal with empty transcript and respawns pty", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir } = yield* createManager(); yield* manager.open(openInput()); const firstProcess = ptyAdapter.processes[0]; expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); const snapshot = yield* manager.restart(restartInput()); assert.equal(snapshot.history, ""); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); }), ); it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => Effect.gen(function* () { const { manager, getEvents, baseDir } = yield* createManager(); const firstWorktreePath = joinPath(baseDir, "worktrees", "feature-a"); const secondWorktreePath = joinPath(baseDir, "worktrees", "feature-b"); yield* makeDirectory(firstWorktreePath); yield* makeDirectory(secondWorktreePath); const startedSnapshot = yield* manager.open( openInput({ cwd: firstWorktreePath, worktreePath: firstWorktreePath, }), ); const restartedSnapshot = yield* manager.restart( restartInput({ cwd: secondWorktreePath, worktreePath: secondWorktreePath, }), ); assert.equal(startedSnapshot.worktreePath, firstWorktreePath); assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); const events = yield* getEvents; const startedEvent = events.find( (event): event is Extract => event.type === "started", ); const restartedEvent = events.find( (event): event is Extract => event.type === "restarted", ); assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); }), ); it.effect("preserves worktree metadata when reopening an exited session", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); const worktreePath = joinPath(baseDir, "worktrees", "feature-a"); yield* makeDirectory(worktreePath); yield* manager.open( openInput({ cwd: worktreePath, worktreePath, }), ); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), ); const reopenedSnapshot = yield* manager.open( openInput({ cwd: worktreePath, worktreePath, }), ); assert.equal(reopenedSnapshot.worktreePath, worktreePath); const events = yield* getEvents; const reopenedEvent = events .toReversed() .find( (event): event is Extract => event.type === "started", ); assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); }), ); it.effect("emits exited event and reopens with clean transcript after exit", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("old data\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), ); const reopened = yield* manager.open(openInput()); assert.equal(reopened.history, ""); expect(ptyAdapter.spawnInputs).toHaveLength(2); expect(yield* readFileString(historyLogPath(logsDir))).toBe(""); }), ); it.effect("ignores trailing writes after terminal exit", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitExit({ exitCode: 0, signal: 0 }); yield* manager.write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "\r", }); expect(process.writes).toEqual([]); }), ); it.effect("emits subprocess activity events when child-process state changes", () => Effect.gen(function* () { let hasRunningSubprocess = false; const { manager, getEvents } = yield* createManager(5, { subprocessChecker: () => Effect.succeed(hasRunningSubprocess), subprocessPollIntervalMs: 20, }); yield* manager.open(openInput()); expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); hasRunningSubprocess = true; yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), ), "1200 millis", ); hasRunningSubprocess = false; yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), ), "1200 millis", ); }), ); it.effect("does not invoke subprocess polling until a terminal session is running", () => Effect.gen(function* () { let checks = 0; const { manager } = yield* createManager(5, { subprocessChecker: () => { checks += 1; return Effect.succeed(false); }, subprocessPollIntervalMs: 20, }); yield* Effect.sleep("80 millis"); assert.equal(checks, 0); yield* manager.open(openInput()); yield* waitFor( Effect.sync(() => checks > 0), "1200 millis", ); }), ); it.effect("caps persisted history to configured line limit", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(3); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("line1\nline2\nline3\nline4\n"); yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); }), ); it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("prompt "); process.emitData("\u001b[32mok\u001b[0m "); process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); process.emitData("\u001b[1;1R"); process.emitData("done\n"); yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); }), ); it.effect( "preserves clear and style control sequences while dropping chunk-split query traffic", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("before clear\n"); process.emitData("\u001b[H\u001b[2J"); process.emitData("prompt "); process.emitData("\u001b]11;"); process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); process.emitData("R\u001b[36mdone\u001b[0m\n"); yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); assert.equal( reopened.history, "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", ); }), ); it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("before "); process.emitData("\u001b(B"); process.emitData("after\n"); yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); assert.equal(reopened.history, "before \u001b(Bafter\n"); }), ); it.effect( "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("before "); process.emitData("\u001b("); process.emitData("Bafter\n"); yield* manager.close({ threadId: "thread-1" }); const reopened = yield* manager.open(openInput()); assert.equal(reopened.history, "before \u001b(Bafter\n"); }), ); it.effect("deletes history file when close(deleteHistory=true)", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir } = yield* createManager(); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("bye\n"); yield* waitFor(pathExists(historyLogPath(logsDir))); yield* manager.close({ threadId: "thread-1", deleteHistory: true }); expect(yield* pathExists(historyLogPath(logsDir))).toBe(false); }), ); it.effect("closes all terminals for a thread when close omits terminalId", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir } = yield* createManager(); yield* manager.open(openInput({ terminalId: "default" })); yield* manager.open(openInput({ terminalId: "sidecar" })); const defaultProcess = ptyAdapter.processes[0]; const sidecarProcess = ptyAdapter.processes[1]; expect(defaultProcess).toBeDefined(); expect(sidecarProcess).toBeDefined(); if (!defaultProcess || !sidecarProcess) return; defaultProcess.emitData("default\n"); sidecarProcess.emitData("sidecar\n"); yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); yield* manager.close({ threadId: "thread-1", deleteHistory: true }); assert.equal(defaultProcess.killed, true); assert.equal(sidecarProcess.killed, true); expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe( false, ); expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe( false, ); }), ); it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); yield* Effect.yieldNow; yield* TestClock.adjust("10 millis"); yield* Fiber.join(closeFiber); assert.equal(process.killSignals[0], "SIGTERM"); expect(process.killSignals).toContain("SIGKILL"); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { maxRetainedInactiveSessions: 1, }); yield* manager.open(openInput({ threadId: "thread-1" })); yield* manager.open(openInput({ threadId: "thread-2" })); const first = ptyAdapter.processes[0]; const second = ptyAdapter.processes[1]; expect(first).toBeDefined(); expect(second).toBeDefined(); if (!first || !second) return; first.emitData("first-history\n"); second.emitData("second-history\n"); yield* waitFor(pathExists(historyLogPath(logsDir, "thread-1"))); first.emitExit({ exitCode: 0, signal: 0 }); yield* Effect.sleep(Duration.millis(5)); second.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map( getEvents, (events) => events.filter((event) => event.type === "exited").length === 2, ), ); const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); assert.equal(reopenedFirst.history, "first-history\n"); assert.equal(reopenedSecond.history, ""); }), ); it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager(); const legacyPath = joinPath(logsDir, "thread-1.log"); const nextPath = historyLogPath(logsDir); yield* writeFileString(legacyPath, "legacy-line\n"); const snapshot = yield* manager.open(openInput()); assert.equal(snapshot.history, "legacy-line\n"); expect(yield* pathExists(nextPath)).toBe(true); expect(yield* readFileString(nextPath)).toBe("legacy-line\n"); expect(yield* pathExists(legacyPath)).toBe(false); }), ); it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { const missingShell = process.platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => missingShell, }); ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); const snapshot = yield* manager.open(openInput()); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); expect(ptyAdapter.spawnInputs[0]?.shell).toBe( process.platform === "win32" ? missingShell : "/definitely/missing-shell", ); if (process.platform === "win32") { expect( ptyAdapter.spawnInputs.some( (input) => input.shell === "pwsh.exe" || input.shell === "powershell.exe" || input.shell === "cmd.exe", ), ).toBe(true); } else { expect( ptyAdapter.spawnInputs .slice(1) .some((input) => input.shell !== "/definitely/missing-shell"), ).toBe(true); } }), ); it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, }); yield* manager.open(openInput()); expect(ptyAdapter.spawnInputs[0]).toEqual( expect.objectContaining({ shell: "pwsh.exe", args: ["-NoLogo"], }), ); }), ); it.effect("falls back to built-in PowerShell by absolute path on Windows", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, shellResolver: () => "C:\\missing\\custom-shell.exe", }); ptyAdapter.spawnFailures.push( new Error("spawn custom-shell.exe ENOENT"), new Error("spawn pwsh.exe ENOENT"), ); yield* manager.open(openInput()); expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ "C:\\missing\\custom-shell.exe", "pwsh.exe", "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ]); expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); }), ); it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const originalValues = new Map(); const setEnv = (key: string, value: string | undefined) => { if (!originalValues.has(key)) { originalValues.set(key, process.env[key]); } if (value === undefined) { delete process.env[key]; return; } process.env[key] = value; }; const restoreEnv = () => { for (const [key, value] of originalValues) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } }; setEnv("PORT", "5173"); setEnv("T3CODE_PORT", "3773"); setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); setEnv("TEST_TERMINAL_KEEP", "keep-me"); try { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); const spawnInput = ptyAdapter.spawnInputs[0]; expect(spawnInput).toBeDefined(); if (!spawnInput) return; expect(spawnInput.env.PORT).toBeUndefined(); expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); } finally { restoreEnv(); } }), ); it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open( openInput({ env: { T3CODE_PROJECT_ROOT: "/repo", T3CODE_WORKTREE_PATH: "/repo/worktree-a", CUSTOM_FLAG: "1", }, }), ); const spawnInput = ptyAdapter.spawnInputs[0]; expect(spawnInput).toBeDefined(); if (!spawnInput) return; assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); }), ); it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if (process.platform === "win32") return; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => "/bin/zsh", }); yield* manager.open(openInput()); const spawnInput = ptyAdapter.spawnInputs[0]; expect(spawnInput).toBeDefined(); if (!spawnInput) return; expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); }), ); it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(5, { ptyAdapter: new FakePtyAdapter("async"), }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello from callback\n"); yield* waitFor( Effect.map(getEvents, (events) => events.some((event) => event.type === "output" && event.data === "hello from callback\n"), ), "1200 millis", ); }), ); it.effect("pushes PTY callbacks to direct event subscribers", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { ptyAdapter: new FakePtyAdapter("async"), }); const scope = yield* Effect.scope; const subscriberEvents = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => Ref.update(subscriberEvents, (events) => [...events, event]), ); yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello from subscriber\n"); yield* waitFor( Effect.map(Ref.get(subscriberEvents), (events) => events.some( (event) => event.type === "output" && event.data === "hello from subscriber\n", ), ), "1200 millis", ); }), ); it.effect("preserves queued PTY output ordering through exit callbacks", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(5, { ptyAdapter: new FakePtyAdapter("async"), }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("first\n"); process.emitData("second\n"); process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( Effect.map(getEvents, (events) => { const relevant = events.filter( (event) => event.type === "output" || event.type === "exited", ); return relevant.length >= 3; }), "1200 millis", ); const relevant = (yield* getEvents).filter( (event) => event.type === "output" || event.type === "exited", ); expect(relevant).toEqual([ expect.objectContaining({ type: "output", data: "first\n" }), expect.objectContaining({ type: "output", data: "second\n" }), expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }), ]); }), ); it.effect("scoped runtime shutdown stops active terminals cleanly", () => Effect.gen(function* () { const scope = yield* Scope.make("sequential"); const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10, }).pipe(Effect.provideService(Scope.Scope, scope)); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); yield* Effect.yieldNow; yield* TestClock.adjust("10 millis"); yield* Fiber.join(closeScope); assert.equal(process.killSignals[0], "SIGTERM"); expect(process.killSignals).toContain("SIGKILL"); }).pipe(Effect.provide(TestClock.layer())), ); });