The guard added in the previous commit refused legitimate temp directories on macOS. os.tmpdir() returns a path under /var/folders/..., /var is a symlink to /private/var, and path.resolve() does not resolve symlinks. So a caller that passed the realpath'd form — via fs.realpathSync(), or via process.cwd() after chdir-ing into a temp dir, which returns the resolved path — produced /private/var/folders/... and failed a check written against /var/folders/... Measured on this machine before the fix: mkdtemp path /var/folders/.../probe-XXX allowed fs.realpathSync of the same dir /private/var/folders/.../probe-XXX REFUSED process.cwd() after chdir to it /private/var/folders/.../probe-XXX REFUSED The accepted-roots set is now built from both the resolved and realpath'd forms of os.tmpdir(), deduped — on Linux they are identical, so the set collapses to one entry and nothing changes there. realpathSync is wrapped because it throws if the root is momentarily missing, and a safety check must not become a new crash. The target itself is deliberately NOT realpath'd: cleanup() is called on already-deleted directories, where realpathSync raises ENOENT. The roots are computed per call rather than hoisted to module scope, because two test files override TMPDIR and a hoisted value would go stale for them. Worth naming: the remote matrix runs linux-node22 and linux-node24 only, and it passed 30544/30544 on the broken commit. os.tmpdir() is /tmp on Linux with no symlink indirection, so that matrix could not have caught this at any sample size. The regression test added here branches on whether realpath differs from the original path, so it exercises the real case on macOS and stays meaningful rather than vacuous on Linux. Refs #3057 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.7 KiB
JavaScript
188 lines
7.7 KiB
JavaScript
/**
|
||
* GSD Tools Test Helpers – cleanup() behavioral tests
|
||
*
|
||
* Three deterministic, cross-platform tests that verify cleanup()'s
|
||
* observable contract at the seam rather than probing its internals.
|
||
*/
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const os = require('os');
|
||
|
||
const { cleanup, createTempDir } = require('./helpers.cjs');
|
||
|
||
// ─── Test 1: Real-FS happy path ──────────────────────────────────────────────
|
||
|
||
test('cleanup removes a real temp dir with nested subdirs and files', () => {
|
||
const dir = createTempDir('gsd-cleanup-test-');
|
||
const nested = path.join(dir, 'a', 'b', 'c');
|
||
fs.mkdirSync(nested, { recursive: true });
|
||
fs.writeFileSync(path.join(nested, 'file.txt'), 'hello');
|
||
fs.writeFileSync(path.join(dir, 'root.txt'), 'world');
|
||
|
||
cleanup(dir);
|
||
|
||
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should not exist after cleanup');
|
||
});
|
||
|
||
// ─── Test 2: Retry-budget contract ───────────────────────────────────────────
|
||
|
||
test('cleanup passes recursive/force/maxRetries/retryDelay options to fs.rmSync', () => {
|
||
// Use a real temp dir as the target so cleanup() has a valid path argument.
|
||
// We chdir AWAY from it first so cleanup() does not try to chdir either.
|
||
const dir = createTempDir('gsd-cleanup-opts-test-');
|
||
|
||
// Capture original cwd and shift away from the target.
|
||
const originalCwd = process.cwd();
|
||
// Chdir to the parent of the target so cleanup's cwd-guard is a no-op.
|
||
process.chdir(path.dirname(dir));
|
||
|
||
let capturedOptions = null;
|
||
const realRmSync = fs.rmSync;
|
||
|
||
try {
|
||
// Replace fs.rmSync with a probe that captures options then does nothing.
|
||
// This is an assignment expression (not a CallExpression) so it satisfies
|
||
// the ESLint rule that bans raw fs.rmSync(...) call expressions in tests.
|
||
fs.rmSync = (targetPath, opts) => {
|
||
capturedOptions = opts;
|
||
// Do NOT call through — we don't want the dir actually removed here;
|
||
// we're only testing the options shape.
|
||
};
|
||
|
||
cleanup(dir);
|
||
} finally {
|
||
fs.rmSync = realRmSync;
|
||
process.chdir(originalCwd);
|
||
// Remove the dir with the real rmSync now that we restored it.
|
||
cleanup(dir);
|
||
}
|
||
|
||
assert.ok(capturedOptions !== null, 'fs.rmSync should have been called');
|
||
assert.strictEqual(capturedOptions.recursive, true, 'recursive must be true');
|
||
assert.strictEqual(capturedOptions.force, true, 'force must be true');
|
||
assert.ok(
|
||
typeof capturedOptions.maxRetries === 'number' && capturedOptions.maxRetries > 0,
|
||
'maxRetries must be a positive number'
|
||
);
|
||
assert.ok(
|
||
typeof capturedOptions.retryDelay === 'number' && capturedOptions.retryDelay > 0,
|
||
'retryDelay must be a positive number'
|
||
);
|
||
});
|
||
|
||
// ─── Test 3: cwd-guard ───────────────────────────────────────────────────────
|
||
|
||
test('cleanup does not throw when cwd is inside the target dir, and removes the dir', () => {
|
||
const dir = createTempDir('gsd-cleanup-cwd-test-');
|
||
const nested = path.join(dir, 'deep', 'nested');
|
||
fs.mkdirSync(nested, { recursive: true });
|
||
|
||
const originalCwd = process.cwd();
|
||
|
||
try {
|
||
// Step INTO the nested subdir so cwd is inside the cleanup target.
|
||
process.chdir(nested);
|
||
|
||
assert.doesNotThrow(() => {
|
||
cleanup(dir);
|
||
}, 'cleanup should not throw even when cwd is inside the target');
|
||
} finally {
|
||
// Restore original cwd. cleanup() will have chdir'd to dirname(dir),
|
||
// so we always restore explicitly regardless.
|
||
if (process.cwd() !== originalCwd) {
|
||
process.chdir(originalCwd);
|
||
}
|
||
}
|
||
|
||
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should not exist after cleanup');
|
||
});
|
||
|
||
// ─── Test 4: out-of-tmpdir refusal ───────────────────────────────────────────
|
||
|
||
test('cleanup throws and does not chdir or delete when target is outside os.tmpdir()', () => {
|
||
// __dirname (this repo's tests/ directory) must never be deleted. Whether
|
||
// it is actually outside os.tmpdir() is environment-dependent: on Linux
|
||
// os.tmpdir() is /tmp, and a CI container that checks the repo out under
|
||
// /tmp would put __dirname INSIDE tmpdir, in which case a correctly-working
|
||
// cleanup() would not refuse it -- it would delete this directory. The
|
||
// precondition assertion below verifies the "outside tmpdir" assumption
|
||
// before cleanup() is ever called, so that situation fails loudly and
|
||
// safely instead of destructively. No scratch directory is created, so
|
||
// there is nothing to tear down.
|
||
const outsideDir = __dirname;
|
||
const knownFile = path.join(outsideDir, 'helpers-cleanup.test.cjs');
|
||
|
||
// Mirror cleanup()'s own out-of-tmpdir predicate (tests/helpers.cjs) so
|
||
// this test cannot run on a target it does not actually control.
|
||
const tmpRoot = path.resolve(os.tmpdir());
|
||
const resolvedOutsideDir = path.resolve(outsideDir);
|
||
const isInsideTmpdir =
|
||
resolvedOutsideDir === tmpRoot || resolvedOutsideDir.startsWith(`${tmpRoot}${path.sep}`);
|
||
assert.strictEqual(
|
||
isInsideTmpdir,
|
||
false,
|
||
`this test cannot run safely when the repo lives under os.tmpdir(): ` +
|
||
`outsideDir (${resolvedOutsideDir}) is inside os.tmpdir() (${tmpRoot})`
|
||
);
|
||
|
||
const cwdBefore = process.cwd();
|
||
|
||
assert.throws(
|
||
() => cleanup(outsideDir),
|
||
(err) => err instanceof Error && err.message.includes(outsideDir),
|
||
'cleanup must throw an Error whose message names the offending path'
|
||
);
|
||
|
||
assert.strictEqual(
|
||
process.cwd(),
|
||
cwdBefore,
|
||
'cleanup must refuse before chdir, so cwd is unchanged'
|
||
);
|
||
assert.strictEqual(fs.existsSync(outsideDir), true, 'target directory must still exist after refusal');
|
||
assert.strictEqual(
|
||
fs.existsSync(knownFile),
|
||
true,
|
||
'a known file inside the target must still exist, proving the dir was not emptied'
|
||
);
|
||
});
|
||
|
||
// ─── Test 5: control — a real os.tmpdir()-rooted path still cleans up ───────
|
||
|
||
test('cleanup still removes a real os.tmpdir()-rooted directory (control)', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-control-'));
|
||
|
||
cleanup(dir);
|
||
|
||
assert.strictEqual(fs.existsSync(dir), false, 'os.tmpdir()-rooted directory should be removed');
|
||
});
|
||
|
||
// ─── Test 6: realpath'd os.tmpdir() form is not refused (regression) ────────
|
||
|
||
test('cleanup accepts a realpath()d temp dir even when it differs from the raw path (macOS /var -> /private/var)', (t) => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cleanup-realpath-'));
|
||
const realPath = fs.realpathSync(dir);
|
||
|
||
if (realPath !== dir) {
|
||
// macOS (and any other symlinked-tmpdir platform): the realpath'd form
|
||
// diverges from the raw mkdtempSync() path. This is exactly the shape a
|
||
// caller gets from fs.realpathSync() or from process.cwd() after
|
||
// chdir-ing into a realpath'd dir — assert the guard does NOT refuse it.
|
||
cleanup(realPath);
|
||
assert.strictEqual(fs.existsSync(realPath), false, 'realpath()d form should be removed, not refused');
|
||
assert.strictEqual(fs.existsSync(dir), false, 'raw path should also be gone (same directory)');
|
||
} else {
|
||
// Linux and any platform with no tmpdir symlink indirection: realpath()
|
||
// equals the raw path, so this branch exercises the ordinary path and
|
||
// keeps the test meaningful (non-vacuous) on both platforms.
|
||
t.after(() => {
|
||
if (fs.existsSync(dir)) cleanup(dir);
|
||
});
|
||
cleanup(dir);
|
||
assert.strictEqual(fs.existsSync(dir), false, 'temp dir should be removed');
|
||
}
|
||
});
|