766 lines · 24.1 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import {  DEFAULT_TERMINAL_ID,  type TerminalEvent,  type TerminalOpenInput,  type TerminalRestartInput,} from "@t3tools/contracts";import { afterEach, describe, expect, it } from "vitest"; import {  PtySpawnError,  type PtyAdapterShape,  type PtyExitEvent,  type PtyProcess,  type PtySpawnInput,} from "../Services/PTY";import { TerminalManagerRuntime } from "./Manager";import { Effect, Encoding } from "effect"; class FakePtyProcess implements PtyProcess {  readonly writes: string[] = [];  readonly resizeCalls: Array<{ cols: number; rows: number }> = [];  readonly killSignals: Array<string | undefined> = [];  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(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 nextPid = 9000;   constructor(private readonly mode: "sync" | "async" = "sync") {}   spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError> {    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);  }} 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 restartInput(overrides: Partial<TerminalRestartInput> = {}): TerminalRestartInput {  return {    threadId: "thread-1",    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 path.join(logsDir, historyLogName(threadId));} function multiTerminalHistoryLogPath(  logsDir: string,  threadId = "thread-1",  terminalId = "default",): string {  return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));} 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;      subprocessChecker?: (terminalPid: number) => Promise<boolean>;      subprocessPollIntervalMs?: number;      processKillGraceMs?: number;      maxRetainedInactiveSessions?: number;      ptyAdapter?: FakePtyAdapter;    } = {},  ) {    const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));    tempDirs.push(logsDir);    const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter();    const manager = new TerminalManagerRuntime({      logsDir,      ptyAdapter,      historyLineLimit,      shellResolver: options.shellResolver ?? (() => "/bin/bash"),      ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}),      ...(options.subprocessPollIntervalMs        ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }        : {}),      ...(options.processKillGraceMs ? { processKillGraceMs: options.processKillGraceMs } : {}),      ...(options.maxRetainedInactiveSessions        ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }        : {}),    });    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(first.terminalId).toBe("default");    expect(second.threadId).toBe("thread-1");    expect(third.threadId).toBe("thread-1");    expect(ptyAdapter.spawnInputs).toHaveLength(1);     manager.dispose();  });   it("supports asynchronous PTY spawn effects", async () => {    const { manager, ptyAdapter } = makeManager(5, { ptyAdapter: new FakePtyAdapter("async") });     const snapshot = await manager.open(openInput());     expect(snapshot.status).toBe("running");    expect(ptyAdapter.spawnInputs).toHaveLength(1);    expect(ptyAdapter.processes).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("resizes running terminal on open when a different size is requested", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open(openInput({ cols: 100, rows: 24 }));    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;     await manager.open(openInput({ cols: 140, rows: 40 }));     expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]);     manager.dispose();  });   it("preserves existing terminal size on open when size is omitted", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open(openInput({ cols: 100, rows: 24 }));    const ptyProcess = ptyAdapter.processes[0];    expect(ptyProcess).toBeDefined();    if (!ptyProcess) return;     await manager.open({      threadId: "thread-1",      cwd: globalThis.process.cwd(),    });     expect(ptyProcess.resizeCalls).toEqual([]);     ptyProcess.emitExit({ exitCode: 0, signal: 0 });    await manager.open({      threadId: "thread-1",      cwd: globalThis.process.cwd(),    });     const resumedSpawn = ptyAdapter.spawnInputs[1];    expect(resumedSpawn).toBeDefined();    if (!resumedSpawn) return;    expect(resumedSpawn.cols).toBe(100);    expect(resumedSpawn.rows).toBe(24);     manager.dispose();  });   it("uses default dimensions when opening a new terminal without size hints", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open({      threadId: "thread-1",      cwd: process.cwd(),    });     const spawned = ptyAdapter.spawnInputs[0];    expect(spawned).toBeDefined();    if (!spawned) return;    expect(spawned.cols).toBe(120);    expect(spawned.rows).toBe(30);     manager.dispose();  });   it("supports multiple terminals per thread with isolated sessions", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open(openInput({ terminalId: "default" }));    await 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;     await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });    await 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);     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);    expect(      events.some(        (event) =>          event.type === "cleared" &&          event.threadId === "thread-1" &&          event.terminalId === "default",      ),    ).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(restartInput());    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("ignores trailing writes after terminal exit", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;     process.emitExit({ exitCode: 0, signal: 0 });     await expect(manager.write({ threadId: "thread-1", data: "\r" })).resolves.toBeUndefined();    expect(process.writes).toEqual([]);     manager.dispose();  });   it("emits subprocess activity events when child-process state changes", async () => {    let hasRunningSubprocess = false;    const { manager } = makeManager(5, {      subprocessChecker: async () => hasRunningSubprocess,      subprocessPollIntervalMs: 20,    });    const events: TerminalEvent[] = [];    manager.on("event", (event) => {      events.push(event);    });     await manager.open(openInput());    await waitFor(() => events.some((event) => event.type === "started"));    expect(events.some((event) => event.type === "activity")).toBe(false);     hasRunningSubprocess = true;    await waitFor(      () =>        events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true),      1_200,    );     hasRunningSubprocess = false;    await waitFor(      () =>        events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false),      1_200,    );     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("strips replay-unsafe terminal query and reply sequences from persisted history", async () => {    const { manager, ptyAdapter } = makeManager();    await 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");     await manager.close({ threadId: "thread-1" });     const reopened = await manager.open(openInput());    expect(reopened.history).toBe("prompt \u001b[32mok\u001b[0m done\n");     manager.dispose();  });   it("preserves clear and style control sequences while dropping chunk-split query traffic", async () => {    const { manager, ptyAdapter } = makeManager();    await manager.open(openInput());    const process = ptyAdapter.processes[0];    expect(process).toBeDefined();    if (!process) return;     process.emitData("before clear\n");

Lines 1–500 of 766.