Skip to content

Commit b0f50c3

Browse files
committed
fix(tests): centralize test config dir lifecycle to prevent env var pollution
Bun runs test files sequentially in a single thread. Several test files unconditionally deleted process.env.SENTRY_CONFIG_DIR in afterEach hooks, causing the next file's module-level code to read undefined and crash with TypeError. A runner image upgrade changed file discovery order, exposing this latent bug. Introduces useTestConfigDir() helper in test/helpers.ts that creates a unique temp directory in beforeEach and restores (never deletes) the env var in afterEach. Migrates all 11 affected test files to use it.
1 parent 35d9b0d commit b0f50c3

File tree

12 files changed

+157
-244
lines changed

12 files changed

+157
-244
lines changed

AGENTS.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,37 @@ Minimal comments, maximum clarity. Comments explain **intent and reasoning**, no
383383
| Unit tests | `*.test.ts` | `test/` (mirrors `src/`) |
384384
| E2E tests | `*.test.ts` | `test/e2e/` |
385385

386+
### Test Environment Isolation (CRITICAL)
387+
388+
Tests that need a database or config directory **must** use `useTestConfigDir()` from `test/helpers.ts`. This helper:
389+
- Creates a unique temp directory in `beforeEach`
390+
- Sets `SENTRY_CONFIG_DIR` to point at it
391+
- **Restores** (never deletes) the env var in `afterEach`
392+
- Closes the database and cleans up temp files
393+
394+
**NEVER** do any of these in test files:
395+
- `delete process.env.SENTRY_CONFIG_DIR`This pollutes other test files that load after yours
396+
- `const baseDir = process.env[CONFIG_DIR_ENV_VAR]!` at module scopeThis captures a value that may be stale
397+
- Manual `beforeEach`/`afterEach` that sets/deletes `SENTRY_CONFIG_DIR`
398+
399+
**Why**: Bun runs test files **sequentially in one thread** (loadrun all testsload next file). If your `afterEach` deletes the env var, the next file's module-level code reads `undefined`, causing `TypeError: The "paths[0]" property must be of type string`.
400+
401+
```typescript
402+
// CORRECT: Use the helper
403+
import { useTestConfigDir } from "../helpers.js";
404+
405+
const getConfigDir = useTestConfigDir("my-test-prefix-");
406+
407+
// If you need the directory path in a test:
408+
test("example", () => {
409+
const dir = getConfigDir();
410+
});
411+
412+
// WRONG: Manual env var management
413+
beforeEach(() => { process.env.SENTRY_CONFIG_DIR = tmpDir; });
414+
afterEach(() => { delete process.env.SENTRY_CONFIG_DIR; }); // BUG!
415+
```
416+
386417
### Property-Based Testing
387418

388419
Use property-based tests when verifying invariants that should hold for **any valid input**.

test/commands/cli/fix.test.ts

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,16 @@
33
*/
44

55
import { Database } from "bun:sqlite";
6-
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
7-
import { mkdirSync, rmSync } from "node:fs";
6+
import { describe, expect, mock, test } from "bun:test";
87
import { join } from "node:path";
98
import { fixCommand } from "../../../src/commands/cli/fix.js";
10-
import {
11-
CONFIG_DIR_ENV_VAR,
12-
closeDatabase,
13-
} from "../../../src/lib/db/index.js";
9+
import { closeDatabase } from "../../../src/lib/db/index.js";
1410
import {
1511
EXPECTED_TABLES,
1612
generatePreMigrationTableDDL,
1713
initSchema,
1814
} from "../../../src/lib/db/schema.js";
15+
import { useTestConfigDir } from "../../helpers.js";
1916

2017
/**
2118
* Generate DDL for creating a database with pre-migration tables.
@@ -64,46 +61,12 @@ function createDatabaseWithMissingTables(
6461
).run();
6562
}
6663

67-
let testDir: string;
68-
let originalConfigDir: string | undefined;
69-
70-
beforeEach(() => {
71-
// Save original config dir
72-
originalConfigDir = process.env[CONFIG_DIR_ENV_VAR];
73-
74-
// Close any existing database connection
75-
closeDatabase();
76-
77-
// Create unique test directory
78-
const baseDir = originalConfigDir ?? "/tmp/sentry-cli-test";
79-
testDir = join(
80-
baseDir,
81-
`fix-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
82-
);
83-
mkdirSync(testDir, { recursive: true });
84-
process.env[CONFIG_DIR_ENV_VAR] = testDir;
85-
});
86-
87-
afterEach(() => {
88-
closeDatabase();
89-
// Restore original config dir
90-
if (originalConfigDir) {
91-
process.env[CONFIG_DIR_ENV_VAR] = originalConfigDir;
92-
} else {
93-
delete process.env[CONFIG_DIR_ENV_VAR];
94-
}
95-
// Clean up test directory
96-
try {
97-
rmSync(testDir, { recursive: true, force: true });
98-
} catch {
99-
// Ignore cleanup errors
100-
}
101-
});
64+
const getTestDir = useTestConfigDir("fix-test-");
10265

10366
describe("sentry cli fix", () => {
10467
test("reports no issues for healthy database", async () => {
10568
// Create healthy database
106-
const db = new Database(join(testDir, "cli.db"));
69+
const db = new Database(join(getTestDir(), "cli.db"));
10770
initSchema(db);
10871
db.close();
10972

@@ -124,7 +87,7 @@ describe("sentry cli fix", () => {
12487

12588
test("detects and reports missing columns in dry-run mode", async () => {
12689
// Create database with pre-migration tables (missing v4 columns)
127-
const db = new Database(join(testDir, "cli.db"));
90+
const db = new Database(join(getTestDir(), "cli.db"));
12891
createPreMigrationDatabase(db);
12992
db.close();
13093

@@ -148,7 +111,7 @@ describe("sentry cli fix", () => {
148111

149112
test("fixes missing columns when not in dry-run mode", async () => {
150113
// Create database with pre-migration tables (missing v4 columns)
151-
const db = new Database(join(testDir, "cli.db"));
114+
const db = new Database(join(getTestDir(), "cli.db"));
152115
createPreMigrationDatabase(db);
153116
db.close();
154117

@@ -169,7 +132,7 @@ describe("sentry cli fix", () => {
169132

170133
// Verify the column was actually added
171134
closeDatabase();
172-
const verifyDb = new Database(join(testDir, "cli.db"));
135+
const verifyDb = new Database(join(getTestDir(), "cli.db"));
173136
const cols = verifyDb.query("PRAGMA table_info(dsn_cache)").all() as Array<{
174137
name: string;
175138
}>;
@@ -188,7 +151,7 @@ describe("sentry cli fix", () => {
188151
// that was previously missing tables (now fixed by auto-repair at startup).
189152
test("handles database that was auto-repaired at startup", async () => {
190153
// Create database missing dsn_cache - initSchema will create it when command runs
191-
const db = new Database(join(testDir, "cli.db"));
154+
const db = new Database(join(getTestDir(), "cli.db"));
192155
createDatabaseWithMissingTables(db, ["dsn_cache"]);
193156
db.close();
194157

@@ -210,7 +173,7 @@ describe("sentry cli fix", () => {
210173

211174
// Verify the table was created (by initSchema auto-repair)
212175
closeDatabase();
213-
const verifyDb = new Database(join(testDir, "cli.db"));
176+
const verifyDb = new Database(join(getTestDir(), "cli.db"));
214177
const tables = verifyDb
215178
.query(
216179
"SELECT name FROM sqlite_master WHERE type='table' AND name='dsn_cache'"
@@ -221,7 +184,7 @@ describe("sentry cli fix", () => {
221184
});
222185

223186
test("shows database path in output", async () => {
224-
const db = new Database(join(testDir, "cli.db"));
187+
const db = new Database(join(getTestDir(), "cli.db"));
225188
initSchema(db);
226189
db.close();
227190

@@ -237,6 +200,6 @@ describe("sentry cli fix", () => {
237200

238201
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
239202
expect(output).toContain("Database:");
240-
expect(output).toContain(testDir);
203+
expect(output).toContain(getTestDir());
241204
});
242205
});

test/commands/issue/utils.test.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ import {
1313
} from "../../../src/commands/issue/utils.js";
1414
import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js";
1515
import { setAuthToken } from "../../../src/lib/db/auth.js";
16-
import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js";
1716
import { setOrgRegion } from "../../../src/lib/db/regions.js";
18-
import { cleanupTestDir, createTestConfigDir } from "../../helpers.js";
17+
import { useTestConfigDir } from "../../helpers.js";
1918

2019
describe("buildCommandHint", () => {
2120
test("suggests <org>/ID for numeric IDs", () => {
@@ -47,15 +46,13 @@ describe("buildCommandHint", () => {
4746
});
4847
});
4948

50-
let testConfigDir: string;
49+
const getConfigDir = useTestConfigDir("test-issue-utils-", {
50+
isolateProjectRoot: true,
51+
});
52+
5153
let originalFetch: typeof globalThis.fetch;
5254

5355
beforeEach(async () => {
54-
// Use isolateProjectRoot to prevent DSN detection from scanning the real project
55-
testConfigDir = await createTestConfigDir("test-issue-utils-", {
56-
isolateProjectRoot: true,
57-
});
58-
process.env[CONFIG_DIR_ENV_VAR] = testConfigDir;
5956
originalFetch = globalThis.fetch;
6057
await setAuthToken("test-token");
6158
// Pre-populate region cache for orgs used in tests to avoid region resolution API calls
@@ -65,9 +62,8 @@ beforeEach(async () => {
6562
await setOrgRegion("org1", DEFAULT_SENTRY_URL);
6663
});
6764

68-
afterEach(async () => {
65+
afterEach(() => {
6966
globalThis.fetch = originalFetch;
70-
await cleanupTestDir(testConfigDir);
7167
});
7268

7369
describe("resolveOrgAndIssueId", () => {
@@ -106,7 +102,7 @@ describe("resolveOrgAndIssueId", () => {
106102
await expect(
107103
resolveOrgAndIssueId({
108104
issueArg: "123456789",
109-
cwd: testConfigDir,
105+
cwd: getConfigDir(),
110106
command: "explain",
111107
})
112108
).rejects.toThrow("Organization");
@@ -144,7 +140,7 @@ describe("resolveOrgAndIssueId", () => {
144140

145141
const result = await resolveOrgAndIssueId({
146142
issueArg: "my-org/PROJECT-ABC",
147-
cwd: testConfigDir,
143+
cwd: getConfigDir(),
148144
command: "explain",
149145
});
150146

@@ -196,7 +192,7 @@ describe("resolveOrgAndIssueId", () => {
196192

197193
const result = await resolveOrgAndIssueId({
198194
issueArg: "f-g",
199-
cwd: testConfigDir,
195+
cwd: getConfigDir(),
200196
command: "explain",
201197
});
202198

@@ -237,7 +233,7 @@ describe("resolveOrgAndIssueId", () => {
237233

238234
const result = await resolveOrgAndIssueId({
239235
issueArg: "org1/dashboard-4y",
240-
cwd: testConfigDir,
236+
cwd: getConfigDir(),
241237
command: "explain",
242238
});
243239

@@ -280,7 +276,7 @@ describe("resolveOrgAndIssueId", () => {
280276

281277
const result = await resolveOrgAndIssueId({
282278
issueArg: "G",
283-
cwd: testConfigDir,
279+
cwd: getConfigDir(),
284280
command: "explain",
285281
});
286282

@@ -298,7 +294,7 @@ describe("resolveOrgAndIssueId", () => {
298294
await expect(
299295
resolveOrgAndIssueId({
300296
issueArg: "G",
301-
cwd: testConfigDir,
297+
cwd: getConfigDir(),
302298
command: "explain",
303299
})
304300
).rejects.toThrow("Cannot resolve issue suffix");
@@ -375,7 +371,7 @@ describe("resolveOrgAndIssueId", () => {
375371

376372
const result = await resolveOrgAndIssueId({
377373
issueArg: "craft-g",
378-
cwd: testConfigDir,
374+
cwd: getConfigDir(),
379375
command: "explain",
380376
});
381377

@@ -435,7 +431,7 @@ describe("resolveOrgAndIssueId", () => {
435431
await expect(
436432
resolveOrgAndIssueId({
437433
issueArg: "nonexistent-g",
438-
cwd: testConfigDir,
434+
cwd: getConfigDir(),
439435
command: "explain",
440436
})
441437
).rejects.toThrow("not found");
@@ -511,7 +507,7 @@ describe("resolveOrgAndIssueId", () => {
511507
await expect(
512508
resolveOrgAndIssueId({
513509
issueArg: "common-g",
514-
cwd: testConfigDir,
510+
cwd: getConfigDir(),
515511
command: "explain",
516512
})
517513
).rejects.toThrow("multiple organizations");
@@ -531,7 +527,7 @@ describe("resolveOrgAndIssueId", () => {
531527
await expect(
532528
resolveOrgAndIssueId({
533529
issueArg: "G",
534-
cwd: testConfigDir,
530+
cwd: getConfigDir(),
535531
command: "explain",
536532
})
537533
).rejects.toThrow();
@@ -551,7 +547,7 @@ describe("resolveOrgAndIssueId", () => {
551547
await expect(
552548
resolveOrgAndIssueId({
553549
issueArg: "G",
554-
cwd: testConfigDir,
550+
cwd: getConfigDir(),
555551
command: "explain",
556552
})
557553
).rejects.toThrow("500");

test/helpers.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
* Shared utilities for test setup and teardown.
55
*/
66

7+
import { afterEach, beforeEach } from "bun:test";
78
import { mkdirSync } from "node:fs";
89
import { mkdtemp, rm } from "node:fs/promises";
910
import { join, resolve } from "node:path";
11+
import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../src/lib/db/index.js";
1012

1113
const TEST_TMP_DIR = resolve(import.meta.dir, "../.test-tmp");
1214
mkdirSync(TEST_TMP_DIR, { recursive: true });
@@ -73,3 +75,47 @@ type FetchMockFn =
7375
export function mockFetch(fn: FetchMockFn): typeof fetch {
7476
return fn as unknown as typeof fetch;
7577
}
78+
79+
/**
80+
* Sets up an isolated test config directory with proper env var lifecycle.
81+
*
82+
* Registers beforeEach/afterEach hooks that create a unique config directory,
83+
* point SENTRY_CONFIG_DIR at it, and restore the original value on teardown.
84+
* This eliminates the fragile pattern of manually managing process.env in
85+
* each test file, which caused cross-file pollution when afterEach hooks
86+
* deleted the env var while other files were still loading.
87+
*
88+
* Must be called at module scope or inside a describe() block.
89+
*
90+
* @param prefix - Directory name prefix for the temp directory
91+
* @param options - Configuration options (e.g., isolateProjectRoot)
92+
* @returns Getter function for the current test's config directory path
93+
*/
94+
export function useTestConfigDir(
95+
prefix = "sentry-test-",
96+
options?: TestConfigDirOptions
97+
): () => string {
98+
let dir: string;
99+
let savedConfigDir: string | undefined;
100+
101+
beforeEach(async () => {
102+
savedConfigDir = process.env[CONFIG_DIR_ENV_VAR];
103+
closeDatabase();
104+
dir = await createTestConfigDir(prefix, options);
105+
process.env[CONFIG_DIR_ENV_VAR] = dir;
106+
});
107+
108+
afterEach(async () => {
109+
closeDatabase();
110+
// Always restore the previous value — never delete.
111+
// Deleting process.env.SENTRY_CONFIG_DIR causes failures in test files
112+
// that load after this afterEach runs, because their module-level code
113+
// (or beforeEach hooks) may read the env var and get undefined.
114+
if (savedConfigDir !== undefined) {
115+
process.env[CONFIG_DIR_ENV_VAR] = savedConfigDir;
116+
}
117+
await cleanupTestDir(dir);
118+
});
119+
120+
return () => dir;
121+
}

0 commit comments

Comments
 (0)