Skip to content

Commit 5cf5e61

Browse files
authored
test: fix timeouts (#3)
* test: fix timeouts * fix: add git credentials to test * docs: capture learnings * ci: update workflow name to comply with org setup * refactor: format fix
1 parent 59cc6e8 commit 5cf5e61

14 files changed

Lines changed: 116 additions & 26 deletions

File tree

.archgate/adrs/ARCH-005-testing-standards.md

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ Use Bun's built-in test runner (`bun test`) for all tests. Test files go in `tes
4040
- Use `tests/fixtures/` for sample data files
4141
- Use temp directories (`mkdtemp`) for tests that write to the filesystem
4242
- Clean up temp directories in `afterEach` or `afterAll`
43+
- **Close external SDK instances** (servers, clients, transports) in `afterEach` or `afterAll` by calling their cleanup method (e.g., `await server.close()`). Manage their lifecycle in `beforeEach`/`afterEach` rather than inside individual test bodies so cleanup is guaranteed.
44+
- **When a test creates a temp git repo and needs to call `git commit`, configure local user identity first** — CI runners have no global git config, so commits fail without explicit local identity. Set it with `await Bun.$\`git config user.email "test@test.com"\`.cwd(tempDir).quiet()`and`await Bun.$\`git config user.name "Test"\`.cwd(tempDir).quiet()`immediately after`git init`.
4345
- Test public module interfaces, not private implementation details
4446
- Use descriptive test names that explain the expected behavior
4547

@@ -48,6 +50,8 @@ Use Bun's built-in test runner (`bun test`) for all tests. Test files go in `tes
4850
- Don't test private implementation details — test the public API of each module
4951
- Don't depend on network access in unit tests
5052
- Don't leave temp files after test runs
53+
- **Don't leave external SDK instances open after tests** — instances from libraries such as `@modelcontextprotocol/sdk` hold internal references (e.g., `AjvJsonSchemaValidator` backed by `ajv`) that keep Bun's event loop alive on Linux, causing `bun test` to hang indefinitely after all tests complete even though every test passes. Always call the cleanup method in `afterEach`.
54+
- **Don't rely on globally-configured git identity in temp git repos** — always set `user.email` and `user.name` locally in any repo that makes commits. Omitting this works locally (where developers have global git config) but fails silently in CI, producing a cryptic `ShellPromise` error with no indication that git identity is the cause.
5155
- Don't skip tests without a tracking issue
5256
- Don't import test utilities from `node:test` — use Bun's built-in `bun:test` module
5357

@@ -83,6 +87,53 @@ describe("runChecks", () => {
8387
});
8488
```
8589

90+
### Good Example — Temp Git Repo with Commits
91+
92+
```typescript
93+
// tests/engine/git-files.test.ts
94+
it("returns both staged and unstaged changes", async () => {
95+
await Bun.$`git init`.cwd(tempDir).quiet();
96+
// GOOD: set local identity before any commit — CI has no global git config
97+
await Bun.$`git config user.email "test@test.com"`.cwd(tempDir).quiet();
98+
await Bun.$`git config user.name "Test"`.cwd(tempDir).quiet();
99+
writeFileSync(join(tempDir, "a.ts"), "export const a = 1;");
100+
await Bun.$`git add a.ts`.cwd(tempDir).quiet();
101+
await Bun.$`git commit -m "init"`.cwd(tempDir).quiet();
102+
// ... rest of test
103+
});
104+
```
105+
106+
### Good Example — External SDK Cleanup
107+
108+
```typescript
109+
// tests/mcp/resources.test.ts
110+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
111+
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
112+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
113+
import { registerResources } from "../../src/mcp/resources";
114+
115+
describe("registerResources", () => {
116+
let tempDir: string;
117+
let server: McpServer;
118+
119+
// GOOD: lifecycle managed in beforeEach/afterEach, not inside test bodies
120+
beforeEach(() => {
121+
tempDir = mkdtempSync(join(tmpdir(), "archgate-mcp-res-test-"));
122+
mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true });
123+
server = new McpServer({ name: "test", version: "0.0.0" });
124+
});
125+
126+
afterEach(async () => {
127+
await server.close(); // GOOD: releases internal validator references
128+
rmSync(tempDir, { recursive: true, force: true });
129+
});
130+
131+
test("does not throw when registering resources", () => {
132+
expect(() => registerResources(server, tempDir)).not.toThrow();
133+
});
134+
});
135+
```
136+
86137
### Bad Example
87138

88139
```typescript
@@ -102,6 +153,22 @@ it("writes output", async () => {
102153
await Bun.write("/tmp/test-output.json", data);
103154
// No cleanup — file persists after test
104155
});
156+
157+
// BAD: SDK instance created inside test body — no guaranteed cleanup path
158+
it("registers resources", () => {
159+
const server = new McpServer({ name: "test", version: "0.0.0" });
160+
// server.close() never called — event loop held open on Linux
161+
expect(() => registerResources(server, tempDir)).not.toThrow();
162+
});
163+
164+
// BAD: git commit without local identity — works locally, fails in CI
165+
it("reads changes", async () => {
166+
await Bun.$`git init`.cwd(tempDir).quiet();
167+
writeFileSync(join(tempDir, "a.ts"), "x");
168+
await Bun.$`git add a.ts`.cwd(tempDir).quiet();
169+
await Bun.$`git commit -m "init"`.cwd(tempDir).quiet();
170+
// Fails in CI: "*** Please tell me who you are."
171+
});
105172
```
106173

107174
## Consequences
@@ -124,23 +191,28 @@ it("writes output", async () => {
124191
- **Mitigation:** The project pins a specific Bun version via `.prototools` (currently 1.3.8). Test runner API changes are caught during controlled Bun upgrades with full test suite validation.
125192
- **Coverage reporting gaps**`bun test --coverage` may not report accurate coverage for all code paths, especially for dynamically imported modules.
126193
- **Mitigation:** Coverage is a guideline (80% target), not a hard gate. Critical modules (engine, formats) are tested thoroughly regardless of coverage numbers.
194+
- **Third-party SDK event loop retention** — External SDK instances that hold internal resource references (e.g., `AjvJsonSchemaValidator` inside `@modelcontextprotocol/sdk`) keep Bun's event loop alive on Linux after all tests complete, causing `bun test` to hang indefinitely. This does not surface on macOS (event loop drains normally there), making it a Linux-CI-only failure that is hard to reproduce locally.
195+
- **Mitigation:** Always manage SDK lifecycle in `beforeEach`/`afterEach` and call the cleanup method (`close()`, `destroy()`, `disconnect()`) in `afterEach`. Add `timeout-minutes` to CI jobs as a safety net — the `code-pull-request.yml` job is set to 10 minutes to cap any future regressions.
127196

128197
## Compliance and Enforcement
129198

130199
### Automated Enforcement
131200

132201
- **Archgate rule** `ARCH-005/test-mirrors-src`: Scans all source files in `src/` and verifies a corresponding `.test.ts` file exists in `tests/`. Severity: `error`.
133-
- **CI pipeline**: `bun test` runs on every pull request. Test failures block merge.
202+
- **CI pipeline**: `bun test --timeout 60000` runs on every pull request. Test failures and per-test timeouts block merge. All workflow jobs have `timeout-minutes` set to prevent indefinite hangs.
134203

135204
### Manual Enforcement
136205

137206
Code reviewers MUST verify:
138207

139208
1. New source files have corresponding test files
140209
2. Tests use temp directories for filesystem operations (no hardcoded paths)
141-
3. Tests clean up after themselves (`afterEach`/`afterAll` cleanup)
210+
3. Tests clean up after themselves (`afterEach`/`afterAll` cleanup) — including both temp directories and external SDK instances
211+
4. Tests that instantiate SDK objects (servers, clients, connections) manage their lifecycle in `beforeEach`/`afterEach`, not inside individual test bodies
212+
5. Tests that call `git commit` on a temp repo configure `user.email` and `user.name` locally before committing
142213

143214
## References
144215

145216
- [Bun test runner documentation](https://bun.sh/docs/cli/test)
146217
- [ARCH-001 — Command Structure](./ARCH-001-command-structure.md) — In-process execution enables testing commands directly without process spawning
218+
- [ARCH-006 — Dependency Policy](./ARCH-006-dependency-policy.md) — Third-party dependencies introduce runtime behaviors (like event loop retention) that must be accounted for in test teardown

.claude/agent-memory/archgate-developer/MEMORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv
2626

2727
## Patterns & Fixes
2828

29+
- **`McpServer` event loop retention on Linux**`new McpServer()` from `@modelcontextprotocol/sdk` creates an `AjvJsonSchemaValidator` (backed by `ajv` + `ajv-formats`) that keeps Bun's event loop alive on Linux after tests complete, causing `bun test` to hang for 30+ minutes. macOS drains the event loop fine — this is a Linux-CI-only failure. Fix: manage server lifecycle in `beforeEach`/`afterEach` and call `await server.close()` in `afterEach`. Also captured in ARCH-005 Don'ts.
30+
- **`git commit` in temp repos requires local identity** — CI runners have no global `user.email`/`user.name` configured. Any test that runs `git commit` on a temp repo MUST call `git config user.email` and `git config user.name` locally after `git init`. Fails with a cryptic `ShellPromise` error in CI; passes locally. Also captured in ARCH-005 Do's.
2931
- **Bun import cache-busting**: Bun caches `import()` per-process. For long-running processes (MCP server), append `?t=${Date.now()}` to the import path to force re-reading from disk. Applied in `src/engine/loader.ts`.
3032
- **Never use `bunx prettier` directly** — Always use `bun run format` (to fix) or `bun run format:check` (to verify). Using `bunx prettier` can fail or use a different version than the project's devDependency. The same applies to all dev tools: prefer `bun run <script>` over `bunx <tool>` when a package.json script exists.
3133
- **`Bun.Glob.match()` triggers oxlint `prefer-regexp-test`**`Bun.Glob.match()` returns a boolean (not a RegExp), but oxlint can't tell. Suppress with `// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp`.

