Files
paseo/packages/server/src/terminal/terminal-manager.test.ts
T

624 lines
20 KiB
TypeScript

import { it, expect, afterEach } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import type { TerminalWorkspaceContributionChangedEvent } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
}
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`);
}
let manager: TerminalManager;
const temporaryDirs: string[] = [];
afterEach(async () => {
if (manager) {
const terminalsByCwd = await Promise.all(
manager.listDirectories().map((cwd) => manager.getTerminals(cwd)),
);
for (const terminal of terminalsByCwd.flat()) {
await manager.killTerminalAndWait(terminal.id);
}
manager.killAll();
}
await new Promise((resolve) => setTimeout(resolve, 50));
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
try {
rmSync(dir, { recursive: true, force: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EBUSY") {
throw error;
}
}
}
}
});
it("returns empty list for new cwd", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const terminals = await manager.getTerminals(cwd);
expect(terminals).toHaveLength(0);
});
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const created = await manager.createTerminal({ cwd });
const first = await manager.getTerminals(cwd);
const second = await manager.getTerminals(cwd);
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 firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-"));
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-"));
temporaryDirs.push(firstCwd, secondCwd);
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })];
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })];
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
});
it("lists subdirectory terminals when querying the workspace root", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-subdir-root-"));
const subdirCwd = join(rootCwd, "apps", "mobile");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const created = await manager.createTerminal({ cwd: subdirCwd, name: "Mobile" });
const rootTerminals = await manager.getTerminals(rootCwd);
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
});
it("includes only stamped terminals in workspace-scoped queries", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const legacy = await manager.createTerminal({ cwd });
const owned = await manager.createTerminal({ cwd, workspaceId: "ws-owned" });
const sibling = await manager.createTerminal({ cwd, workspaceId: "ws-sibling" });
const scoped = await manager.getTerminals(cwd, { workspaceId: "ws-owned" });
const unscoped = await manager.getTerminals(cwd);
expect(scoped.map((terminal) => terminal.id)).toEqual([owned.id]);
expect(unscoped.map((terminal) => terminal.id)).toEqual([legacy.id, owned.id, sibling.id]);
});
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
expect(second.name).toBe("Terminal 2");
const terminals = await manager.getTerminals(cwd);
expect(terminals.length).toBe(2);
});
it("uses custom name when provided", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" });
expect(session.name).toBe("Dev Server");
});
it("creates first terminal if none exist", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals(cwd);
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();
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-absolute-path-"));
temporaryDirs.push(cwd);
try {
await manager.createTerminal({ cwd });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
});
it("inherits registered env for the worktree root cwd", 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" },
});
await manager.createTerminal({
cwd,
command: process.execPath,
args: [
"-e",
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
],
});
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45678");
});
it("inherits registered env for subdirectories within the worktree", 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" },
});
await manager.createTerminal({
cwd: subdirCwd,
command: process.execPath,
args: [
"-e",
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, process.env.PASEO_WORKTREE_PORT ?? '')`,
],
});
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45679");
});
it("returns terminal by id", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
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();
});
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
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 cwd = realpathSync(tmpdir());
const created = await manager.createTerminal({ cwd });
manager.killTerminal(created.id);
const remaining = await manager.getTerminals(cwd);
expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain(cwd);
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
const terminals = await manager.getTerminals(cwd);
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain(cwd);
const remaining = await manager.getTerminals(cwd);
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 cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
const exitedId = session.id;
session.kill();
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals(cwd);
expect(remaining).toHaveLength(0);
});
it("returns empty array initially", () => {
manager = createTerminalManager();
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-"));
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-"));
temporaryDirs.push(firstCwd, secondCwd);
await manager.createTerminal({ cwd: firstCwd });
await manager.createTerminal({ cwd: secondCwd });
const dirs = manager.listDirectories();
expect(dirs).toContain(firstCwd);
expect(dirs).toContain(secondCwd);
expect(dirs.length).toBe(2);
});
it("kills all terminals and clears state", async () => {
manager = createTerminalManager();
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-"));
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-"));
temporaryDirs.push(firstCwd, secondCwd);
const tmpSession = await manager.createTerminal({ cwd: firstCwd });
const homeSession = await manager.createTerminal({ cwd: secondCwd });
const tmpId = tmpSession.id;
const homeId = homeSession.id;
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpId)).toBeUndefined();
expect(manager.getTerminal(homeId)).toBeUndefined();
});
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string }> }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminals: input.terminals.map((terminal) => ({
name: terminal.name,
})),
});
});
await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd, name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd,
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd,
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
unsubscribe();
});
interface TerminalTitleEntry {
id: string;
title?: string;
}
function toTitleEntry(terminal: { id: string; title?: string }): TerminalTitleEntry {
return {
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
};
}
function hasLogsTitle(sessionId: string) {
const matches = (terminal: TerminalTitleEntry) =>
terminal.id === sessionId && terminal.title === "Logs";
return (snapshot: TerminalTitleEntry[]) => snapshot.some(matches);
}
it("emits updated terminal titles after debounced title changes", async () => {
manager = createTerminalManager();
const snapshots: TerminalTitleEntry[][] = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(input.terminals.map(toTitleEntry));
});
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
command: process.execPath,
args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"],
});
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
unsubscribe();
}, 10000);
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
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 });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
cwd,
terminalCount: 0,
});
unsubscribe();
});
it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
title: "Existing title",
});
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 } : {}),
})),
);
});
expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false);
expect(session.getTitle()).toBe("Existing title");
expect(session.getState().title).toBe("Existing title");
expect(snapshots).toEqual([]);
unsubscribe();
});
it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
expect(manager.setTerminalTitle(session.id, "x")).toBe(true);
expect(session.getTitle()).toBe("x");
});
it("new terminal starts with unknown activity", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
expect(session.getActivity()).toBeNull();
});
it("includes nullable activity in terminals-changed snapshot items", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const snapshots: Array<{ id: string; activityState: string | null }[]> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
activityState: terminal.activity?.state ?? null,
})),
);
});
const session = await manager.createTerminal({ cwd });
expect(snapshots.length).toBeGreaterThan(0);
const lastSnapshot = snapshots[snapshots.length - 1];
expect(lastSnapshot?.find((t) => t.id === session.id)?.activityState).toBeNull();
unsubscribe();
});
it("setTerminalActivity returns true and emits terminals-changed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
interface ActivityEntry {
id: string;
state: string | null;
}
const snapshots: ActivityEntry[][] = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
state: terminal.activity?.state ?? null,
})),
);
});
const session = await manager.createTerminal({ cwd });
await expect(manager.setTerminalActivity(session.id, "working")).resolves.toBe(true);
const hasWorkingActivity = (entries: ActivityEntry[]) =>
entries.some((e) => e.id === session.id && e.state === "working");
expect(snapshots.some(hasWorkingActivity)).toBe(true);
unsubscribe();
});
it("clearTerminalAttention returns attention terminals to idle", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
interface ActivityEntry {
id: string;
state: string | null;
}
const snapshots: ActivityEntry[][] = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
state: terminal.activity?.state ?? null,
})),
);
});
const session = await manager.createTerminal({ cwd });
await manager.setTerminalActivity(session.id, "attention");
await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(true);
expect(session.getActivity()?.state).toBe("idle");
expect(
snapshots.some((entries) =>
entries.some((entry) => entry.id === session.id && entry.state === "idle"),
),
).toBe(true);
unsubscribe();
});
it("clearTerminalAttention leaves non-attention terminals unchanged", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
await manager.setTerminalActivity(session.id, "working");
await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(false);
expect(session.getActivity()?.state).toBe("working");
});
it("emits workspace contribution event when activity bucket changes", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await manager.setTerminalActivity(session.id, "working");
expect(events).toEqual([
{
terminalId: session.id,
cwd,
workspaceId: "ws-test",
},
]);
unsubscribe();
});
it("does not emit workspace contribution event for title-only changes", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd });
manager.setTerminalTitle(session.id, "New title");
expect(events).toEqual([]);
unsubscribe();
});
it("emits workspace contribution event when a contributing terminal is removed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await manager.setTerminalActivity(session.id, "working");
events.length = 0;
manager.killTerminal(session.id);
expect(events).toEqual([
{
terminalId: session.id,
cwd,
workspaceId: "ws-test",
},
]);
unsubscribe();
});
it("does not emit workspace contribution event when an idle terminal is removed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const events: TerminalWorkspaceContributionChangedEvent[] = [];
const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => {
events.push(event);
});
const session = await manager.createTerminal({ cwd });
manager.killTerminal(session.id);
expect(events).toEqual([]);
unsubscribe();
});