Files
paseo/packages/server/src/terminal/terminal-manager.test.ts
T
Mohamed Boudra 177153e5e8 chore: apply oxlint autofix (safe level)
Auto-fixed 102 warnings (6040 → 5938) across 57 files. Main categories:
- preserve-caught-error: add { cause: error } to re-thrown errors
- unicorn/no-array-reverse: .reverse() → .toReversed()
- unicorn/no-useless-fallback-in-spread: drop unnecessary ?? {} in spreads
- oxc/no-map-spread: rewrite object spread in .map() to Object.assign
- react-hooks/exhaustive-deps: trim module-level constants from dep arrays

Ran npm run format after to clean up whitespace from lint mutations.
2026-04-23 21:47:36 +07:00

381 lines
13 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
async function waitForCondition(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 25,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
const originalShell = process.env.SHELL;
process.env.SHELL = shell;
try {
return await run();
} finally {
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
}
}
describe("TerminalManager", () => {
let manager: TerminalManager;
const temporaryDirs: string[] = [];
afterEach(() => {
if (manager) {
manager.killAll();
}
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("getTerminals", () => {
it("returns empty list for new cwd", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
expect(terminals).toHaveLength(0);
});
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
const first = await manager.getTerminals("/tmp");
const second = await manager.getTerminals("/tmp");
expect(first.length).toBe(1);
expect(first[0].id).toBe(created.id);
expect(second.length).toBe(1);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
});
it("accepts Windows absolute paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
});
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
});
});
describe("createTerminal", () => {
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
expect(second.name).toBe("Terminal 2");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(2);
});
it("uses custom name when provided", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(session.name).toBe("Dev Server");
});
it("creates first terminal if none exist", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(1);
expect(terminals[0].id).toBe(session.id);
});
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow(
"cwd must be absolute path",
);
});
it("does not reject Windows absolute paths as relative", async () => {
manager = createTerminalManager();
// Should pass path validation (not throw "cwd must be absolute path").
// The terminal may or may not spawn successfully on non-Windows hosts,
// so we only assert the validation error is absent.
try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
});
it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
temporaryDirs.push(cwd);
const markerPath = join(cwd, "root-port.txt");
manager.registerCwdEnv({
cwd,
env: { PASEO_WORKTREE_PORT: "45678" },
});
const session = await manager.createTerminal({ cwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45678");
});
});
it("inherits registered env for subdirectories within the worktree", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
const subdirCwd = join(rootCwd, "packages", "app");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const markerPath = join(subdirCwd, "subdir-port.txt");
manager.registerCwdEnv({
cwd: rootCwd,
env: { PASEO_WORKTREE_PORT: "45679" },
});
const session = await manager.createTerminal({ cwd: subdirCwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45679");
});
});
});
describe("getTerminal", () => {
it("returns terminal by id", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const found = manager.getTerminal(session.id);
expect(found).toBe(session);
});
it("returns undefined for unknown id", () => {
manager = createTerminalManager();
const found = manager.getTerminal("unknown-id");
expect(found).toBeUndefined();
});
});
describe("killTerminal", () => {
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const id = session.id;
manager.killTerminal(id);
expect(manager.getTerminal(id)).toBeUndefined();
});
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(created.id);
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain("/tmp");
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
it("is no-op for unknown id", () => {
manager = createTerminalManager();
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
});
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const exitedId = session.id;
session.kill();
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
});
});
describe("listDirectories", () => {
it("returns empty array initially", () => {
manager = createTerminalManager();
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/home" });
const dirs = manager.listDirectories();
expect(dirs).toContain("/tmp");
expect(dirs).toContain("/home");
expect(dirs.length).toBe(2);
});
});
describe("killAll", () => {
it("kills all terminals and clears state", async () => {
manager = createTerminalManager();
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
const homeSession = await manager.createTerminal({ cwd: "/home" });
const tmpId = tmpSession.id;
const homeId = homeSession.id;
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpId)).toBeUndefined();
expect(manager.getTerminal(homeId)).toBeUndefined();
});
});
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> =
[];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminals: input.terminals.map((terminal) => ({
name: terminal.name,
...(terminal.title ? { title: terminal.title } : {}),
})),
});
});
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
unsubscribe();
});
it("emits updated terminal titles after debounced title changes", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(
() =>
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
),
10000,
);
unsubscribe();
});
}, 10000);
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalCount: input.terminals.length,
});
});
const session = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalCount: 0,
});
unsubscribe();
});
});
});