.github/workflows/code-pull-request.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ concurrency:
1212

1313
jobs:
1414
validate:
15-
name: Validate
15+
name: Validate Code
1616
runs-on: ubuntu-latest
17+
timeout-minutes: 10
1718
if: github.event.pull_request.draft == false
1819
steps:
1920
- name: Checkout code

.github/workflows/release-binaries.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ permissions:
1515
jobs:
1616
build:
1717
name: Build ${{ matrix.artifact }}
18+
timeout-minutes: 15
1819
strategy:
1920
fail-fast: false
2021
matrix:
@@ -70,6 +71,7 @@ jobs:
7071
update-homebrew:
7172
name: Update Homebrew tap
7273
needs: build
74+
timeout-minutes: 5
7375
runs-on: ubuntu-latest
7476

7577
steps:

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ permissions:
1919
jobs:
2020
check:
2121
runs-on: ubuntu-latest
22+
timeout-minutes: 10
2223
name: Context check
2324
outputs:
2425
continue: ${{ steps.check.outputs.continue }}
@@ -56,6 +57,7 @@ jobs:
5657
branch: release
5758
pull-request:
5859
runs-on: ubuntu-latest
60+
timeout-minutes: 10
5961
name: Pull request
6062
needs: check
6163
if: needs.check.outputs.workflow == 'pull-request'
@@ -91,6 +93,7 @@ jobs:
9193
branch: release
9294
release:
9395
runs-on: ubuntu-latest
96+
timeout-minutes: 10
9497
name: Release
9598
needs: check
9699
if: needs.check.outputs.workflow == 'release'

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
"typecheck": "tsc --build",
4141
"format": "prettier --write .",
4242
"format:check": "prettier --check .",
43-
"test": "bun test",
44-
"test:watch": "bun test --watch",
43+
"test": "bun test --timeout 60000",
44+
"test:watch": "bun test --watch --timeout 60000",
4545
"validate": "bun run lint && bun run typecheck && bun run format:check && bun test && bun run check",
4646
"commit": "cz",
4747
"build": "bun run scripts/build.ts",

