Skip to content

Commit 5a2e54d

Browse files
committed
fix(tests): LEVELCODE_HOME override + per-test isolated home preload
Root fix for the CI test matrix: bun caches os.homedir() at first use, so HOME/USERPROFILE swapping per test did not move user-data paths after the first caller — common's team tests leaked fixtures into the runner's real home and collided across files (ENOTEMPTY on archive renames). - getUserHomeDir() (common/src/utils/home-dir.ts): LEVELCODE_HOME wins over the cached os.homedir(); env reads are live - all user-data paths routed through it: team-fs, team-discovery, team-maintenance, hooks loader, common getConfigDir - test/setup-isolated-home.ts preload (wired into root + sdk bunfig): every test gets a fresh isolated home, cleaned up automatically - agents-console test seeds through getTeamsDir()/getTasksDir() instead of literal home paths
1 parent 3f5b518 commit 5a2e54d

10 files changed

Lines changed: 84 additions & 21 deletions

File tree

‎bunfig.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ linkWorkspacePackages = true
77
[test]
88
# Exclude test repositories, integration tests, and Playwright e2e tests from test execution by default
99
exclude = ["evals/test-repos/**", "**/*.integration.test.*", "web/src/__tests__/e2e/**"]
10-
preload = ["./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts"]
10+
preload = ["./test/setup-isolated-home.ts", "./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts"]

‎cli/src/agents-console/__tests__/agents-console.test.ts‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
plainTheme,
1010
} from '../agents-console'
1111
import { collectAgentsData, renderAgentsCommand } from '../run-agents'
12-
import { getTeamsDir } from '@levelcode/common/utils/team-fs'
12+
import { getTeamsDir, getTasksDir } from '@levelcode/common/utils/team-fs'
1313

1414
import type { TeamConfig, TeamMember, TeamTask } from '@levelcode/common/types/team-config'
1515

@@ -148,18 +148,9 @@ describe('agents command (disk-backed)', () => {
148148
}
149149
process.env.HOME = tmpHome
150150
process.env.USERPROFILE = tmpHome
151-
// bun may cache os.homedir() on first use; clean the cached location.
152-
const configRoot = path.dirname(path.dirname(getTeamsDir()))
153-
if (fs.existsSync(configRoot)) {
154-
fs.rmSync(configRoot, { recursive: true, force: true })
155-
}
156151
})
157152

