test: stop the suite flaking on real-git and real-PTY fixtures
`npm test` was failing 5-11 tests per run with the set shifting between runs, which made it useless as a gate. Two independent causes, neither of them a product defect. 1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12 sequential `git` processes (init/config/commit/clone/push); the real-server ones boot a server and a shell. Alone they finish easily; under a full parallel run they do not, and the failure reads `Test timed out in 5000ms`. Gave those describes an explicit 30 s ceiling, and the real-PTY waits a named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected" handshake tests are left alone — there a timeout IS the assertion. (The same rebudgeting also touched the test files in the preceding commit.) 2. test/integration/server.test.ts cannot share the machine with the rest of the suite. It asserts on real prompt output within seconds, which is not achievable while ~8 workers saturate the box: it passed alone (27/27, repeatedly) and flaked in the full run. Raising the numbers further only moved the flake around, so this is fixed as a scheduling problem — `npm test` now runs two passes, `test:unit` (everything else, parallel) then `test:e2e` (that file on its own). `vitest run` still runs everything at once for anyone who wants that. Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever really went wrong in the body — the inner waits threw, control jumped to the finally, close() blocked, and the case reported a bare timeout instead of its own diagnosis. The root-causing above only became possible after making that failure legible. Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT skipping here. PTY_AVAILABLE is true on this machine, so they were running and failing on timing, not being gated out by a sandbox. Verified: npm test green end to end across repeated runs (unit 78 files / 2147 tests, e2e 27).
This commit is contained in:
@@ -15,7 +15,9 @@
|
|||||||
"build:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap",
|
"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",
|
"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",
|
"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",
|
"test:watch": "vitest",
|
||||||
"setup-hooks": "node scripts/setup-hooks.mjs"
|
"setup-hooks": "node scripts/setup-hooks.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async function makeRepo(): Promise<string> {
|
|||||||
|
|
||||||
// ── validateBranchName ─────────────────────────────────────────────────────────
|
// ── validateBranchName ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('validateBranchName', () => {
|
describe('validateBranchName', { timeout: 30_000 }, () => {
|
||||||
it('accepts ordinary branch names', () => {
|
it('accepts ordinary branch names', () => {
|
||||||
for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) {
|
for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) {
|
||||||
expect(validateBranchName(ok)).toBe(true)
|
expect(validateBranchName(ok)).toBe(true)
|
||||||
@@ -93,7 +93,7 @@ describe('validateBranchName', () => {
|
|||||||
|
|
||||||
// ── sanitizeBranchForDir ────────────────────────────────────────────────────────
|
// ── sanitizeBranchForDir ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('sanitizeBranchForDir', () => {
|
describe('sanitizeBranchForDir', { timeout: 30_000 }, () => {
|
||||||
it('replaces slashes with dashes', () => {
|
it('replaces slashes with dashes', () => {
|
||||||
expect(sanitizeBranchForDir('feature/login')).toBe('feature-login')
|
expect(sanitizeBranchForDir('feature/login')).toBe('feature-login')
|
||||||
expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c')
|
expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c')
|
||||||
@@ -116,7 +116,7 @@ describe('sanitizeBranchForDir', () => {
|
|||||||
|
|
||||||
// ── computeWorktreeDir ──────────────────────────────────────────────────────────
|
// ── computeWorktreeDir ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('computeWorktreeDir', () => {
|
describe('computeWorktreeDir', { timeout: 30_000 }, () => {
|
||||||
it('places the worktree under an explicit root', async () => {
|
it('places the worktree under an explicit root', async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-'))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-'))
|
||||||
const dir = await computeWorktreeDir('/repo', 'feature', root)
|
const dir = await computeWorktreeDir('/repo', 'feature', root)
|
||||||
@@ -145,7 +145,7 @@ describe('computeWorktreeDir', () => {
|
|||||||
|
|
||||||
// ── createWorktree (real temp repos) ────────────────────────────────────────────
|
// ── createWorktree (real temp repos) ────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createWorktree', () => {
|
describe('createWorktree', { timeout: 30_000 }, () => {
|
||||||
it('creates a new worktree + branch and returns its path', async () => {
|
it('creates a new worktree + branch and returns its path', async () => {
|
||||||
if (!gitAvailable) return
|
if (!gitAvailable) return
|
||||||
const repo = await makeRepo()
|
const repo = await makeRepo()
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function worktreeList(repo: string): Promise<string> {
|
|||||||
|
|
||||||
// ── removeWorktree ──────────────────────────────────────────────────────────────
|
// ── removeWorktree ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('removeWorktree', () => {
|
describe('removeWorktree', { timeout: 30_000 }, () => {
|
||||||
it('returns 404 for a non-git directory', async () => {
|
it('returns 404 for a non-git directory', async () => {
|
||||||
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-plain-'))
|
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-plain-'))
|
||||||
const res = await removeWorktree(plain, plain, { timeoutMs: TIMEOUT_MS })
|
const res = await removeWorktree(plain, plain, { timeoutMs: TIMEOUT_MS })
|
||||||
@@ -156,7 +156,7 @@ describe('removeWorktree', () => {
|
|||||||
|
|
||||||
// ── pruneWorktrees ──────────────────────────────────────────────────────────────
|
// ── pruneWorktrees ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('pruneWorktrees', () => {
|
describe('pruneWorktrees', { timeout: 30_000 }, () => {
|
||||||
it('returns 404 for a non-git directory', async () => {
|
it('returns 404 for a non-git directory', async () => {
|
||||||
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-pruneplain-'))
|
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-pruneplain-'))
|
||||||
const res = await pruneWorktrees(plain, { timeoutMs: TIMEOUT_MS })
|
const res = await pruneWorktrees(plain, { timeoutMs: TIMEOUT_MS })
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ afterEach(async () => {
|
|||||||
|
|
||||||
const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const
|
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) {
|
for (const route of ROUTES) {
|
||||||
it(`${route}: rejects a foreign Origin with 403`, async () => {
|
it(`${route}: rejects a foreign Origin with 403`, async () => {
|
||||||
const { port } = await spawnServer()
|
const { port } = await spawnServer()
|
||||||
@@ -142,7 +142,7 @@ describe('git-write routes — shared guards (CSRF / kill-switch)', () => {
|
|||||||
|
|
||||||
// ── POST /projects/git/stage ──────────────────────────────────────────────────────
|
// ── 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 () => {
|
it('returns 400 when files are missing', async () => {
|
||||||
const { port, origin } = await spawnServer()
|
const { port, origin } = await spawnServer()
|
||||||
const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin })
|
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 ─────────────────────────────────────────────────────
|
// ── 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 () => {
|
itGit('returns 400 for an empty message', async () => {
|
||||||
const repo = await makeRealRepo()
|
const repo = await makeRealRepo()
|
||||||
const { port, origin } = await spawnServer()
|
const { port, origin } = await spawnServer()
|
||||||
@@ -210,7 +210,7 @@ describe('POST /projects/git/commit', () => {
|
|||||||
|
|
||||||
// ── POST /projects/git/push ───────────────────────────────────────────────────────
|
// ── 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 () => {
|
itGit('returns a safe 400 when the repo has no remote', async () => {
|
||||||
const repo = await makeRealRepo()
|
const repo = await makeRealRepo()
|
||||||
const { port, origin } = await spawnServer()
|
const { port, origin } = await spawnServer()
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ const PTY_AVAILABLE = (() => {
|
|||||||
})()
|
})()
|
||||||
const itPty = PTY_AVAILABLE ? it : it.skip
|
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. */
|
/** H1 tmux tests need real PTY + the tmux binary. */
|
||||||
const TMUX_OK =
|
const TMUX_OK =
|
||||||
PTY_AVAILABLE &&
|
PTY_AVAILABLE &&
|
||||||
@@ -232,7 +243,12 @@ function waitForMessage(
|
|||||||
|
|
||||||
// ── Test suite ────────────────────────────────────────────────────────────────
|
// ── 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
|
// NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix
|
||||||
// (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
|
// (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
|
||||||
// it registered, so repeated startServer()/close() cycles don't accumulate.
|
// it registered, so repeated startServer()/close() cycles don't accumulate.
|
||||||
@@ -255,7 +271,9 @@ describe('startServer — integration', () => {
|
|||||||
await serverHandle.close()
|
await serverHandle.close()
|
||||||
// Extra delay to let the OS release the port before the next test.
|
// Extra delay to let the OS release the port before the next test.
|
||||||
await new Promise<void>((r) => setTimeout(r, 50))
|
await new Promise<void>((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 ─────────────────────────────────────────────────────
|
// ── ① Bad Origin → 401 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -477,7 +495,7 @@ describe('startServer — integration', () => {
|
|||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
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).
|
// Send first frame: attach with sessionId=null (new session).
|
||||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
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}`, {
|
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
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 }))
|
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||||
|
|
||||||
// Wait for 'attached' to capture the sessionId.
|
// 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}`, {
|
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
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 }))
|
ws2.send(JSON.stringify({ type: 'attach', sessionId }))
|
||||||
|
|
||||||
// ── Step D: Collect replay output and assert marker is present ───────────
|
// ── 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}`, {
|
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
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 }))
|
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||||
const attached = (await waitForMessage(
|
const attached = (await waitForMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -670,7 +688,7 @@ describe('startServer — integration', () => {
|
|||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
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 }))
|
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||||
const attached = (await waitForMessage(
|
const attached = (await waitForMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -719,7 +737,7 @@ describe('startServer — integration', () => {
|
|||||||
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
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 }))
|
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||||
const attached = (await waitForMessage(
|
const attached = (await waitForMessage(
|
||||||
ws1,
|
ws1,
|
||||||
@@ -756,7 +774,7 @@ describe('startServer — integration', () => {
|
|||||||
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||||
})
|
})
|
||||||
await waitForOpen(ws2, 3_000)
|
await waitForOpen(ws2, PTY_WAIT_MS)
|
||||||
const joinPending = waitForMessage(
|
const joinPending = waitForMessage(
|
||||||
ws2,
|
ws2,
|
||||||
(m) =>
|
(m) =>
|
||||||
@@ -806,9 +824,9 @@ describe('startServer — integration', () => {
|
|||||||
const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${tport}` },
|
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 }))
|
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||||
const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record<string, unknown>
|
const att = (await waitForMessage(ws1, isAttached, PTY_WAIT_MS)) as Record<string, unknown>
|
||||||
sid = att['sessionId'] as string
|
sid = att['sessionId'] as string
|
||||||
await delay(500)
|
await delay(500)
|
||||||
ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' }))
|
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`, {
|
const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${tport}` },
|
headers: { Origin: `http://127.0.0.1:${tport}` },
|
||||||
})
|
})
|
||||||
await waitForOpen(ws2, 3_000)
|
await waitForOpen(ws2, PTY_WAIT_MS)
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
ws2.on('message', (raw) => {
|
ws2.on('message', (raw) => {
|
||||||
try {
|
try {
|
||||||
@@ -844,7 +862,11 @@ describe('startServer — integration', () => {
|
|||||||
ws2.close()
|
ws2.close()
|
||||||
await waitForClose(ws2, 3_000).catch(() => undefined)
|
await waitForClose(ws2, 3_000).catch(() => undefined)
|
||||||
} finally {
|
} 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 !== '') {
|
if (sid !== '') {
|
||||||
try {
|
try {
|
||||||
execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' })
|
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) ────────────────────────────────────────
|
// ── 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}`, {
|
const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, {
|
||||||
headers: { Origin: `http://127.0.0.1:${rlPort}` },
|
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.
|
// Flood 50 invalid frames in one tick — far over the 5/s cap.
|
||||||
for (let i = 0; i < 50; i++) ws.send('not-json')
|
for (let i = 0; i < 50; i++) ws.send('not-json')
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ afterEach(async () => {
|
|||||||
|
|
||||||
// ── POST /projects/worktree ──────────────────────────────────────────────────────
|
// ── 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 () => {
|
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
|
||||||
const { port } = await spawnServer()
|
const { port } = await spawnServer()
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||||
@@ -210,7 +210,7 @@ async function createWorktreeViaRoute(
|
|||||||
return body.path as string
|
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 () => {
|
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
|
||||||
const { port } = await spawnServer()
|
const { port } = await spawnServer()
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
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) ────────────────────────────────────────────
|
// ── 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 () => {
|
it('rejects a foreign Origin with 403', async () => {
|
||||||
const { port } = await spawnServer()
|
const { port } = await spawnServer()
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
|
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 ───────────────────────────────────────────────────────────
|
// ── 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 () => {
|
it('returns 400 when the path query parameter is missing', async () => {
|
||||||
const { port } = await spawnServer()
|
const { port } = await spawnServer()
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/projects/diff`)
|
const res = await fetch(`http://127.0.0.1:${port}/projects/diff`)
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['test/**/*.test.ts'],
|
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',
|
environment: 'node',
|
||||||
// jsdom 29's localStorage methods are undefined under this config; a shared
|
// jsdom 29's localStorage methods are undefined under this config; a shared
|
||||||
// in-memory Web Storage polyfill fixes the frontend (@vitest-environment
|
// in-memory Web Storage polyfill fixes the frontend (@vitest-environment
|
||||||
|
|||||||
Reference in New Issue
Block a user