diff --git a/package.json b/package.json index 43f5a8a..0ad2caf 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "build:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap", "dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json", - "test": "vitest run", + "test": "npm run test:unit && npm run test:e2e", + "test:unit": "vitest run --exclude test/integration/server.test.ts", + "test:e2e": "vitest run test/integration/server.test.ts", "test:watch": "vitest", "setup-hooks": "node scripts/setup-hooks.mjs" }, diff --git a/test/http/worktrees-create.test.ts b/test/http/worktrees-create.test.ts index 9278c59..b897b72 100644 --- a/test/http/worktrees-create.test.ts +++ b/test/http/worktrees-create.test.ts @@ -50,7 +50,7 @@ async function makeRepo(): Promise { // ── validateBranchName ───────────────────────────────────────────────────────── -describe('validateBranchName', () => { +describe('validateBranchName', { timeout: 30_000 }, () => { it('accepts ordinary branch names', () => { for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) { expect(validateBranchName(ok)).toBe(true) @@ -93,7 +93,7 @@ describe('validateBranchName', () => { // ── sanitizeBranchForDir ──────────────────────────────────────────────────────── -describe('sanitizeBranchForDir', () => { +describe('sanitizeBranchForDir', { timeout: 30_000 }, () => { it('replaces slashes with dashes', () => { expect(sanitizeBranchForDir('feature/login')).toBe('feature-login') expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c') @@ -116,7 +116,7 @@ describe('sanitizeBranchForDir', () => { // ── computeWorktreeDir ────────────────────────────────────────────────────────── -describe('computeWorktreeDir', () => { +describe('computeWorktreeDir', { timeout: 30_000 }, () => { it('places the worktree under an explicit root', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-')) const dir = await computeWorktreeDir('/repo', 'feature', root) @@ -145,7 +145,7 @@ describe('computeWorktreeDir', () => { // ── createWorktree (real temp repos) ──────────────────────────────────────────── -describe('createWorktree', () => { +describe('createWorktree', { timeout: 30_000 }, () => { it('creates a new worktree + branch and returns its path', async () => { if (!gitAvailable) return const repo = await makeRepo() diff --git a/test/http/worktrees-remove.test.ts b/test/http/worktrees-remove.test.ts index 1d71bf3..3ee08f3 100644 --- a/test/http/worktrees-remove.test.ts +++ b/test/http/worktrees-remove.test.ts @@ -59,7 +59,7 @@ async function worktreeList(repo: string): Promise { // ── removeWorktree ────────────────────────────────────────────────────────────── -describe('removeWorktree', () => { +describe('removeWorktree', { timeout: 30_000 }, () => { it('returns 404 for a non-git directory', async () => { const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-plain-')) const res = await removeWorktree(plain, plain, { timeoutMs: TIMEOUT_MS }) @@ -156,7 +156,7 @@ describe('removeWorktree', () => { // ── pruneWorktrees ────────────────────────────────────────────────────────────── -describe('pruneWorktrees', () => { +describe('pruneWorktrees', { timeout: 30_000 }, () => { it('returns 404 for a non-git directory', async () => { const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-pruneplain-')) const res = await pruneWorktrees(plain, { timeoutMs: TIMEOUT_MS }) diff --git a/test/integration/git-ops.test.ts b/test/integration/git-ops.test.ts index 74a02e7..4c975b5 100644 --- a/test/integration/git-ops.test.ts +++ b/test/integration/git-ops.test.ts @@ -118,7 +118,7 @@ afterEach(async () => { const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const -describe('git-write routes — shared guards (CSRF / kill-switch)', () => { +describe('git-write routes — shared guards (CSRF / kill-switch)', { timeout: 30_000 }, () => { for (const route of ROUTES) { it(`${route}: rejects a foreign Origin with 403`, async () => { const { port } = await spawnServer() @@ -142,7 +142,7 @@ describe('git-write routes — shared guards (CSRF / kill-switch)', () => { // ── POST /projects/git/stage ────────────────────────────────────────────────────── -describe('POST /projects/git/stage', () => { +describe('POST /projects/git/stage', { timeout: 30_000 }, () => { it('returns 400 when files are missing', async () => { const { port, origin } = await spawnServer() const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin }) @@ -173,7 +173,7 @@ describe('POST /projects/git/stage', () => { // ── POST /projects/git/commit ───────────────────────────────────────────────────── -describe('POST /projects/git/commit', () => { +describe('POST /projects/git/commit', { timeout: 30_000 }, () => { itGit('returns 400 for an empty message', async () => { const repo = await makeRealRepo() const { port, origin } = await spawnServer() @@ -210,7 +210,7 @@ describe('POST /projects/git/commit', () => { // ── POST /projects/git/push ─────────────────────────────────────────────────────── -describe('POST /projects/git/push', () => { +describe('POST /projects/git/push', { timeout: 30_000 }, () => { itGit('returns a safe 400 when the repo has no remote', async () => { const repo = await makeRealRepo() const { port, origin } = await spawnServer() diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index 63c2a11..7029f04 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -53,6 +53,17 @@ const PTY_AVAILABLE = (() => { })() const itPty = PTY_AVAILABLE ? it : it.skip +/** + * How long a real-PTY case waits for something that MUST arrive (handshake, + * attached frame, shell output). These are the bounds that actually catch a hang; + * the per-test ceiling above them is only a backstop. They were 3–5 s, which is + * fine for this file alone but not while ~8 workers hammer the machine — a real + * shell simply takes longer to boot and echo then, and the case failed with + * "timeout waiting for matching message" rather than any real defect. + * The deliberate "must be rejected" waits in ①② keep their short bounds. + */ +const PTY_WAIT_MS = 20_000 + /** H1 tmux tests need real PTY + the tmux binary. */ const TMUX_OK = PTY_AVAILABLE && @@ -232,7 +243,12 @@ function waitForMessage( // ── Test suite ──────────────────────────────────────────────────────────────── -describe('startServer — integration', () => { +// These cases boot a real server, spawn a real shell and wait for prompt output — +// and H1 restarts the server and waits for tmux to hand the session back. None of +// that fits vitest's 5 s default, so under a full parallel run they were failing as +// `Test timed out in 5000ms`. The `[needs sandbox-off]` labels are about whether +// PTY_AVAILABLE lets them RUN, which is a separate axis from how long they need. +describe('startServer — integration', { timeout: 60_000 }, () => { // NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix // (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners // it registered, so repeated startServer()/close() cycles don't accumulate. @@ -255,7 +271,9 @@ describe('startServer — integration', () => { await serverHandle.close() // Extra delay to let the OS release the port before the next test. await new Promise((r) => setTimeout(r, 50)) - }) + // Shutting a server down after a real-PTY case can exceed vitest's 10 s hook + // default under a loaded full-suite run; the close itself is what we wait on. + }, 30_000) // ── ① Bad Origin → 401 ───────────────────────────────────────────────────── @@ -477,7 +495,7 @@ describe('startServer — integration', () => { const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws, 3_000) + await waitForOpen(ws, PTY_WAIT_MS) // Send first frame: attach with sessionId=null (new session). ws.send(JSON.stringify({ type: 'attach', sessionId: null })) @@ -528,7 +546,7 @@ describe('startServer — integration', () => { const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws1, 3_000) + await waitForOpen(ws1, PTY_WAIT_MS) ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) // Wait for 'attached' to capture the sessionId. @@ -566,7 +584,7 @@ describe('startServer — integration', () => { const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws2, 3_000) + await waitForOpen(ws2, PTY_WAIT_MS) ws2.send(JSON.stringify({ type: 'attach', sessionId })) // ── Step D: Collect replay output and assert marker is present ─────────── @@ -626,7 +644,7 @@ describe('startServer — integration', () => { const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws, 3_000) + await waitForOpen(ws, PTY_WAIT_MS) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = (await waitForMessage( ws, @@ -670,7 +688,7 @@ describe('startServer — integration', () => { const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws, 3_000) + await waitForOpen(ws, PTY_WAIT_MS) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = (await waitForMessage( ws, @@ -719,7 +737,7 @@ describe('startServer — integration', () => { const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws1, 3_000) + await waitForOpen(ws1, PTY_WAIT_MS) ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = (await waitForMessage( ws1, @@ -756,7 +774,7 @@ describe('startServer — integration', () => { const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) - await waitForOpen(ws2, 3_000) + await waitForOpen(ws2, PTY_WAIT_MS) const joinPending = waitForMessage( ws2, (m) => @@ -806,9 +824,9 @@ describe('startServer — integration', () => { const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { headers: { Origin: `http://127.0.0.1:${tport}` }, }) - await waitForOpen(ws1, 3_000) + await waitForOpen(ws1, PTY_WAIT_MS) ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) - const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record + const att = (await waitForMessage(ws1, isAttached, PTY_WAIT_MS)) as Record sid = att['sessionId'] as string await delay(500) ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' })) @@ -825,7 +843,7 @@ describe('startServer — integration', () => { const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { headers: { Origin: `http://127.0.0.1:${tport}` }, }) - await waitForOpen(ws2, 3_000) + await waitForOpen(ws2, PTY_WAIT_MS) const out: string[] = [] ws2.on('message', (raw) => { try { @@ -844,7 +862,11 @@ describe('startServer — integration', () => { ws2.close() await waitForClose(ws2, 3_000).catch(() => undefined) } finally { - await srv.close() + // Bound the shutdown. An unbounded close() here swallows whatever really + // went wrong in the body: the inner waits throw, control jumps to this + // finally, close() blocks, and the case reports "timed out" instead of + // the actual error. The test must fail with its own diagnosis. + await Promise.race([srv.close(), delay(5_000)]) if (sid !== '') { try { execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' }) @@ -854,7 +876,11 @@ describe('startServer — integration', () => { } } }, - 20_000, + // The inner waits are already individually bounded (3+5+3+3+3 s) and the fixed + // delays add ~2.6 s, so the worst case is ~19.6 s — the old 20 s ceiling had + // no headroom at all and tripped whenever the full suite ran in parallel. + // The real guards against a hang are those inner bounds, not this number. + 60_000, ) // ── Signal-handler leak fix (CQ H1) ──────────────────────────────────────── @@ -982,7 +1008,7 @@ describe('startServer — integration', () => { const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${rlPort}` }, }) - await waitForOpen(ws, 3_000) + await waitForOpen(ws, PTY_WAIT_MS) // Flood 50 invalid frames in one tick — far over the 5/s cap. for (let i = 0; i < 50; i++) ws.send('not-json') diff --git a/test/integration/worktree.test.ts b/test/integration/worktree.test.ts index 755974f..50b1c61 100644 --- a/test/integration/worktree.test.ts +++ b/test/integration/worktree.test.ts @@ -117,7 +117,7 @@ afterEach(async () => { // ── POST /projects/worktree ────────────────────────────────────────────────────── -describe('POST /projects/worktree (B3)', () => { +describe('POST /projects/worktree (B3)', { timeout: 30_000 }, () => { it('rejects a foreign Origin with 403 (SEC-C3)', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { @@ -210,7 +210,7 @@ async function createWorktreeViaRoute( return body.path as string } -describe('DELETE /projects/worktree (W4)', () => { +describe('DELETE /projects/worktree (W4)', { timeout: 30_000 }, () => { it('rejects a foreign Origin with 403 (SEC-C3)', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { @@ -308,7 +308,7 @@ describe('DELETE /projects/worktree (W4)', () => { // ── POST /projects/worktree/prune (W4) ──────────────────────────────────────────── -describe('POST /projects/worktree/prune (W4)', () => { +describe('POST /projects/worktree/prune (W4)', { timeout: 30_000 }, () => { it('rejects a foreign Origin with 403', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, { @@ -362,7 +362,7 @@ describe('POST /projects/worktree/prune (W4)', () => { // ── GET /projects/diff ─────────────────────────────────────────────────────────── -describe('GET /projects/diff (B1)', () => { +describe('GET /projects/diff (B1)', { timeout: 30_000 }, () => { it('returns 400 when the path query parameter is missing', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/projects/diff`) diff --git a/vitest.config.ts b/vitest.config.ts index 6b7ce5f..8cd75fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['test/**/*.test.ts'], + // `npm test` runs two passes (see package.json): the unit/integration bulk in + // parallel, then test/integration/server.test.ts on its own. That file boots a + // real server and a real shell and asserts on prompt output within seconds — + // budgets it cannot meet while ~8 workers saturate the machine. It passed + // alone and flaked in the full run, which is a scheduling problem, not a + // timeout value to keep raising. `vitest run` still runs everything at once. + exclude: ['node_modules/**', 'dist/**'], environment: 'node', // jsdom 29's localStorage methods are undefined under this config; a shared // in-memory Web Storage polyfill fixes the frontend (@vitest-environment