Testing
@openclaw/fs-safe/test-hooks exposes a small set of test-only injection points. They are inert in production: the hooks only activate when process.env.NODE_ENV === "test". Outside test mode, calls to set hooks are no-ops, so leaking a test setup line into production is safe but ineffective.
import {
__setFsSafeTestHooksForTest,
type FsSafeTestHooks,
} from "@openclaw/fs-safe/test-hooks";
The double-underscore prefix is a deliberate "hands off" signal: production code should never import this module. ESLint or your equivalent linter should flag it.
#When to reach for hooks
- Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
- Force Node-only behavior without uninstalling Python from your runners.
- Inject latency to test cancellation/timeout paths.
If you don't need to inject a race, you don't need hooks — most tests should drive the library through normal calls and assert on observable behavior.
#Hooks API
type FsSafeTestHooks = {
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
afterOpen?: (filePath: string, handle: import("node:fs/promises").FileHandle) => Promise<void> | void;
};
function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
Hooks are called at well-defined points in the library's hot paths:
afterPreOpenLstat— runs after the pre-openlstat. A common use is to swap the path's target viafs.symlink/fs.unlinkto drive a TOCTOU race.beforeOpen— runs beforefs.openwith the exact flags the root read path will use.afterOpen— runs after the file handle is opened. Useful to wrap handle methods or inject a size race before a stream is consumed.
__setFsSafeTestHooksForTest(undefined) clears all hooks. Always clean up between tests.
#Example: simulate a TOCTOU swap
import { describe, it, beforeEach, afterEach, expect } from "vitest";
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { root, FsSafeError } from "@openclaw/fs-safe";
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "fs-safe-toctou-"));
await writeFile(path.join(dir, "real.txt"), "secret");
await writeFile(path.join(dir, "decoy.txt"), "decoy");
});
afterEach(async () => {
__setFsSafeTestHooksForTest(undefined);
await rm(dir, { recursive: true, force: true });
});
it("rejects a swap between resolve and open", async () => {
const fs = await root(dir, { symlinks: "reject" });
__setFsSafeTestHooksForTest({
afterPreOpenLstat: async (absPath) => {
// swap real.txt for a symlink to decoy.txt right before the open
await unlink(absPath);
await symlink(path.join(dir, "decoy.txt"), absPath);
},
});
await expect(fs.read("real.txt")).rejects.toMatchObject({
name: "FsSafeError",
code: expect.stringMatching(/symlink|path-mismatch/),
});
});
The code may be symlink (caught at open by O_NOFOLLOW) or path-mismatch (caught by the post-open identity check) depending on platform — both are correct refusals.
#Example: force Node-only fallback behavior
import { configureFsSafePython } from "@openclaw/fs-safe/config";
beforeEach(() => {
configureFsSafePython({ mode: "off" });
});
afterEach(() => {
configureFsSafePython({ mode: "auto", pythonPath: undefined });
});
it("runs without the Python helper", async () => {
const fs = await root(dir);
await fs.write("file.txt", "ok");
await expect(fs.readText("file.txt")).resolves.toBe("ok");
});
#Cleanup is mandatory
Hooks set by __setFsSafeTestHooksForTest persist across tests until explicitly cleared. Always clear in afterEach (or your test framework's equivalent) — leaked hooks will silently change behavior in unrelated tests and cause maddening intermittent failures.
import { afterEach } from "vitest";
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
afterEach(() => {
__setFsSafeTestHooksForTest(undefined);
});
A global hook clear in your test setup file is a good safety net.
#Patterns for testing fs-safe-using code
You usually don't need hooks. Most tests follow this shape:
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { root } from "@openclaw/fs-safe";
let dir: string;
let fs: Awaited<ReturnType<typeof root>>;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "my-feature-"));
fs = await root(dir, { symlinks: "reject", hardlinks: "reject", mkdir: true });
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("writes and reads through the boundary", async () => {
await fs.write("notes/today.txt", "hello");
expect(await fs.readText("notes/today.txt")).toBe("hello");
});
For tests that need a private temp workspace, withTempWorkspace makes the setup-and-teardown story trivial.
#Repo test shards
Run the full local gate before handoff:
pnpm check
Run only the security boundary corpus while iterating on root/path/archive/temp hardening:
pnpm test:security
Run the static primitive guard after changing low-level filesystem helpers:
pnpm lint:fs-boundary
It catches the specific raw fallback patterns that previously led to check-then-use bugs, such as direct copy-to-destination fallback and sync temp workspace reads that bypass pinned file descriptors.
pnpm check also runs pnpm lint:file-size. New source and test files should stay under 500 lines. Existing larger files have explicit budgets in scripts/check-file-size.mjs; do not increase those budgets as part of unrelated work.
#See also
- Security model — what the boundary is supposed to defend; design tests around the same threats.
root()— the surface most tests will exercise.- Temp workspaces —
withTempWorkspacefor cleanup-on-exit test directories.