tests/engine/git-files.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ describe("git-files", () => {
5959

6060
test("returns both staged and unstaged changes", async () => {
6161
await Bun.$`git init`.cwd(tempDir).quiet();
62+
await Bun.$`git config user.email "test@test.com"`.cwd(tempDir).quiet();
63+
await Bun.$`git config user.name "Test"`.cwd(tempDir).quiet();
6264
writeFileSync(join(tempDir, "a.ts"), "export const a = 1;");
6365
await Bun.$`git add a.ts`.cwd(tempDir).quiet();
6466
await Bun.$`git commit -m "init"`.cwd(tempDir).quiet();

tests/mcp/resources.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,24 @@ import { registerResources } from "../../src/mcp/resources";
77

88
describe("registerResources", () => {
99
let tempDir: string;
10+
let server: McpServer;
1011

1112
beforeEach(() => {
1213
tempDir = mkdtempSync(join(tmpdir(), "archgate-mcp-res-test-"));
1314
mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true });
15+
server = new McpServer({ name: "test", version: "0.0.0" });
1416
});
1517

16-
afterEach(() => {
18+
afterEach(async () => {
19+
await server.close();
1720
rmSync(tempDir, { recursive: true, force: true });
1821
});
1922

2023
test("does not throw when registering resources", () => {
21-
const server = new McpServer({ name: "test", version: "0.0.0" });
2224
expect(() => registerResources(server, tempDir)).not.toThrow();
2325
});
2426

2527
test("registers the adr resource template", () => {
26-
const server = new McpServer({ name: "test", version: "0.0.0" });
2728
registerResources(server, tempDir);
2829
// If registration succeeded without throwing, the resource is registered
2930
expect(true).toBe(true);

tests/mcp/server.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,30 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
22
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
5+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
56
import { createMcpServer } from "../../src/mcp/server";
67

78
describe("createMcpServer", () => {
89
let tempDir: string;
10+
let server: McpServer;
911

1012
beforeEach(() => {
1113
tempDir = mkdtempSync(join(tmpdir(), "archgate-mcp-server-test-"));
1214
mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true });
15+
server = createMcpServer(tempDir);
1316
});
1417

15-
afterEach(() => {
18+
afterEach(async () => {
19+
await server.close();
1620
rmSync(tempDir, { recursive: true, force: true });
1721
});
1822

1923
test("returns an McpServer instance", () => {
20-
const server = createMcpServer(tempDir);
2124
expect(server).toBeDefined();
2225
expect(typeof server.connect).toBe("function");
2326
});
2427

2528
test("server has tool and resource registration methods", () => {
26-
const server = createMcpServer(tempDir);
2729
expect(typeof server.registerTool).toBe("function");
2830
expect(typeof server.registerResource).toBe("function");
2931
});

tests/mcp/tools.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,24 @@ import { registerTools } from "../../src/mcp/tools/index";
77

88
describe("registerTools", () => {
99
let tempDir: string;
10+
let server: McpServer;
1011

1112
beforeEach(() => {
1213
tempDir = mkdtempSync(join(tmpdir(), "archgate-mcp-tools-test-"));
1314
mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true });
15+
server = new McpServer({ name: "test", version: "0.0.0" });
1416
});
1517

16-
afterEach(() => {
18+
afterEach(async () => {
19+
await server.close();
1720
rmSync(tempDir, { recursive: true, force: true });
1821
});
1922

2023
test("does not throw when registering tools", () => {
21-
const server = new McpServer({ name: "test", version: "0.0.0" });
2224
expect(() => registerTools(server, tempDir)).not.toThrow();
2325
});
2426

2527
test("registers all expected tools", () => {
26-
const server = new McpServer({ name: "test", version: "0.0.0" });
2728
const registerSpy = spyOn(server, "registerTool");
2829
registerTools(server, tempDir);
2930
// The tools module registers 4 tools: check, list_adrs, review_context, session_context

0 commit comments

Comments
 (0)