test(FN-2360): harden test isolation around repo fusion state
This commit is contained in:
@@ -1,22 +1,26 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { existsSync, renameSync, rmSync } from "node:fs";
|
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import vitestConfig from "../../vitest.config";
|
import vitestConfig from "../../vitest.config";
|
||||||
|
|
||||||
const cliRoot = join(__dirname, "..", "..");
|
const cliRoot = join(__dirname, "..", "..");
|
||||||
const workspaceRoot = join(cliRoot, "..", "..");
|
const workspaceRoot = join(cliRoot, "..", "..");
|
||||||
|
const hiddenDistRoot = join(workspaceRoot, `.tmp-fn-vitest-workspace-resolution-${process.pid}`);
|
||||||
|
|
||||||
const internalPackages = ["core", "engine", "dashboard"] as const;
|
const internalPackages = ["core", "engine", "dashboard"] as const;
|
||||||
const movedDistDirs: Array<{ from: string; to: string }> = [];
|
const movedDistDirs: Array<{ from: string; to: string }> = [];
|
||||||
|
|
||||||
function hideInternalPackageDistDirs() {
|
function hideInternalPackageDistDirs() {
|
||||||
|
rmSync(hiddenDistRoot, { recursive: true, force: true });
|
||||||
|
mkdirSync(hiddenDistRoot, { recursive: true });
|
||||||
|
|
||||||
for (const pkg of internalPackages) {
|
for (const pkg of internalPackages) {
|
||||||
const distPath = join(workspaceRoot, "packages", pkg, "dist");
|
const distPath = join(workspaceRoot, "packages", pkg, "dist");
|
||||||
if (!existsSync(distPath)) {
|
if (!existsSync(distPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hiddenPath = `${distPath}.__fn2360-hidden-${process.pid}`;
|
const hiddenPath = join(hiddenDistRoot, `${pkg}-dist`);
|
||||||
if (existsSync(hiddenPath)) {
|
if (existsSync(hiddenPath)) {
|
||||||
rmSync(hiddenPath, { recursive: true, force: true });
|
rmSync(hiddenPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -39,6 +43,7 @@ function restoreInternalPackageDistDirs() {
|
|||||||
renameSync(to, from);
|
renameSync(to, from);
|
||||||
}
|
}
|
||||||
movedDistDirs.length = 0;
|
movedDistDirs.length = 0;
|
||||||
|
rmSync(hiddenDistRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CLI Vitest workspace resolution", () => {
|
describe("CLI Vitest workspace resolution", () => {
|
||||||
|
|||||||
@@ -5,16 +5,26 @@
|
|||||||
* 2. Changes process.cwd() to a per-worker temp dir (main thread only) so any
|
* 2. Changes process.cwd() to a per-worker temp dir (main thread only) so any
|
||||||
* accidental `process.cwd()` call resolves to a disposable path.
|
* accidental `process.cwd()` call resolves to a disposable path.
|
||||||
* 3. Wraps `process.chdir` to reject attempts to chdir into the real .fusion.
|
* 3. Wraps `process.chdir` to reject attempts to chdir into the real .fusion.
|
||||||
|
* 4. Wraps write-capable fs APIs so tests cannot mutate the repo's live .fusion.
|
||||||
*
|
*
|
||||||
* Worker temp dirs live under a single parent (FUSION_WORKER_ROOT) that is
|
* Worker temp dirs live under a single parent (FUSION_WORKER_ROOT) that is
|
||||||
* wiped by the vitest globalTeardown in vitest-teardown.ts — this handles the
|
* wiped by the vitest globalTeardown in vitest-teardown.ts — this handles the
|
||||||
* case where workers are killed (SIGKILL) and never run their exit handlers.
|
* case where workers are killed (SIGKILL) and never run their exit handlers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdtempSync, mkdirSync, rmSync, realpathSync, existsSync } from "node:fs";
|
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve, sep } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { isMainThread } from "node:worker_threads";
|
import { isMainThread } from "node:worker_threads";
|
||||||
|
import { assertOutsideRealFusionPath } from "../test-safety.js";
|
||||||
|
|
||||||
|
type FsModule = typeof import("node:fs");
|
||||||
|
type FsPromisesModule = typeof import("node:fs/promises");
|
||||||
|
|
||||||
|
const requireFromHere = createRequire(import.meta.url);
|
||||||
|
const fs = requireFromHere("node:fs") as FsModule;
|
||||||
|
const fsPromises = requireFromHere("node:fs/promises") as FsPromisesModule;
|
||||||
|
const { mkdtempSync, mkdirSync, rmSync, realpathSync, existsSync } = fs;
|
||||||
|
|
||||||
const originalCwd = process.cwd.bind(process);
|
const originalCwd = process.cwd.bind(process);
|
||||||
|
|
||||||
@@ -77,22 +87,250 @@ if (isMainThread) {
|
|||||||
process.chdir(workerTempDir);
|
process.chdir(workerTempDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installFsGuards(): void {
|
||||||
|
const guardState = globalThis as typeof globalThis & { __fusionTestFsGuardInstalled?: boolean };
|
||||||
|
if (guardState.__fusionTestFsGuardInstalled) return;
|
||||||
|
guardState.__fusionTestFsGuardInstalled = true;
|
||||||
|
|
||||||
|
const mutableFs = fs as unknown as Record<string, unknown>;
|
||||||
|
const mutableFsPromises = fsPromises as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
const originalFs = {
|
||||||
|
mkdirSync: fs.mkdirSync.bind(fs),
|
||||||
|
writeFileSync: fs.writeFileSync.bind(fs),
|
||||||
|
appendFileSync: fs.appendFileSync.bind(fs),
|
||||||
|
rmSync: fs.rmSync.bind(fs),
|
||||||
|
unlinkSync: fs.unlinkSync.bind(fs),
|
||||||
|
rmdirSync: fs.rmdirSync.bind(fs),
|
||||||
|
renameSync: fs.renameSync.bind(fs),
|
||||||
|
copyFileSync: fs.copyFileSync.bind(fs),
|
||||||
|
cpSync: fs.cpSync.bind(fs),
|
||||||
|
mkdtempSync: fs.mkdtempSync.bind(fs),
|
||||||
|
openSync: fs.openSync.bind(fs),
|
||||||
|
createWriteStream: fs.createWriteStream.bind(fs),
|
||||||
|
truncateSync: fs.truncateSync.bind(fs),
|
||||||
|
linkSync: fs.linkSync.bind(fs),
|
||||||
|
symlinkSync: fs.symlinkSync.bind(fs),
|
||||||
|
mkdir: fs.mkdir.bind(fs),
|
||||||
|
writeFile: fs.writeFile.bind(fs),
|
||||||
|
appendFile: fs.appendFile.bind(fs),
|
||||||
|
rm: fs.rm.bind(fs),
|
||||||
|
unlink: fs.unlink.bind(fs),
|
||||||
|
rmdir: fs.rmdir.bind(fs),
|
||||||
|
rename: fs.rename.bind(fs),
|
||||||
|
copyFile: fs.copyFile.bind(fs),
|
||||||
|
cp: fs.cp.bind(fs),
|
||||||
|
open: fs.open.bind(fs),
|
||||||
|
truncate: fs.truncate.bind(fs),
|
||||||
|
link: fs.link.bind(fs),
|
||||||
|
symlink: fs.symlink.bind(fs),
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalFsPromises = {
|
||||||
|
mkdir: fsPromises.mkdir.bind(fsPromises),
|
||||||
|
writeFile: fsPromises.writeFile.bind(fsPromises),
|
||||||
|
appendFile: fsPromises.appendFile.bind(fsPromises),
|
||||||
|
rm: fsPromises.rm.bind(fsPromises),
|
||||||
|
unlink: fsPromises.unlink.bind(fsPromises),
|
||||||
|
rmdir: fsPromises.rmdir.bind(fsPromises),
|
||||||
|
rename: fsPromises.rename.bind(fsPromises),
|
||||||
|
copyFile: fsPromises.copyFile.bind(fsPromises),
|
||||||
|
cp: fsPromises.cp.bind(fsPromises),
|
||||||
|
open: fsPromises.open.bind(fsPromises),
|
||||||
|
mkdtemp: fsPromises.mkdtemp.bind(fsPromises),
|
||||||
|
truncate: fsPromises.truncate.bind(fsPromises),
|
||||||
|
link: fsPromises.link.bind(fsPromises),
|
||||||
|
symlink: fsPromises.symlink.bind(fsPromises),
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardOne = (pathValue: unknown, context: string) => {
|
||||||
|
if (pathValue === undefined || pathValue === null) return;
|
||||||
|
assertOutsideRealFusionPath(pathValue as Parameters<typeof assertOutsideRealFusionPath>[0], context);
|
||||||
|
};
|
||||||
|
const guardBoth = (source: unknown, target: unknown, context: string) => {
|
||||||
|
guardOne(source, `${context} source`);
|
||||||
|
guardOne(target, `${context} target`);
|
||||||
|
};
|
||||||
|
|
||||||
|
mutableFs.mkdirSync = ((path, options) => {
|
||||||
|
guardOne(path, "fs.mkdirSync");
|
||||||
|
return originalFs.mkdirSync(path, options as Parameters<typeof fs.mkdirSync>[1]);
|
||||||
|
}) as typeof fs.mkdirSync;
|
||||||
|
mutableFs.writeFileSync = ((path, data, options) => {
|
||||||
|
guardOne(path, "fs.writeFileSync");
|
||||||
|
return originalFs.writeFileSync(path, data, options as Parameters<typeof fs.writeFileSync>[2]);
|
||||||
|
}) as typeof fs.writeFileSync;
|
||||||
|
mutableFs.appendFileSync = ((path, data, options) => {
|
||||||
|
guardOne(path, "fs.appendFileSync");
|
||||||
|
return originalFs.appendFileSync(path, data, options as Parameters<typeof fs.appendFileSync>[2]);
|
||||||
|
}) as typeof fs.appendFileSync;
|
||||||
|
mutableFs.rmSync = ((path, options) => {
|
||||||
|
guardOne(path, "fs.rmSync");
|
||||||
|
return originalFs.rmSync(path, options as Parameters<typeof fs.rmSync>[1]);
|
||||||
|
}) as typeof fs.rmSync;
|
||||||
|
mutableFs.unlinkSync = ((path) => {
|
||||||
|
guardOne(path, "fs.unlinkSync");
|
||||||
|
return originalFs.unlinkSync(path);
|
||||||
|
}) as typeof fs.unlinkSync;
|
||||||
|
mutableFs.rmdirSync = ((path, options) => {
|
||||||
|
guardOne(path, "fs.rmdirSync");
|
||||||
|
return originalFs.rmdirSync(path, options as Parameters<typeof fs.rmdirSync>[1]);
|
||||||
|
}) as typeof fs.rmdirSync;
|
||||||
|
mutableFs.renameSync = ((oldPath, newPath) => {
|
||||||
|
guardBoth(oldPath, newPath, "fs.renameSync");
|
||||||
|
return originalFs.renameSync(oldPath, newPath);
|
||||||
|
}) as typeof fs.renameSync;
|
||||||
|
mutableFs.copyFileSync = ((src, dest, mode) => {
|
||||||
|
guardBoth(src, dest, "fs.copyFileSync");
|
||||||
|
return originalFs.copyFileSync(src, dest, mode as Parameters<typeof fs.copyFileSync>[2]);
|
||||||
|
}) as typeof fs.copyFileSync;
|
||||||
|
mutableFs.cpSync = ((src, dest, options) => {
|
||||||
|
guardBoth(src, dest, "fs.cpSync");
|
||||||
|
return originalFs.cpSync(src, dest, options as Parameters<typeof fs.cpSync>[2]);
|
||||||
|
}) as typeof fs.cpSync;
|
||||||
|
mutableFs.mkdtempSync = ((prefix, options) => {
|
||||||
|
guardOne(prefix, "fs.mkdtempSync");
|
||||||
|
return originalFs.mkdtempSync(prefix, options as Parameters<typeof fs.mkdtempSync>[1]);
|
||||||
|
}) as typeof fs.mkdtempSync;
|
||||||
|
mutableFs.openSync = ((path, flags, mode) => {
|
||||||
|
guardOne(path, "fs.openSync");
|
||||||
|
return originalFs.openSync(path, flags, mode as Parameters<typeof fs.openSync>[2]);
|
||||||
|
}) as typeof fs.openSync;
|
||||||
|
mutableFs.createWriteStream = ((path, options) => {
|
||||||
|
guardOne(path, "fs.createWriteStream");
|
||||||
|
return originalFs.createWriteStream(path, options as Parameters<typeof fs.createWriteStream>[1]);
|
||||||
|
}) as typeof fs.createWriteStream;
|
||||||
|
mutableFs.truncateSync = ((path, len) => {
|
||||||
|
guardOne(path, "fs.truncateSync");
|
||||||
|
return originalFs.truncateSync(path, len as Parameters<typeof fs.truncateSync>[1]);
|
||||||
|
}) as typeof fs.truncateSync;
|
||||||
|
mutableFs.linkSync = ((existingPath, newPath) => {
|
||||||
|
guardBoth(existingPath, newPath, "fs.linkSync");
|
||||||
|
return originalFs.linkSync(existingPath, newPath);
|
||||||
|
}) as typeof fs.linkSync;
|
||||||
|
mutableFs.symlinkSync = ((target, path, type) => {
|
||||||
|
guardBoth(target, path, "fs.symlinkSync");
|
||||||
|
return originalFs.symlinkSync(target, path, type as Parameters<typeof fs.symlinkSync>[2]);
|
||||||
|
}) as typeof fs.symlinkSync;
|
||||||
|
|
||||||
|
mutableFs.mkdir = ((...args: Parameters<typeof fs.mkdir>) => {
|
||||||
|
guardOne(args[0], "fs.mkdir");
|
||||||
|
return originalFs.mkdir(...args);
|
||||||
|
}) as typeof fs.mkdir;
|
||||||
|
mutableFs.writeFile = ((...args: Parameters<typeof fs.writeFile>) => {
|
||||||
|
guardOne(args[0], "fs.writeFile");
|
||||||
|
return originalFs.writeFile(...args);
|
||||||
|
}) as typeof fs.writeFile;
|
||||||
|
mutableFs.appendFile = ((...args: Parameters<typeof fs.appendFile>) => {
|
||||||
|
guardOne(args[0], "fs.appendFile");
|
||||||
|
return originalFs.appendFile(...args);
|
||||||
|
}) as typeof fs.appendFile;
|
||||||
|
mutableFs.rm = ((...args: Parameters<typeof fs.rm>) => {
|
||||||
|
guardOne(args[0], "fs.rm");
|
||||||
|
return originalFs.rm(...args);
|
||||||
|
}) as typeof fs.rm;
|
||||||
|
mutableFs.unlink = ((...args: Parameters<typeof fs.unlink>) => {
|
||||||
|
guardOne(args[0], "fs.unlink");
|
||||||
|
return originalFs.unlink(...args);
|
||||||
|
}) as typeof fs.unlink;
|
||||||
|
mutableFs.rmdir = ((...args: Parameters<typeof fs.rmdir>) => {
|
||||||
|
guardOne(args[0], "fs.rmdir");
|
||||||
|
return originalFs.rmdir(...args);
|
||||||
|
}) as typeof fs.rmdir;
|
||||||
|
mutableFs.rename = ((...args: Parameters<typeof fs.rename>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.rename");
|
||||||
|
return originalFs.rename(...args);
|
||||||
|
}) as typeof fs.rename;
|
||||||
|
mutableFs.copyFile = ((...args: Parameters<typeof fs.copyFile>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.copyFile");
|
||||||
|
return originalFs.copyFile(...args);
|
||||||
|
}) as typeof fs.copyFile;
|
||||||
|
mutableFs.cp = ((...args: Parameters<typeof fs.cp>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.cp");
|
||||||
|
return originalFs.cp(...args);
|
||||||
|
}) as typeof fs.cp;
|
||||||
|
mutableFs.open = ((...args: Parameters<typeof fs.open>) => {
|
||||||
|
guardOne(args[0], "fs.open");
|
||||||
|
return originalFs.open(...args);
|
||||||
|
}) as typeof fs.open;
|
||||||
|
mutableFs.truncate = ((...args: Parameters<typeof fs.truncate>) => {
|
||||||
|
guardOne(args[0], "fs.truncate");
|
||||||
|
return originalFs.truncate(...args);
|
||||||
|
}) as typeof fs.truncate;
|
||||||
|
mutableFs.link = ((...args: Parameters<typeof fs.link>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.link");
|
||||||
|
return originalFs.link(...args);
|
||||||
|
}) as typeof fs.link;
|
||||||
|
mutableFs.symlink = ((...args: Parameters<typeof fs.symlink>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.symlink");
|
||||||
|
return originalFs.symlink(...args);
|
||||||
|
}) as typeof fs.symlink;
|
||||||
|
|
||||||
|
mutableFsPromises.mkdir = (async (...args: Parameters<typeof fsPromises.mkdir>) => {
|
||||||
|
guardOne(args[0], "fs.promises.mkdir");
|
||||||
|
return originalFsPromises.mkdir(...args);
|
||||||
|
}) as typeof fsPromises.mkdir;
|
||||||
|
mutableFsPromises.writeFile = (async (...args: Parameters<typeof fsPromises.writeFile>) => {
|
||||||
|
guardOne(args[0], "fs.promises.writeFile");
|
||||||
|
return originalFsPromises.writeFile(...args);
|
||||||
|
}) as typeof fsPromises.writeFile;
|
||||||
|
mutableFsPromises.appendFile = (async (...args: Parameters<typeof fsPromises.appendFile>) => {
|
||||||
|
guardOne(args[0], "fs.promises.appendFile");
|
||||||
|
return originalFsPromises.appendFile(...args);
|
||||||
|
}) as typeof fsPromises.appendFile;
|
||||||
|
mutableFsPromises.rm = (async (...args: Parameters<typeof fsPromises.rm>) => {
|
||||||
|
guardOne(args[0], "fs.promises.rm");
|
||||||
|
return originalFsPromises.rm(...args);
|
||||||
|
}) as typeof fsPromises.rm;
|
||||||
|
mutableFsPromises.unlink = (async (...args: Parameters<typeof fsPromises.unlink>) => {
|
||||||
|
guardOne(args[0], "fs.promises.unlink");
|
||||||
|
return originalFsPromises.unlink(...args);
|
||||||
|
}) as typeof fsPromises.unlink;
|
||||||
|
mutableFsPromises.rmdir = (async (...args: Parameters<typeof fsPromises.rmdir>) => {
|
||||||
|
guardOne(args[0], "fs.promises.rmdir");
|
||||||
|
return originalFsPromises.rmdir(...args);
|
||||||
|
}) as typeof fsPromises.rmdir;
|
||||||
|
mutableFsPromises.rename = (async (...args: Parameters<typeof fsPromises.rename>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.promises.rename");
|
||||||
|
return originalFsPromises.rename(...args);
|
||||||
|
}) as typeof fsPromises.rename;
|
||||||
|
mutableFsPromises.copyFile = (async (...args: Parameters<typeof fsPromises.copyFile>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.promises.copyFile");
|
||||||
|
return originalFsPromises.copyFile(...args);
|
||||||
|
}) as typeof fsPromises.copyFile;
|
||||||
|
mutableFsPromises.cp = (async (...args: Parameters<typeof fsPromises.cp>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.promises.cp");
|
||||||
|
return originalFsPromises.cp(...args);
|
||||||
|
}) as typeof fsPromises.cp;
|
||||||
|
mutableFsPromises.open = (async (...args: Parameters<typeof fsPromises.open>) => {
|
||||||
|
guardOne(args[0], "fs.promises.open");
|
||||||
|
return originalFsPromises.open(...args);
|
||||||
|
}) as typeof fsPromises.open;
|
||||||
|
mutableFsPromises.mkdtemp = (async (...args: Parameters<typeof fsPromises.mkdtemp>) => {
|
||||||
|
guardOne(args[0], "fs.promises.mkdtemp");
|
||||||
|
return originalFsPromises.mkdtemp(...args);
|
||||||
|
}) as typeof fsPromises.mkdtemp;
|
||||||
|
mutableFsPromises.truncate = (async (...args: Parameters<typeof fsPromises.truncate>) => {
|
||||||
|
guardOne(args[0], "fs.promises.truncate");
|
||||||
|
return originalFsPromises.truncate(...args);
|
||||||
|
}) as typeof fsPromises.truncate;
|
||||||
|
mutableFsPromises.link = (async (...args: Parameters<typeof fsPromises.link>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.promises.link");
|
||||||
|
return originalFsPromises.link(...args);
|
||||||
|
}) as typeof fsPromises.link;
|
||||||
|
mutableFsPromises.symlink = (async (...args: Parameters<typeof fsPromises.symlink>) => {
|
||||||
|
guardBoth(args[0], args[1], "fs.promises.symlink");
|
||||||
|
return originalFsPromises.symlink(...args);
|
||||||
|
}) as typeof fsPromises.symlink;
|
||||||
|
|
||||||
|
syncBuiltinESMExports();
|
||||||
|
}
|
||||||
|
|
||||||
|
installFsGuards();
|
||||||
|
|
||||||
const originalChdir = process.chdir.bind(process);
|
const originalChdir = process.chdir.bind(process);
|
||||||
process.chdir = (target: string) => {
|
process.chdir = (target: string) => {
|
||||||
const resolvedTarget = (() => {
|
assertOutsideRealFusionPath(target, "process.chdir");
|
||||||
try {
|
|
||||||
return realpathSync(target);
|
|
||||||
} catch {
|
|
||||||
return resolve(target);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
const realFusion = join(repoRoot, ".fusion");
|
|
||||||
if (resolvedTarget === realFusion || resolvedTarget.startsWith(realFusion + sep)) {
|
|
||||||
throw new Error(
|
|
||||||
`[test-safety] Test attempted process.chdir into real .fusion directory: ${resolvedTarget}\n` +
|
|
||||||
`Use useIsolatedCwd() from __test-utils__/workspace.ts instead.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
originalChdir(target);
|
originalChdir(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* All tests that touch the filesystem or resolve paths from process.cwd()
|
* All tests that touch the filesystem or resolve paths from process.cwd()
|
||||||
* should use these helpers so they never touch the real user's ~/Projects or
|
* should use these helpers so they never touch the real user's ~/Projects or
|
||||||
* the repo's real .fusion/ directory.
|
* the repo's protected .fusion/ directory.
|
||||||
*
|
*
|
||||||
* - `tempWorkspace()` returns a tracked temp dir that is auto-removed in afterEach.
|
* - `tempWorkspace()` returns a tracked temp dir that is auto-removed in afterEach.
|
||||||
* - `useIsolatedCwd()` chdirs into a tracked temp dir for the test and restores after.
|
* - `useIsolatedCwd()` chdirs into a tracked temp dir for the test and restores after.
|
||||||
@@ -12,40 +12,12 @@
|
|||||||
|
|
||||||
import { mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
|
import { mkdtempSync, rmSync, existsSync, realpathSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, resolve, sep } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { afterEach } from "vitest";
|
import { afterEach } from "vitest";
|
||||||
|
import { assertOutsideRealFusionPath } from "../test-safety.js";
|
||||||
let realFusionRootCache: string | null = null;
|
|
||||||
function getRealFusionRoot(): string | null {
|
|
||||||
if (realFusionRootCache !== null) return realFusionRootCache;
|
|
||||||
const fromEnv = process.env.FUSION_TEST_REAL_ROOT;
|
|
||||||
if (fromEnv) {
|
|
||||||
try {
|
|
||||||
realFusionRootCache = realpathSync(fromEnv);
|
|
||||||
} catch {
|
|
||||||
realFusionRootCache = resolve(fromEnv);
|
|
||||||
}
|
|
||||||
return realFusionRootCache;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertOutsideRealFusion(path: string, context = "operation"): void {
|
export function assertOutsideRealFusion(path: string, context = "operation"): void {
|
||||||
const realRoot = getRealFusionRoot();
|
assertOutsideRealFusionPath(path, context);
|
||||||
if (!realRoot) return;
|
|
||||||
let candidate: string;
|
|
||||||
try {
|
|
||||||
candidate = realpathSync(path);
|
|
||||||
} catch {
|
|
||||||
candidate = resolve(path);
|
|
||||||
}
|
|
||||||
const realFusionDir = join(realRoot, ".fusion");
|
|
||||||
if (candidate === realFusionDir || candidate.startsWith(realFusionDir + sep)) {
|
|
||||||
throw new Error(
|
|
||||||
`[test-safety] ${context} targeted real user .fusion directory: ${candidate}\n` +
|
|
||||||
`Tests must operate inside a temp directory. Use tempWorkspace() or useIsolatedCwd().`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTempDirs = new Set<string>();
|
const activeTempDirs = new Set<string>();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import * as fsPromises from "node:fs/promises";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Database } from "../db.js";
|
||||||
|
|
||||||
const TEMP_HOME_PREFIX = "fn-test-home-";
|
const TEMP_HOME_PREFIX = "fn-test-home-";
|
||||||
|
|
||||||
@@ -46,4 +49,38 @@ describe("test isolation setup", () => {
|
|||||||
|
|
||||||
expect(process.cwd().startsWith(repoFusionDir)).toBe(false);
|
expect(process.cwd().startsWith(repoFusionDir)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks sync filesystem writes into the repository .fusion directory", () => {
|
||||||
|
const thisFile = fileURLToPath(import.meta.url);
|
||||||
|
const repoRoot = resolve(dirname(thisFile), "../../../../");
|
||||||
|
const blockedPath = join(repoRoot, ".fusion", "__vitest-guard-sync__");
|
||||||
|
|
||||||
|
expect(() => mkdirSync(blockedPath, { recursive: true })).toThrow(
|
||||||
|
"targeted protected repo .fusion directory",
|
||||||
|
);
|
||||||
|
expect(() => writeFileSync(join(repoRoot, ".fusion", "__vitest-guard-sync__.txt"), "x")).toThrow(
|
||||||
|
"targeted protected repo .fusion directory",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks async filesystem writes into the repository .fusion directory", async () => {
|
||||||
|
const thisFile = fileURLToPath(import.meta.url);
|
||||||
|
const repoRoot = resolve(dirname(thisFile), "../../../../");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fsPromises.mkdir(join(repoRoot, ".fusion", "__vitest-guard-async__"), { recursive: true }),
|
||||||
|
).rejects.toThrow("targeted protected repo .fusion directory");
|
||||||
|
await expect(
|
||||||
|
fsPromises.writeFile(join(repoRoot, ".fusion", "__vitest-guard-async__.txt"), "x"),
|
||||||
|
).rejects.toThrow("targeted protected repo .fusion directory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks SQLite opens against the repository fusion database", () => {
|
||||||
|
const thisFile = fileURLToPath(import.meta.url);
|
||||||
|
const repoRoot = resolve(dirname(thisFile), "../../../../");
|
||||||
|
|
||||||
|
expect(() => new Database(join(repoRoot, ".fusion"))).toThrow(
|
||||||
|
"targeted protected repo .fusion directory",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
import { assertOutsideRealFusionPath } from "./test-safety.js";
|
||||||
|
|
||||||
const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ export class DatabaseSync {
|
|||||||
private impl: RawDatabase;
|
private impl: RawDatabase;
|
||||||
|
|
||||||
constructor(path: string) {
|
constructor(path: string) {
|
||||||
|
assertOutsideRealFusionPath(path, "SQLite database open");
|
||||||
const Ctor = loadDatabaseCtor();
|
const Ctor = loadDatabaseCtor();
|
||||||
this.impl = new Ctor(path);
|
this.impl = new Ctor(path);
|
||||||
}
|
}
|
||||||
|
|||||||
51
packages/core/src/test-safety.ts
Normal file
51
packages/core/src/test-safety.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { realpathSync, type PathLike } from "node:fs";
|
||||||
|
import { join, resolve, sep } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
function pathLikeToString(pathValue: PathLike): string {
|
||||||
|
if (typeof pathValue === "string") return pathValue;
|
||||||
|
if (pathValue instanceof URL) return fileURLToPath(pathValue);
|
||||||
|
return pathValue.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGuardPath(pathValue: PathLike): string {
|
||||||
|
const raw = pathLikeToString(pathValue);
|
||||||
|
if (!raw || raw === ":memory:") return raw;
|
||||||
|
try {
|
||||||
|
return realpathSync(raw);
|
||||||
|
} catch {
|
||||||
|
return resolve(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProtectedFusionDir(): string | null {
|
||||||
|
const root = process.env.FUSION_TEST_REAL_ROOT;
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
const resolvedRoot = resolveGuardPath(root);
|
||||||
|
if (!resolvedRoot) return null;
|
||||||
|
return join(resolvedRoot, ".fusion");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithinProtectedFusionDir(pathValue: PathLike): boolean {
|
||||||
|
const protectedFusionDir = getProtectedFusionDir();
|
||||||
|
if (!protectedFusionDir) return false;
|
||||||
|
|
||||||
|
const candidate = resolveGuardPath(pathValue);
|
||||||
|
if (!candidate || candidate === ":memory:") return false;
|
||||||
|
return candidate === protectedFusionDir || candidate.startsWith(protectedFusionDir + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOutsideRealFusionPath(pathValue: PathLike, context = "operation"): void {
|
||||||
|
const protectedFusionDir = getProtectedFusionDir();
|
||||||
|
if (!protectedFusionDir) return;
|
||||||
|
|
||||||
|
const candidate = resolveGuardPath(pathValue);
|
||||||
|
if (!candidate || candidate === ":memory:") return;
|
||||||
|
if (!isWithinProtectedFusionDir(candidate)) return;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`[test-safety] ${context} targeted protected repo .fusion directory: ${candidate}\n` +
|
||||||
|
"Tests must operate inside a temp directory. Use tempWorkspace() or useIsolatedCwd().",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
@@ -8,6 +9,8 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
|
||||||
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
hookTimeout: 30_000,
|
hookTimeout: 30_000,
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
||||||
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||||
@@ -9,6 +10,8 @@ process.env.VITEST_MAX_WORKERS = String(maxWorkers);
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
|
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
|
||||||
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ["./src/__tests__/setup-test-isolation.ts"],
|
setupFiles: [
|
||||||
|
"./src/__tests__/setup-test-isolation.ts",
|
||||||
|
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||||
|
],
|
||||||
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "json-summary"],
|
reporter: ["text", "json-summary"],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
|
||||||
@@ -15,6 +16,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
|
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
|
||||||
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
pool: "threads",
|
pool: "threads",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
|
|||||||
Reference in New Issue
Block a user