Repository navigation
Expand file tree
/
Copy pathvitest.setup.ts
More file actions
222 lines (211 loc) · 9.61 KB
/
Copy pathvitest.setup.ts
File metadata and controls
222 lines (211 loc) · 9.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { threadId } from "node:worker_threads";
import { afterAll, afterEach, beforeEach } from "vitest";
import { setControlWriter } from "./src/tui/ansi.js";
// Terminal-mode escapes are state in the developer's terminal, not in
// this process, so any that escape a test outlive it. Screen.start() and
// the picker's grab both write them past the injected mock `term`, so
// before this the suite left the runner's own terminal with focus
// reporting on (which then fed \x1b[I / \x1b[O into vitest's stdin) and
// auto-wrap off (which clipped every line the reporter printed after,
// making a live run look hung). Discard them wholesale: no test asserts
// on a mode sequence, and the ones that do assert on OSC content writes
// (title excepted) still spy on process.stdout directly.
setControlWriter(() => {});
// Worker-wide root for per-test tmp dirs. Created once at module load
// and torn down in afterAll so the OS doesn't have to garbage-collect us.
// realpathSync.NATIVE, specifically. os.tmpdir() reads TEMP, which
// GitHub's Windows runners set to an 8.3 short path
// (C:\Users\RUNNER~1\...), while git reports the long form
// (C:\Users\runneradmin\...). Those name one directory and compare
// unequal. HYDRA_ACP_HOME is minted under this root, so leaving it short
// leaks that spelling into every product-side path while fixtures use
// the long one.
//
// The plain fs.realpathSync does NOT fix this: it resolves symlinks by
// walking with lstat and leaves an 8.3 component exactly as it found it.
// Only the .native variant goes through the OS call that expands it.
// Both settle the macOS /var -> /private/var case, so the native one is
// strictly the better default here.
const workerRoot = fs.realpathSync.native(
fs.mkdtempSync(path.join(os.tmpdir(), "hydra-acp-vitest-")),
);
// Every `git init` in this suite otherwise copies from the developer's
// init.templateDir, which makes the tests depend on a directory outside
// the repo that nothing here controls. That was a real intermittent
// failure, not a theoretical one: a dotfiles sync rewrites
// ~/.git_template/hooks with atomic temp+rename, and a `git init` whose
// readdir sees a temp name that is gone by the stat dies with
//
// fatal: cannot stat template '~/.git_template/hooks/CuqQwGXs'
//
// which surfaced as unrelated workspace tests failing in ~25ms, roughly
// one run in twenty, and never reproducibly. Point git at an empty
// directory instead: the env var outranks the config, no test asserts on
// hook contents, and copying nothing makes init marginally faster too.
const gitTemplateDir = path.join(workerRoot, "empty-git-template");
fs.mkdirSync(gitTemplateDir, { recursive: true });
process.env.GIT_TEMPLATE_DIR = gitTemplateDir;
// GIT_TEMPLATE_DIR alone was not enough, because a template is only one
// of the ways the developer's environment reaches into a fixture repo.
// core.hooksPath is another, and it is not copied at init time but read
// at command time, so an empty template does nothing about it. A global
// core.hooksPath pointing anywhere unreliable (dotfiles that symlink
// into another checkout, say) fails the fixture's own commits:
//
// Error: Command failed: git commit -q -m agent work
// /bin/sh: ~/.git_template/hooks/pre-commit: No such file or directory
//
// which lands as one arbitrary workspace test failing per full-suite run
// and passing when that file is run alone. Other keys are latent traps of
// the same shape: status.showuntrackedfiles=no, diff.renames=copies and
// core.whitespace all change output this suite parses.
//
// So substitute a config of our own for the user's. It is not empty: an
// identity has to come from somewhere. Fixtures set user.name/user.email
// on the repos they create by hand, but a submodule this suite clones
// into a workspace gets a fresh config with no identity, and its commits
// then die on "Author identity unknown" — which presents as a submodule
// test seeing an unexpectedly clean tree, not as an obvious config error.
// Naming the identity here also keeps the developer's own name out of
// fixture commits. Branch names need no equivalent: fixtures pass
// `git init -b main` explicitly.
//
// The GIT_CONFIG_KEY_* injections a couple of tests use still win over
// this, since env-supplied config outranks config files.
const gitConfigFile = path.join(workerRoot, "gitconfig");
fs.writeFileSync(
gitConfigFile,
"[user]\n\tname = hydra-acp tests\n\temail = tests@hydra-acp.invalid\n",
);
process.env.GIT_CONFIG_GLOBAL = gitConfigFile;
process.env.GIT_CONFIG_SYSTEM = gitConfigFile;
// Mint a fresh empty HYDRA_ACP_HOME before every test. Doing this
// globally (instead of in each test's own beforeEach) means tests can't
// accidentally inherit one another's leftover state — every test boots
// from nothing, which surfaces "implicit fixture" bugs that would
// otherwise stay hidden.
//
// SessionStore / HistoryStore call paths.ts on every write and some
// writes are fire-and-forget. Leaving HYDRA_ACP_HOME pointing at the
// (now-deleted) per-test dir on afterEach means a straggler write that
// races past teardown fails with ENOENT inside its surrounding .catch,
// never falling back to ~/.hydra-acp.
let currentHome: string | undefined;
beforeEach(() => {
currentHome = fs.mkdtempSync(path.join(workerRoot, "home-"));
process.env.HYDRA_ACP_HOME = currentHome;
// Tests rely on the legacy `npx -y` plan from planSpawn rather than
// pre-installing into a temp HYDRA_ACP_HOME — the npm install would
// hit the network and slow every test to a crawl. The npm-install
// tests opt back in by `delete process.env.HYDRA_ACP_SKIP_NPM_PREFETCH`.
process.env.HYDRA_ACP_SKIP_NPM_PREFETCH = "1";
});
// Retries back off linearly (50ms, 100ms, ...), so N retries can stall a
// test for 25*N*(N+1) ms, and the swallowed error hides why. Set
// HYDRA_ACP_TEST_CLEANUP_LOG to a directory to get one JSON line per sweep
// that retried or gave up: the test, the time spent, the error code and
// what was still on disk.
const cleanupLogDir = process.env.HYDRA_ACP_TEST_CLEANUP_LOG;
const SLOW_SWEEP_MS = 40;
function leftovers(dir: string): string[] {
try {
return fs
.readdirSync(dir, { recursive: true })
.map(String)
.slice(0, 50);
} catch {
return [];
}
}
// Async on purpose: rmSync sleeps between retries with the event loop
// blocked, so a stream whose end() was already called (agent and extension
// logs, an appendFile in flight) never gets the turn it needs to close its
// file, and on Windows that open file keeps the directory from going.
//
// The async rm retries each entry on its own, so a tree with several busy
// files can run far past maxRetries' budget. The deadline bounds the wait;
// an rm still going past it keeps working in the background.
async function sweep(
dir: string,
test: string,
maxRetries: number,
deadlineMs: number,
): Promise<void> {
const started = Date.now();
let error: NodeJS.ErrnoException | undefined;
let timer: NodeJS.Timeout | undefined;
const removal = fs.promises.rm(dir, {
recursive: true,
force: true,
maxRetries,
retryDelay: 50,
});
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(Object.assign(new Error("sweep deadline"), { code: "DEADLINE" }));
}, deadlineMs);
});
try {
await Promise.race([removal, deadline]);
} catch (err) {
error = err as NodeJS.ErrnoException;
removal.catch(() => undefined);
} finally {
clearTimeout(timer);
}
const ms = Date.now() - started;
if (!cleanupLogDir || (!error && ms < SLOW_SWEEP_MS)) {
return;
}
const record = {
test,
ms,
gaveUp: error !== undefined,
code: error?.code,
path: error?.path,
leftovers: error ? leftovers(dir) : undefined,
};
try {
fs.mkdirSync(cleanupLogDir, { recursive: true });
fs.appendFileSync(
path.join(cleanupLogDir, `cleanup-${process.pid}-${threadId}.jsonl`),
`${JSON.stringify(record)}\n`,
);
} catch {
// Diagnostics only.
}
}
afterEach(async (ctx) => {
if (currentHome) {
// Fire-and-forget writes from Session (queue persist, history append)
// can land mid-rm: as rm walks the tree, a pending mkdir/writeFile
// recreates a file inside the dir we just emptied and the next rmdir
// fails with ENOTEMPTY. maxRetries/retryDelay tells Node to retry on
// exactly that code (also EBUSY/EMFILE/ENFILE/EPERM), which gives
// those stragglers enough time to land and be swept on the next pass.
// The retry budget has to cover a loaded CI runner, not just a fast
// dev box; 5x10ms was enough locally and not on macOS runners.
//
// A straggler that outlasts even that budget must not fail a test
// that already passed, so sweep() swallows it. The per-worker root
// this lives under is removed wholesale in afterAll, so nothing leaks
// beyond the run.
//
// Six retries and a 2s deadline, no more: on Windows a file a test
// left open cannot leave its directory until the handle closes, and a
// 20-retry budget made each such test wait out 10.5s for nothing.
const home = currentHome;
currentHome = undefined;
await sweep(home, `${ctx.task.file?.name ?? "?"} > ${ctx.task.name}`, 6, 2_000);
}
});
afterAll(async () => {
// Same race as afterEach, one level up: hardening only the per-test
// sweep just moved the ENOTEMPTY here, where it fails the whole file
// rather than one test. The root is a mkdtemp under os.tmpdir(), so
// the worst case of giving up is a directory the OS reaps later.
await sweep(workerRoot, "(worker root)", 20, 10_000);
}, 15_000);