Run server tests on Linux+Windows matrix (#809)

* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
This commit is contained in:
Mohamed Boudra
2026-05-08 10:57:22 +08:00
committed by GitHub
parent 39e461b872
commit c4e4a28bc0
44 changed files with 5673 additions and 4899 deletions
@@ -1,9 +1,14 @@
import { it, expect, afterEach } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
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,
@@ -19,47 +24,52 @@ async function waitForCondition(
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;
}
}
}
let manager: TerminalManager;
const temporaryDirs: string[] = [];
afterEach(() => {
afterEach(async () => {
if (manager) {
const terminalsByCwd = await Promise.all(
manager.listDirectories().map((cwd) => manager.getTerminals(cwd)),
);
await Promise.all(
terminalsByCwd
.flat()
.map((terminal) =>
manager.killTerminalAndWait(terminal.id, { gracefulTimeoutMs: 50, forceTimeoutMs: 50 }),
),
);
manager.killAll();
}
await new Promise((resolve) => setTimeout(resolve, 50));
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
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 terminals = await manager.getTerminals("/tmp");
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 created = await manager.createTerminal({ cwd: "/tmp" });
const first = await manager.getTerminals("/tmp");
const second = await manager.getTerminals("/tmp");
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);
@@ -79,8 +89,11 @@ it("accepts Windows absolute paths", async () => {
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
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);
@@ -89,29 +102,31 @@ it("creates separate terminals for different cwds", async () => {
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
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("/tmp");
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: "/tmp", name: "Dev Server" });
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 session = await manager.createTerminal({ cwd: "/tmp" });
const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
expect(session.name).toBe("Terminal 1");
const terminals = await manager.getTerminals("/tmp");
const terminals = await manager.getTerminals(cwd);
expect(terminals.length).toBe(1);
expect(terminals[0].id).toBe(session.id);
});
@@ -123,71 +138,67 @@ it("throws for relative paths", async () => {
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.
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-absolute-path-"));
temporaryDirs.push(cwd);
try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
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 () => {
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 = 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");
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 () => {
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 = 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");
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: "/tmp" });
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const found = manager.getTerminal(session.id);
expect(found).toBe(session);
@@ -202,7 +213,7 @@ it("returns undefined for unknown id", () => {
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const id = session.id;
manager.killTerminal(id);
@@ -212,24 +223,26 @@ it("removes terminal from manager", async () => {
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
const cwd = realpathSync(tmpdir());
const created = await manager.createTerminal({ cwd });
manager.killTerminal(created.id);
const remaining = await manager.getTerminals("/tmp");
const remaining = await manager.getTerminals(cwd);
expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain("/tmp");
expect(manager.listDirectories()).not.toContain(cwd);
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
const cwd = realpathSync(tmpdir());
await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
const terminals = await manager.getTerminals("/tmp");
const terminals = await manager.getTerminals(cwd);
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(manager.listDirectories()).toContain(cwd);
const remaining = await manager.getTerminals(cwd);
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
@@ -241,7 +254,8 @@ it("is no-op for unknown id", () => {
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: "/tmp" });
const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
const exitedId = session.id;
session.kill();
@@ -249,7 +263,7 @@ it("auto-removes terminal when shell exits", async () => {
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
const remaining = await manager.getTerminals(cwd);
expect(remaining).toHaveLength(0);
});
@@ -260,19 +274,25 @@ it("returns empty array initially", () => {
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/home" });
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("/tmp");
expect(dirs).toContain("/home");
expect(dirs).toContain(firstCwd);
expect(dirs).toContain(secondCwd);
expect(dirs.length).toBe(2);
});
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 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;
@@ -285,6 +305,7 @@ it("kills all terminals and clears state", async () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
@@ -296,15 +317,15 @@ it("emits cwd snapshots when terminals are created", async () => {
});
});
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd, name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd: "/tmp",
cwd,
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
cwd,
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
@@ -330,24 +351,26 @@ function hasLogsTitle(sessionId: string) {
}
it("emits updated terminal titles after debounced title changes", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: TerminalTitleEntry[][] = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(input.terminals.map(toTitleEntry));
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000);
unsubscribe();
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({
@@ -356,11 +379,11 @@ it("emits empty snapshot when last terminal is removed", async () => {
});
});
const session = await manager.createTerminal({ cwd: "/tmp" });
const session = await manager.createTerminal({ cwd });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
cwd: "/tmp",
cwd,
terminalCount: 0,
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff