-
-
Notifications
You must be signed in to change notification settings - Fork 254
Expand file tree
/
Copy pathnode-test-tmpdir.test.ts
More file actions
252 lines (228 loc) · 10.8 KB
/
Copy pathnode-test-tmpdir.test.ts
File metadata and controls
252 lines (228 loc) · 10.8 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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { runCmd } from '../src/utils/exec.ts';
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './vitest-tmpdir-global-setup.ts';
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WRAPPER = path.join(REPOSITORY_ROOT, 'scripts', 'node-test-tmpdir.ts');
// package.json scripts that may legitimately invoke `node --test` without
// routing through the wrapper below. Empty on purpose: every current
// node --test lane is wrapped (#1595). Add a script name here only alongside
// a comment explaining why that lane can't be wrapped — the ratchet test
// below fails closed on anything else, so a 14th `node --test` script added
// later without the wrapper fails CI instead of silently leaking again.
const NODE_TEST_WRAPPER_BYPASS_ALLOWLIST = new Set<string>([]);
// Dumb string matching on purpose (no shell parsing, per the scripts map's
// own style: '&&'-chained commands, nothing fancier). True when a `node ...`
// segment enables the built-in test runner via a word-bounded `--test` flag
// and hasn't already been routed through the wrapper.
function isUnwrappedNodeTestSegment(segment: string): boolean {
const trimmed = segment.trim();
if (!/^node(\s|$)/.test(trimmed)) return false;
if (trimmed.includes('node-test-tmpdir.ts')) return false;
return /(^|\s)--test(\s|$)/.test(trimmed);
}
// Date.now() alone collides when this file's top-level tests happen to start
// within the same millisecond (observed in practice), which overwrites one
// probe's source with another's; the random suffix makes each probe path
// unique regardless of scheduling.
function writeProbe(evidencePath: string): string {
const probeName = `node-test-tmpdir-probe-${process.pid}-${crypto.randomUUID()}.test.ts`;
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
fs.writeFileSync(
probePath,
`import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
test('probe records its inherited TMPDIR', () => {
fs.writeFileSync(${JSON.stringify(evidencePath)}, os.tmpdir());
});
`,
);
return probePath;
}
test('the wrapper redirects a node --test child TMPDIR and removes it after the run', async () => {
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-lifecycle-test-'));
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
const probePath = writeProbe(evidencePath);
let childTmpDir: string | undefined;
try {
const result = await runCmd(
process.execPath,
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
);
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
assert.equal(path.dirname(childTmpDir), TEST_RUN_TMP_ROOT);
assert.match(path.basename(childTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
assert.equal(
fs.existsSync(childTmpDir),
false,
'the wrapper must remove the run directory after the child exits',
);
} finally {
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
test('the wrapper still cleans up and forwards a nonzero exit code when the child fails', async () => {
// Diffing the whole shared TEST_RUN_TMP_ROOT listing is racy here: this
// file itself runs as one of several node --test files/workers sharing
// that root (e.g. alongside vitest-tmpdir-global-setup.test.ts in
// check:tmpdir-leaks:test), any of which can create and remove their own
// sibling run directory mid-diff. Recording the probe's actual TMPDIR (the
// same technique as the test above) and checking only that one path avoids
// the shared-directory race entirely.
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-lifecycle-fail-'));
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
const probeName = `node-test-tmpdir-probe-fail-${process.pid}-${crypto.randomUUID()}.test.ts`;
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
fs.writeFileSync(
probePath,
`import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import { test } from 'node:test';
test('deliberately failing probe', () => {
fs.writeFileSync(${JSON.stringify(evidencePath)}, os.tmpdir());
assert.fail('intentional failure to verify exit-code forwarding');
});
`,
);
let childTmpDir: string | undefined;
try {
const result = await runCmd(
process.execPath,
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000, allowFailure: true },
);
assert.notEqual(result.exitCode, 0, 'a failing child must propagate a nonzero exit code');
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
assert.equal(path.dirname(childTmpDir), TEST_RUN_TMP_ROOT);
assert.match(path.basename(childTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
} finally {
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
test('the wrapper keeps AGENT_DEVICE_SWIFT_CACHE_DIR outside the disposable TMPDIR', async () => {
// Mirrors vitest-tmpdir-global-setup.ts's own carve-out (and its test's
// technique below): the Swift compiler cache must survive across runs, so
// it must resolve outside whichever directory this invocation's TMPDIR
// redirect will remove afterward. Passing '' forces the "unset" branch
// regardless of what this test process itself inherited.
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-swift-cache-'));
const evidencePath = path.join(evidenceRoot, 'swift-cache-dir.txt');
const probeName = `node-test-tmpdir-probe-swift-cache-${process.pid}-${crypto.randomUUID()}.test.ts`;
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
fs.writeFileSync(
probePath,
`import fs from 'node:fs';
import { test } from 'node:test';
test('probe records its inherited AGENT_DEVICE_SWIFT_CACHE_DIR', () => {
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_SWIFT_CACHE_DIR ?? '');
});
`,
);
// Computed the same way the wrapper computes it: from THIS process's
// os.tmpdir(), before the child's TMPDIR gets redirected. If this file is
// itself already running nested inside another wrapper's redirect (e.g.
// as part of check:tmpdir-leaks:test), that's the correct anchor too — the
// cache chains to whichever temp scope was current right before this
// specific invocation, exactly like vitest's setup() would if nested the
// same way.
const expectedCacheDir = path.join(os.tmpdir(), 'agent-device-swift-cache');
try {
const result = await runCmd(
process.execPath,
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
{
cwd: REPOSITORY_ROOT,
timeoutMs: 30_000,
env: { ...process.env, AGENT_DEVICE_SWIFT_CACHE_DIR: '' },
},
);
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
const cacheDir = fs.readFileSync(evidencePath, 'utf8');
assert.equal(
cacheDir,
expectedCacheDir,
'the wrapper must set AGENT_DEVICE_SWIFT_CACHE_DIR from the pre-redirect os.tmpdir(), ' +
'not leave it to default inside the disposable TMPDIR it is about to remove',
);
} finally {
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
test('the wrapper isolates advisory device claims inside its disposable run directory', async () => {
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-claims-dir-'));
const evidencePath = path.join(evidenceRoot, 'claims-dir.txt');
const probeName = `node-test-claims-dir-probe-${process.pid}-${crypto.randomUUID()}.test.ts`;
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
fs.writeFileSync(
probePath,
`import fs from 'node:fs';
import { test } from 'node:test';
test('probe records its inherited AGENT_DEVICE_CLAIMS_DIR', () => {
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_CLAIMS_DIR ?? '');
});
`,
);
try {
const result = await runCmd(
process.execPath,
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
{
cwd: REPOSITORY_ROOT,
timeoutMs: 30_000,
env: { ...process.env, AGENT_DEVICE_CLAIMS_DIR: '/host/device-claims' },
},
);
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
const claimsDir = fs.readFileSync(evidencePath, 'utf8');
assert.equal(path.basename(claimsDir), 'device-claims');
assert.equal(
fs.existsSync(path.dirname(claimsDir)),
false,
'the claims directory must be removed with the wrapper-owned run directory',
);
assert.notEqual(claimsDir, '/host/device-claims');
} finally {
fs.rmSync(probePath, { force: true });
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
});
// The 13 lanes wrapped in package.json when this fix landed were a one-time
// hand sweep; nothing stopped a 14th `node --test` script from being added
// later without the wrapper, silently reopening #1595 for that one lane.
// This turns the sweep into an invariant instead.
test('every node --test package.json script routes through scripts/node-test-tmpdir.ts', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
) as {
scripts?: Record<string, string>;
};
const scripts = manifest.scripts ?? {};
const unwrapped = Object.entries(scripts)
.filter(([name]) => !NODE_TEST_WRAPPER_BYPASS_ALLOWLIST.has(name))
.filter(([, command]) => command.split('&&').some(isUnwrappedNodeTestSegment))
.map(([name]) => name);
assert.deepEqual(
unwrapped,
[],
`these package.json scripts invoke \`node --test\` directly instead of through ` +
`scripts/node-test-tmpdir.ts, so a crash/timeout kill during their run leaks a scratch ` +
`directory again: ${unwrapped.join(', ')}. Route them through the wrapper, or add to ` +
`NODE_TEST_WRAPPER_BYPASS_ALLOWLIST above with a reason if one must legitimately bypass it.`,
);
});