tests / fakeFs.ts
tests / fakeFs.ts
import * as path from "path";
import type { DirEntry, FileStat, SkillFs } from "../src/types";
export interface FakeFsOptions {
/** absolute file path -> content */
files: Record<string, string | Buffer>;
/** absolute path -> mtimeMs (files or directories). Default 1000. */
mtimes?: Record<string, number>;
/** symlink path -> target path. realpath resolves the longest matching prefix. */
symlinks?: Record<string, string>;
}
export interface FakeFs extends SkillFs {
/** Add or replace a file and bump its mtime. */
write(file: string, content: string, mtimeMs?: number): void;
calls: { readdir: number; stat: number; readFile: number };
}
export function fakeFs(opts: FakeFsOptions): FakeFs {
const files = new Map<string, Buffer>();
for (const [p, c] of Object.entries(opts.files)) files.set(p, Buffer.isBuffer(c) ? c : Buffer.from(c));
const mtimes = new Map<string, number>(Object.entries(opts.mtimes ?? {}));
const symlinks = opts.symlinks ?? {};
const calls = { readdir: 0, stat: 0, readFile: 0 };
const dirs = () => {
const set = new Set<string>();
for (const f of files.keys()) {
let d = path.dirname(f);
while (d !== "/" && !set.has(d)) {
set.add(d);
d = path.dirname(d);
}
}
for (const d of mtimes.keys()) if (!files.has(d)) set.add(d);
return set;
};
const enoent = (p: string) => Object.assign(new Error(`ENOENT: no such file or directory, '${p}'`), { code: "ENOENT" });
/** Translates `p` through the longest matching symlink prefix, like the kernel following a link. */
const resolve = (p: string): string => {
const links = Object.keys(symlinks).sort((a, b) => b.length - a.length);
for (const link of links) {
if (p === link || p.startsWith(link + "/")) return symlinks[link] + p.slice(link.length);
}
return p;
};
return {
calls,
write(file, content, mtimeMs) {
files.set(file, Buffer.from(content));
mtimes.set(file, mtimeMs ?? (mtimes.get(file) ?? 1000) + 1);
},
async readdir(dir) {
calls.readdir++;
const real = resolve(dir);
if (!dirs().has(real)) throw enoent(dir);
const out = new Map<string, DirEntry>();
for (const f of files.keys()) {
if (path.dirname(f) === real) out.set(path.basename(f), { name: path.basename(f), isDirectory: false, isFile: true });
}
for (const d of dirs()) {
if (path.dirname(d) === real) out.set(path.basename(d), { name: path.basename(d), isDirectory: true, isFile: false });
}
// A real Dirent for a symlink reports neither isDirectory() nor isFile().
for (const link of Object.keys(symlinks)) {
if (path.dirname(link) === real) out.set(path.basename(link), { name: path.basename(link), isDirectory: false, isFile: false });
}
return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
},
async stat(p): Promise<FileStat> {
calls.stat++;
const real = resolve(p);
const mtimeMs = mtimes.get(real) ?? 1000;
if (files.has(real)) return { mtimeMs, size: files.get(real)!.length, isDirectory: false, isFile: true };
if (dirs().has(real)) return { mtimeMs, size: 0, isDirectory: true, isFile: false };
throw enoent(p);
},
async readFile(p) {
calls.readFile++;
const c = files.get(resolve(p));
if (!c) throw enoent(p);
return c;
},
async realpath(p) {
// Like realpath(3): every component must exist, including those behind a symlink.
const real = resolve(p);
if (!files.has(real) && !dirs().has(real)) throw enoent(p);
return real;
},
};
}
import * as path from "path";
import type { DirEntry, FileStat, SkillFs } from "../src/types";
export interface FakeFsOptions {
/** absolute file path -> content */
files: Record<string, string | Buffer>;
/** absolute path -> mtimeMs (files or directories). Default 1000. */
mtimes?: Record<string, number>;
/** symlink path -> target path. realpath resolves the longest matching prefix. */
symlinks?: Record<string, string>;
}
export interface FakeFs extends SkillFs {
/** Add or replace a file and bump its mtime. */
write(file: string, content: string, mtimeMs?: number): void;
calls: { readdir: number; stat: number; readFile: number };
}
export function fakeFs(opts: FakeFsOptions): FakeFs {
const files = new Map<string, Buffer>();
for (const [p, c] of Object.entries(opts.files)) files.set(p, Buffer.isBuffer(c) ? c : Buffer.from(c));
const mtimes = new Map<string, number>(Object.entries(opts.mtimes ?? {}));
const symlinks = opts.symlinks ?? {};
const calls = { readdir: 0, stat: 0, readFile: 0 };
const dirs = () => {
const set = new Set<string>();
for (const f of files.keys()) {
let d = path.dirname(f);
while (d !== "/" && !set.has(d)) {
set.add(d);
d = path.dirname(d);
}
}
for (const d of mtimes.keys()) if (!files.has(d)) set.add(d);
return set;
};
const enoent = (p: string) => Object.assign(new Error(`ENOENT: no such file or directory, '${p}'`), { code: "ENOENT" });
/** Translates `p` through the longest matching symlink prefix, like the kernel following a link. */
const resolve = (p: string): string => {
const links = Object.keys(symlinks).sort((a, b) => b.length - a.length);
for (const link of links) {
if (p === link || p.startsWith(link + "/")) return symlinks[link] + p.slice(link.length);
}
return p;
};
return {
calls,
write(file, content, mtimeMs) {
files.set(file, Buffer.from(content));
mtimes.set(file, mtimeMs ?? (mtimes.get(file) ?? 1000) + 1);
},
async readdir(dir) {
calls.readdir++;
const real = resolve(dir);
if (!dirs().has(real)) throw enoent(dir);
const out = new Map<string, DirEntry>();
for (const f of files.keys()) {
if (path.dirname(f) === real) out.set(path.basename(f), { name: path.basename(f), isDirectory: false, isFile: true });
}
for (const d of dirs()) {
if (path.dirname(d) === real) out.set(path.basename(d), { name: path.basename(d), isDirectory: true, isFile: false });
}
// A real Dirent for a symlink reports neither isDirectory() nor isFile().
for (const link of Object.keys(symlinks)) {
if (path.dirname(link) === real) out.set(path.basename(link), { name: path.basename(link), isDirectory: false, isFile: false });
}
return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
},
async stat(p): Promise<FileStat> {
calls.stat++;
const real = resolve(p);
const mtimeMs = mtimes.get(real) ?? 1000;
if (files.has(real)) return { mtimeMs, size: files.get(real)!.length, isDirectory: false, isFile: true };
if (dirs().has(real)) return { mtimeMs, size: 0, isDirectory: true, isFile: false };
throw enoent(p);
},
async readFile(p) {
calls.readFile++;
const c = files.get(resolve(p));
if (!c) throw enoent(p);
return c;
},
async realpath(p) {
// Like realpath(3): every component must exist, including those behind a symlink.
const real = resolve(p);
if (!files.has(real) && !dirs().has(real)) throw enoent(p);
return real;
},
};
}