158153
afterEach(() => {
159-
const configRoot = path.dirname(path.dirname(getTeamsDir()))
160-
if (fs.existsSync(configRoot)) {
161-
fs.rmSync(configRoot, { recursive: true, force: true })
162-
}
163154
for (const [key, value] of Object.entries(origHome)) {
164155
if (value === undefined) delete process.env[key]
165156
else process.env[key] = value
@@ -168,7 +159,9 @@ describe('agents command (disk-backed)', () => {
168159
})
169160

170161
function seedTeam(name: string): void {
171-
const teamDir = path.join(tmpHome, '.config', 'levelcode', 'teams', name)
162+
// Seed through getTeamsDir()/getTasksDir(): with the isolated-home
163+
// preload these resolve to a fresh LEVELCODE_HOME per test.
164+
const teamDir = path.join(getTeamsDir(), name)
172165
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true })
173166
const config = makeConfig({
174167
name,
@@ -180,7 +173,7 @@ describe('agents command (disk-backed)', () => {
180173
JSON.stringify(config),
181174
'utf-8',
182175
)
183-
const tasksDir = path.join(tmpHome, '.config', 'levelcode', 'tasks', name)
176+
const tasksDir = getTasksDir(name)
184177
fs.mkdirSync(tasksDir, { recursive: true })
185178
fs.writeFileSync(
186179
path.join(tasksDir, '1.json'),

‎common/src/hooks/loader.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import fs from 'fs'
2-
import os from 'os'
32
import path from 'path'
43

4+
import { getUserHomeDir } from '../utils/home-dir'
5+
56
import { hooksConfigSchema } from './types'
67

78
import type { HookMatcher, HookEventName, HooksConfig } from './types'
@@ -33,7 +34,7 @@ function configEnvSuffix(): string {
3334

3435
/** Candidate settings.json paths in load order. */
3536
export function getHookConfigPaths(projectRoot: string): string[] {
36-
const home = os.homedir()
37+
const home = getUserHomeDir()
3738
const commonBase =
3839
process.env.LEVELCODE_DIR || path.join(home, '.levelcode')
3940
return [

‎common/src/utils/auth.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import path from 'path'
2-
import os from 'os'
2+
3+
import { getUserHomeDir } from './home-dir'
34

45
// ============================================================================
56
// Config Directory
67
// ============================================================================
78

89
export function getConfigDir(): string {
9-
const home = os.homedir()
10+
const home = getUserHomeDir()
1011
// Use .levelcode in home directory
1112
const baseDir = process.env.LEVELCODE_DIR || path.join(home, '.levelcode')
1213
return baseDir

‎common/src/utils/home-dir.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as os from 'os'
2+
3+
/**
4+
* Resolvable user home directory.
5+
*
6+
* Bun caches `os.homedir()` at first use, which makes per-test HOME
7+
* swapping unreliable (the first caller freezes the value for the whole
8+
* process). User-data paths across the monorepo go through this helper
9+
* instead, so tests and sandboxes can relocate the home reliably with
10+
* `LEVELCODE_HOME` (env reads are always live).
11+
*/
12+
export function getUserHomeDir(): string {
13+
return process.env.LEVELCODE_HOME || os.homedir()
14+
}

‎common/src/utils/team-discovery.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as fs from 'fs'
2+
import { getUserHomeDir } from './home-dir'
23
import * as path from 'path'
34
import * as os from 'os'
45
import { getTeamsDir, loadTeamConfig, validateTeamName } from './team-fs'
@@ -32,7 +33,7 @@ export interface TeamSummary {
3233
// agentStepId changes every call) can still locate the team.
3334

3435
function getLastActiveTeamPath(): string {
35-
return path.join(os.homedir(), '.config', 'levelcode', 'teams', '.last-active-team')
36+
return path.join(getUserHomeDir(), '.config', 'levelcode', 'teams', '.last-active-team')
3637
}
3738

3839
/**

‎common/src/utils/team-fs.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as fs from 'fs'
2+
import { getUserHomeDir } from './home-dir'
23
import * as path from 'path'
34
import * as os from 'os'
45
import type { TeamConfig, TeamMember, TeamTask } from '../types/team-config'
@@ -71,7 +72,7 @@ function assertPathContained(resolvedPath: string, expectedParent: string): void
7172
// --- Path helpers ---
7273

7374
function getConfigRoot(): string {
74-
return path.join(os.homedir(), '.config', 'levelcode')
75+
return path.join(getUserHomeDir(), '.config', 'levelcode')
7576
}
7677

7778
export function getTeamsDir(): string {

‎common/src/utils/team-maintenance.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import * as fs from 'fs'
2+
import { getUserHomeDir } from './home-dir'
23
import * as path from 'path'
34
import * as os from 'os'
45
import type { TeamConfig, TeamTask, AgentStatus } from '../types/team-config'
56
import { teamConfigSchema, teamTaskSchema } from '../types/team-config-schemas'
67
import { loadTeamConfig, listTasks, getTeamsDir, getTasksDir, validateTeamName } from './team-fs'
78

89
function getConfigRoot(): string {
9-
return path.join(os.homedir(), '.config', 'levelcode')
10+
return path.join(getUserHomeDir(), '.config', 'levelcode')
1011
}
1112

1213
function getTeamDir(teamName: string): string {

‎sdk/bunfig.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ linkWorkspacePackages = true
66
[test]
77
# Exclude large test repositories from execution
88
exclude = ["../evals/test-repos/**"]
9-
preload = ["./test/setup-env.ts", "../test/setup-bigquery-mocks.ts"]
9+
preload = ["../test/setup-isolated-home.ts", "./test/setup-env.ts", "../test/setup-bigquery-mocks.ts"]

‎test/setup-isolated-home.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Global test preload: give every test a fresh, isolated home directory
3+
* via LEVELCODE_HOME.
4+
*
5+
* Why: bun caches os.homedir() at first use, so swapping HOME/USERPROFILE
6+
* per test does not move user-data paths after the first caller — tests
7+
* leaked state into each other (and on CI, into the runner's real home).
8+
* Every user-data path in the monorepo goes through
9+
* `getUserHomeDir()` (common/src/utils/home-dir.ts), which prefers
10+
* LEVELCODE_HOME. Env reads are live, so this per-test override is reliable
11+
* on every platform.
12+
*
13+
* Tests that want a specific home can set LEVELCODE_HOME themselves; the
14+
* preload restores the previous value afterwards. Temp dirs are cleaned up.
15+
*/
16+
17+
import { beforeEach, afterEach } from 'bun:test'
18+
import * as fs from 'fs'
19+
import * as os from 'os'
20+
import * as path from 'path'
21+
22+
let currentHome: string | null = null
23+
let savedValue: string | undefined
24+
25+
beforeEach(() => {
26+
savedValue = process.env.LEVELCODE_HOME
27+
try {
28+
currentHome = fs.mkdtempSync(path.join(os.tmpdir(), 'levelcode-home-'))
29+
} catch {
30+
currentHome = null // Fall back to whatever homedir resolves to
31+
}
32+
if (currentHome) {
33+
process.env.LEVELCODE_HOME = currentHome
34+
}
35+
})
36+
37+
afterEach(() => {
38+
if (savedValue !== undefined) {
39+
process.env.LEVELCODE_HOME = savedValue
40+
} else {
41+
delete process.env.LEVELCODE_HOME
42+
}
43+
if (currentHome) {
44+
try {
45+
fs.rmSync(currentHome, { recursive: true, force: true })
46+
} catch {
47+
// Best-effort cleanup
48+
}
49+
currentHome = null
50+
}
51+
})

0 commit comments

Comments
 (0)