308 lines · 9.3 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import type {  TerminalEvent,  TerminalOpenInput,} from "@t3tools/contracts";import { afterEach, describe, expect, it } from "vitest"; import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";import { TerminalManager } from "./terminalManager"; class FakePtyProcess implements PtyProcess {  readonly writes: string[] = [];  readonly resizeCalls: Array<{ cols: number; rows: number }> = [];  private readonly dataListeners = new Set<(data: string) => void>();  private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();  killed = false;   constructor(readonly pid: number) {}   write(data: string): void {    this.writes.push(data);  }   resize(cols: number, rows: number): void {    this.resizeCalls.push({ cols, rows });  }   kill(): void {    this.killed = true;  }   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 PtyAdapter {  readonly spawnInputs: PtySpawnInput[] = [];  readonly processes: FakePtyProcess[] = [];  readonly spawnFailures: Error[] = [];  private nextPid = 9000;   spawn(input: PtySpawnInput): PtyProcess {    this.spawnInputs.push(input);    const failure = this.spawnFailures.shift();    if (failure) {      throw failure;    }    const process = new FakePtyProcess(this.nextPid++);    this.processes.push(process);    return process;  }} function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> {  const started = Date.now();  return new Promise((resolve, reject) => {    const poll = () => {      if (predicate()) {        resolve();        return;      }      if (Date.now() - started > timeoutMs) {        reject(new Error("Timed out waiting for condition"));        return;      }      setTimeout(poll, 15);    };    poll();  });} function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {  return {    threadId: "thread-1",    cwd: process.cwd(),    cols: 100,    rows: 24,    ...overrides,  };} function historyLogName(threadId: string): string {  return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;} function historyLogPath(logsDir: string, threadId = "thread-1"): string {  return path.join(logsDir, historyLogName(threadId));} describe("TerminalManager", () => {  const tempDirs: string[] = [];   afterEach(() => {    for (const dir of tempDirs.splice(0, tempDirs.length)) {      fs.rmSync(dir, { recursive: true, force: true });    }  });   function makeManager(    historyLineLimit = 5,    options: { shellResolver?: () => string } = {},  ) {    const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));    tempDirs.push(logsDir);    const ptyAdapter = new FakePtyAdapter();    const manager = new TerminalManager({      logsDir,      ptyAdapter,      historyLineLimit,      shellResolver: options.shellResolver ?? (() => "/bin/bash"),    });    return { logsDir, ptyAdapter, manager };  }   it("spawns lazily and reuses running terminal per thread", async () => {    const { manager, ptyAdapter } = makeManager();    const [first, second] = await Promise.all([manager.open(openInput()), manager.open(openInput())]);    const third = await manager.open(openInput());     expect(first.threadId).toBe("thread-1");    expect(second.threadId).toBe("thread-1");    expect(third.threadId).toBe("thread-1");    expect(ptyAdapter.spawnInputs).toHaveLength(1);     manager.dispose();  });   it("forwards write and resize to active pty process", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;     await manager.write({ threadId: "thread-1", data: "ls\n" });    await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 });     expect(process.writes).toEqual(["ls\n"]);    expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);     manager.dispose();  });   it("clears transcript and emits cleared event", async () => {    const { manager, ptyAdapter, logsDir } = makeManager();    const events: TerminalEvent[] = [];    manager.on("event", (event) => {      events.push(event);    });    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;     process.emitData("hello\n");    await waitFor(() => fs.existsSync(historyLogPath(logsDir)));    await manager.clear({ threadId: "thread-1" });    await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");     expect(events.some((event) => event.type === "cleared")).toBe(true);     manager.dispose();  });   it("restarts terminal with empty transcript and respawns pty", async () => {    const { manager, ptyAdapter, logsDir } = makeManager();    await manager.open(openInput());    const firstProcess = ptyAdapter.processes[0];    expect(firstProcess).toBeDefined();    if (!firstProcess) return;    firstProcess.emitData("before restart\n");    await waitFor(() => fs.existsSync(historyLogPath(logsDir)));     const snapshot = await manager.restart(openInput());    expect(snapshot.history).toBe("");    expect(snapshot.status).toBe("running");    expect(ptyAdapter.spawnInputs).toHaveLength(2);    await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");     manager.dispose();  });   it("emits exited event and reopens with clean transcript after exit", async () => {    const { manager, ptyAdapter, logsDir } = makeManager();    const events: TerminalEvent[] = [];    manager.on("event", (event) => {      events.push(event);    });    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;    process.emitData("old data\n");    await waitFor(() => fs.existsSync(historyLogPath(logsDir)));    process.emitExit({ exitCode: 0, signal: 0 });     await waitFor(() => events.some((event) => event.type === "exited"));    const reopened = await manager.open(openInput());     expect(reopened.history).toBe("");    expect(ptyAdapter.spawnInputs).toHaveLength(2);    expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe("");     manager.dispose();  });   it("caps persisted history to configured line limit", async () => {    const { manager, ptyAdapter } = makeManager(3);    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;     process.emitData("line1\nline2\nline3\nline4\n");    await manager.close({ threadId: "thread-1" });     const reopened = await manager.open(openInput());    const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);    expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);     manager.dispose();  });   it("deletes history file when close(deleteHistory=true)", async () => {    const { manager, ptyAdapter, logsDir } = makeManager();    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;    process.emitData("bye\n");    await waitFor(() => fs.existsSync(historyLogPath(logsDir)));     await manager.close({ threadId: "thread-1", deleteHistory: true });    expect(fs.existsSync(historyLogPath(logsDir))).toBe(false);     manager.dispose();  });   it("loads existing legacy transcript filenames and keeps new naming for writes", async () => {    const { manager, logsDir } = makeManager();    const legacyPath = path.join(logsDir, "thread-1.log");    fs.writeFileSync(legacyPath, "legacy-line\n", "utf8");     const snapshot = await manager.open(openInput());     expect(snapshot.history).toBe("legacy-line\n");    expect(fs.existsSync(legacyPath)).toBe(true);     manager.dispose();  });   it("retries with fallback shells when preferred shell spawn fails", async () => {    const { manager, ptyAdapter } = makeManager(5, {      shellResolver: () => "/definitely/missing-shell -l",    });    ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));     const snapshot = await manager.open(openInput());     expect(snapshot.status).toBe("running");    expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);    expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");     if (process.platform === "win32") {      expect(        ptyAdapter.spawnInputs.some(          (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",        ),      ).toBe(true);    } else {      expect(        ptyAdapter.spawnInputs.some((input) =>          ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(            input.shell,          ),        ),      ).toBe(true);    }     manager.dispose();  });});