1041 lines · 34.2 KB · TypeScript
RawDownload
import path from "node:path"; 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 {  Duration,  Effect,  Encoding,  Exit,  Fiber,  FileSystem,  Option,  PlatformError,  Ref,  Schedule,  Scope,} from "effect";import { TestClock } from "effect/testing";import { expect } from "vitest"; import type { TerminalManagerShape } from "../Services/Manager";import {  type PtyAdapterShape,  type PtyExitEvent,  type PtyProcess,  type PtySpawnInput,  PtySpawnError,} from "../Services/PTY";import { makeTerminalManagerWithOptions } from "./Manager"; 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);  }} const waitFor = <E, R>(  predicate: Effect.Effect<boolean, E, R>,  timeout: Duration.Input = 800,): Effect.Effect<void, Error | E, R> =>  predicate.pipe(    Effect.filterOrFail(      (done) => done,      () => new Error("Condition not met"),    ),    Effect.retry(Schedule.spaced("15 millis")),    Effect.timeoutOption(timeout),    Effect.flatMap((result) =>      Option.match(result, {        onNone: () => Effect.fail(new Error("Timed out waiting for condition")),        onSome: () => Effect.void,      }),    ),  ); function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {  return {    threadId: "thread-1",    terminalId: DEFAULT_TERMINAL_ID,    cwd: process.cwd(),    cols: 100,    rows: 24,    ...overrides,  };} function restartInput(overrides: Partial<TerminalRestartInput> = {}): 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 path.join(logsDir, historyLogName(threadId));} function multiTerminalHistoryLogPath(  logsDir: string,  threadId = "thread-1",  terminalId = "default",): string {  return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));} interface CreateManagerOptions {  shellResolver?: () => string;  subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;  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<ReadonlyArray<TerminalEvent>>;} const createManager = (  historyLineLimit = 5,  options: CreateManagerOptions = {},): Effect.Effect<  ManagerFixture,  PlatformError.PlatformError,  FileSystem.FileSystem | Scope.Scope> =>  Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) =>    Effect.gen(function* () {      const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" });      const logsDir = path.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.subprocessChecker !== undefined          ? { subprocessChecker: options.subprocessChecker }          : {}),        ...(options.subprocessPollIntervalMs !== undefined          ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }          : {}),        ...(options.processKillGraceMs !== undefined          ? { processKillGraceMs: options.processKillGraceMs }          : {}),        ...(options.maxRetainedInactiveSessions !== undefined          ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }          : {}),      });      const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);      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* () {      const { manager, baseDir } = yield* createManager();      const blockedRoot = path.join(baseDir, "blocked-root");      const blockedCwd = path.join(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 = path.join(baseDir, "worktrees", "feature-a");      const secondWorktreePath = path.join(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<TerminalEvent, { type: "started" }> => event.type === "started",      );      const restartedEvent = events.find(        (event): event is Extract<TerminalEvent, { type: "restarted" }> =>          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 = path.join(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

Lines 1–500 of 1041.