diff --git a/README.md b/README.md index 55bdc9b..bb798ac 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,9 @@ const vfs = create(); vfs.writeFileSync('/app/index.js', 'module.exports = "hello"'); -// Mount the VFS at /app — patches require() and fs so that -// the rest of the process sees virtual files transparently. -vfs.mount('/app'); - -const mod = require('/app/index.js'); // 'hello' +// Mount in an isolated namespace; the returned path is platform-dependent. +const mountPoint = vfs.mount(); +const mod = require(`${mountPoint}/app/index.js`); // 'hello' vfs.unmount(); ``` @@ -48,7 +46,7 @@ Returns a `VirtualFileSystem`. | Property | Type | Description | |---|---|---| | `provider` | `VirtualProvider` | The underlying storage provider | -| `mountPoint` | `string \| null` | Current mount prefix, or `null` | +| `mountPoint` | `string \| null` | Current isolated mount path, or `null` | | `mounted` | `boolean` | Whether the VFS is currently mounted | | `readonly` | `boolean` | Whether the provider is read-only | | `overlay` | `boolean` | Whether overlay mode is enabled | @@ -56,11 +54,12 @@ Returns a `VirtualFileSystem`. #### Mount / Unmount ```js -vfs.mount('/prefix'); // Start intercepting paths under /prefix -vfs.unmount(); // Stop intercepting +const mountPoint = vfs.mount(); // e.g. '/dev/null/vfs/0' (platform-dependent) +vfs.mountPointURL; // file: URL for import() +vfs.unmount(); // Stop intercepting ``` -`mount()` returns the VFS instance for chaining. When mounted with `moduleHooks: true` (the default), `require()`, `import`, and core `fs` functions (`readFileSync`, `statSync`, `existsSync`, `readdirSync`, `realpathSync`, `openSync`, `watch`, etc.) are patched to serve files from the VFS. +`mount()` returns a unique mount point inside `os.devNull/vfs/` (matching Node.js core's namespace). It takes no arguments; passing a prefix throws a `TypeError`. The mount point avoids shadowing real paths and can be addressed through `mountPointURL`. When mounted with `moduleHooks: true` (the default), this package patches selected `require()`, `import`, `fs`, and `fs.promises` entry points; see the limitations below. Emits `vfs-mount` and `vfs-unmount` events on `process`. @@ -72,7 +71,7 @@ Returns `true` if the given path would be handled by this VFS instance. In overl #### Sync API -The full synchronous `fs` API: +The synchronous VFS API includes: ```js vfs.writeFileSync(path, data[, options]) @@ -91,6 +90,10 @@ vfs.accessSync(path[, mode]) vfs.realpathSync(path[, options]) vfs.symlinkSync(target, path[, type]) vfs.readlinkSync(path[, options]) +vfs.rmSync(path[, options]) // { recursive, force } +vfs.truncateSync(path[, len]) +vfs.ftruncateSync(fd[, len]) +vfs.mkdtempSync(prefix) ``` #### File descriptors @@ -104,7 +107,7 @@ vfs.closeSync(fd); #### Callback API -Every sync method has a callback counterpart following the standard Node.js `(err, result)` convention: +Some sync methods have callback counterparts following the standard Node.js `(err, result)` convention: ```js vfs.readFile(path, options, callback) @@ -132,6 +135,9 @@ const target = await vfs.promises.readlink('/link'); await vfs.promises.lstat('/link'); await vfs.promises.realpath('/link'); await vfs.promises.rmdir('/dir'); +await vfs.promises.rm('/tree', { recursive: true }); +await vfs.promises.truncate('/file.txt', 0); +const tmp = await vfs.promises.mkdtemp('/tmp-'); ``` #### Streams @@ -157,10 +163,10 @@ When created with `{ virtualCwd: true }`: ```js const vfs = create({ virtualCwd: true }); vfs.writeFileSync('/app/file.txt', 'data'); -vfs.mount('/app'); +const mountPoint = vfs.mount(); -vfs.chdir('/app'); -vfs.cwd(); // '/app' +vfs.chdir(`${mountPoint}/app`); +vfs.cwd(); // `${mountPoint}/app` ``` When mounted, `process.cwd()` and `process.chdir()` are patched to work with the virtual directory. @@ -252,15 +258,23 @@ Higher-level operations (`readFile`, `writeFile`, `copyFile`, `exists`, `access` When `moduleHooks` is enabled (the default), mounting a VFS instance: 1. **Patches `require()` and `import`** — On Node.js 23.5+ uses `Module.registerHooks()`. On older versions falls back to `Module._resolveFilename` + `Module._extensions` patching. -2. **Patches core `fs` functions** — `readFileSync`, `statSync`, `lstatSync`, `readdirSync`, `existsSync`, `realpathSync`, `watch`, `watchFile`, `unwatchFile`, and the descriptor family `openSync`/`open`, `readSync`/`read`, `closeSync`/`close`, `fstatSync`/`fstat`. +2. **Patches selected `fs` functions** — path-based reads, writes, directory mutation, `rm`, `truncate`, `mkdtemp`, `rename`, `copyFile`, symlinks and watchers, plus the sync/callback descriptor family `open`, `read`, `close`, and `fstat`. Cross-mount rename/copy returns `EXDEV`. -This means third-party code using `require()` or `fs.readFileSync()` will transparently pick up files from the VFS. +This covers calls made through patched `fs` properties, not every way Node.js or an addon can access the filesystem. Module resolution supports package.json `exports`, `main`, and bare specifier resolution walking `node_modules`. -## Node.js core VFS support +## Differences from Node.js core VFS + +This is a **userland compatibility layer**, not a direct extraction of the current `node:vfs` implementation (experimental in Node.js 26). It tracks the mount namespace and some of the `fs` API, but monkey-patching cannot intercept Node's native filesystem binding, built-in loader internals, or references to `fs` methods captured before mounting. In particular: + +- Core's full `fs`/`fs.promises` surface is **not provided**: descriptor writes (`fs.write`), `fs.promises.open`/`FileHandle`, write streams, `opendir`, `openAsBlob`, `link`, permission/ownership/time operations, and startup flags (`--vfs-mount`, `--vfs-load`) remain unsupported by the mounted shim. Direct VFS methods are independent of the patched `fs` surface. +- Native addon and FFI library loading from virtual bytes, SEA integration, and transparent interception of Node's internal module-resolution filesystem calls require Node core and are **not supported**. Core's `ZipProvider` and automatic ZIP loading are also **not included**; this package ships memory, real-filesystem, and SQLite providers. The userland resolver approximates CJS/ESM resolution; package edge cases can differ. +- Core invalidates both CJS and ESM module caches on unmount. This shim evicts its CJS cache entries, but cannot invalidate ESM's internal cache. Re-mounting assigns a new URL, avoiding a stale ESM cache entry. +- Overlay and virtual-cwd options are shim-only and can interact with other monkey patches. Overlay mode routes missing paths to disk, including creates; disable it when isolation matters. +- `fs` path dispatch is synchronous under the hood even for callback and promise methods. Direct provider async methods are separate; this shim does not provide core's libuv-backed concurrency guarantees. Recursive `readdir` is implemented for the memory provider without following directory symlinks; other providers may not implement it. -This package is a direct extraction of the Virtual File System being added to Node.js core ([nodejs/node#61478](https://github.com/nodejs/node/pull/61478)), allowing it to be used on Node.js 22+. Once the core PR lands, this package will no longer be necessary (except for `SqliteProvider`). +Use built-in `node:vfs` when running a Node version that provides it. The shim remains useful for Node 22+ and for `SqliteProvider`. ## License diff --git a/index.d.ts b/index.d.ts index e6ced40..2cf238a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -38,6 +38,7 @@ export interface ReadStreamOptions { export interface StatOptions { bigint?: boolean; + throwIfNoEntry?: boolean; } export class VirtualStats { @@ -107,8 +108,10 @@ export class VFSWatchAsyncIterable implements AsyncIterable { } export class VFSStatWatcher extends EventEmitter { - addListener(listener: (curr: VirtualStats, prev: VirtualStats) => void): void; + addListener(listener: (curr: VirtualStats, prev: VirtualStats) => void): this; + addListener(eventName: string | symbol, listener: (...args: any[]) => void): this; removeListener(listener: (curr: VirtualStats, prev: VirtualStats) => void): boolean; + removeListener(eventName: string | symbol, listener: (...args: any[]) => void): this; hasNoListeners(): boolean; stop(): void; unref(): this; @@ -128,6 +131,9 @@ export interface VFSPromisesAPI { readdir(dirPath: string, options?: ReaddirOptions): Promise; mkdir(dirPath: string, options?: MkdirOptions): Promise; rmdir(dirPath: string): Promise; + rm(filePath: string, options?: { recursive?: boolean; force?: boolean }): Promise; + truncate(filePath: string, len?: number): Promise; + mkdtemp(prefix: string): Promise; unlink(filePath: string): Promise; rename(oldPath: string, newPath: string): Promise; copyFile(src: string, dest: string, mode?: number): Promise; @@ -148,6 +154,7 @@ export class VirtualFileSystem { get provider(): VirtualProvider; get mountPoint(): string | null; + get mountPointURL(): string | null; get mounted(): boolean; get readonly(): boolean; get overlay(): boolean; @@ -159,7 +166,7 @@ export class VirtualFileSystem { resolvePath(inputPath: string): string; // Mount - mount(prefix: string): this; + mount(): string; unmount(): void; [Symbol.dispose](): void; @@ -186,6 +193,10 @@ export class VirtualFileSystem { readlinkSync(linkPath: string, options?: { encoding?: BufferEncoding }): string; symlinkSync(target: string, path: string, type?: string): void; accessSync(filePath: string, mode?: number): void; + rmSync(filePath: string, options?: { recursive?: boolean; force?: boolean }): void; + truncateSync(filePath: string, len?: number): void; + ftruncateSync(fd: number, len?: number): void; + mkdtempSync(prefix: string): string; // File descriptor operations openSync(filePath: string, flags?: string, mode?: number): number; @@ -219,6 +230,11 @@ export class VirtualFileSystem { read(fd: number, buffer: Buffer, offset: number, length: number, position: number | null, callback: (err: NodeJS.ErrnoException | null, bytesRead: number, buffer: Buffer) => void): void; fstat(fd: number, callback: Callback): void; fstat(fd: number, options: StatOptions, callback: Callback): void; + rm(filePath: string, callback: Callback): void; + rm(filePath: string, options: { recursive?: boolean; force?: boolean }, callback: Callback): void; + truncate(filePath: string, callback: Callback): void; + truncate(filePath: string, len: number, callback: Callback): void; + mkdtemp(prefix: string, callback: Callback): void; // Stream operations createReadStream(filePath: string, options?: ReadStreamOptions): VirtualReadStream; diff --git a/lib/file_system.js b/lib/file_system.js index bae77c8..443415d 100644 --- a/lib/file_system.js +++ b/lib/file_system.js @@ -1,6 +1,9 @@ 'use strict'; const path = require('node:path'); +const { devNull } = require('node:os'); +const { pathToFileURL } = require('node:url'); +const { randomBytes } = require('node:crypto'); const pathPosix = path.posix; const { isAbsolute, resolve: resolvePath } = path; @@ -11,6 +14,7 @@ const { } = require('./router.js'); const { openVirtualFd, + getVirtualFd, closeFdSync, readFdSync, fstatFdSync, @@ -21,6 +25,8 @@ const { const { createENOENT, createENOTDIR, + createEISDIR, + createEBADF, ERR_INVALID_STATE, } = require('./errors.js'); const { VirtualReadStream } = require('./streams.js'); @@ -54,6 +60,7 @@ const kOriginalCwd = Symbol('kOriginalCwd'); let registerVFS; let deregisterVFS; +let nextLayerId = 0; function loadModuleHooks() { if (!registerVFS) { @@ -109,6 +116,10 @@ class VirtualFileSystem { return this[kMounted]; } + get mountPointURL() { + return this[kMountPoint] === null ? null : pathToFileURL(this[kMountPoint]).href; + } + get readonly() { return this[kProvider].readonly; } @@ -160,11 +171,14 @@ class VirtualFileSystem { // ==================== Mount ==================== - mount(prefix) { + mount() { if (this[kMounted]) { throw new ERR_INVALID_STATE('VFS is already mounted'); } - this[kMountPoint] = normalizeVFSPath(prefix); + if (arguments.length !== 0) { + throw new TypeError('mount() does not accept a prefix'); + } + this[kMountPoint] = path.join(devNull, 'vfs', String(nextLayerId++)); this[kMounted] = true; if (this[kModuleHooks]) { loadModuleHooks(); @@ -180,7 +194,7 @@ class VirtualFileSystem { readonly: this[kProvider].readonly, }); - return this; + return this[kMountPoint]; } unmount() { @@ -197,6 +211,15 @@ class VirtualFileSystem { loadModuleHooks(); deregisterVFS(this); } + // CJS's global cache survives unmounts unless entries are evicted. + // ESM's cache cannot be invalidated from userland. + if (this[kMountPoint] !== null) { + for (const filename of Object.keys(require.cache)) { + if (isUnderMountPoint(filename, this[kMountPoint])) { + delete require.cache[filename]; + } + } + } this[kMountPoint] = null; this[kMounted] = false; this[kVirtualCwd] = null; @@ -269,7 +292,8 @@ class VirtualFileSystem { #toMountedPath(providerPath) { if (this[kMounted] && this[kMountPoint]) { - return joinVFSPath(this[kMountPoint], providerPath); + return providerPath === '/' ? this[kMountPoint] : + joinVFSPath(this[kMountPoint], providerPath); } return providerPath; } @@ -332,9 +356,22 @@ class VirtualFileSystem { this[kProvider].appendFileSync(providerPath, data, options); } + #mapDirents(entries, options, dirPath, providerPath) { + if (options?.withFileTypes) { + const sourceRoot = this[kProvider].rootPath ? + path.join(this[kProvider].rootPath, providerPath) : providerPath; + for (const entry of entries) { + const subdir = options.recursive ? path.relative(sourceRoot, entry.parentPath) : ''; + entry.parentPath = subdir ? joinVFSPath(dirPath, subdir) : dirPath; + } + } + return entries; + } + readdirSync(dirPath, options) { const providerPath = this.#toProviderPath(dirPath); - return this[kProvider].readdirSync(providerPath, options); + const entries = this[kProvider].readdirSync(providerPath, options); + return this.#mapDirents(entries, options, dirPath, providerPath); } mkdirSync(dirPath, options) { @@ -398,6 +435,51 @@ class VirtualFileSystem { } } + rmSync(filePath, options) { + const recursive = options?.recursive === true; + let stats; + try { + stats = this.lstatSync(filePath); + } catch (err) { + if (options?.force === true && err.code === 'ENOENT') return; + throw err; + } + if (stats.isDirectory()) { + if (!recursive) throw createEISDIR('rm', filePath); + for (const name of this.readdirSync(filePath)) { + this.rmSync(joinVFSPath(filePath, name), options); + } + this.rmdirSync(filePath); + } else { + this.unlinkSync(filePath); + } + } + + truncateSync(filePath, len = 0) { + const fd = this.openSync(filePath, 'r+'); + try { + this.ftruncateSync(fd, len); + } finally { + this.closeSync(fd); + } + } + + ftruncateSync(fd, len = 0) { + const vfd = getVirtualFd(fd); + if (!vfd) throw createEBADF('ftruncate'); + vfd.entry.truncateSync(len); + } + + mkdtempSync(prefix) { + // A trailing separator is part of the prefix, not a path component. + const trailing = prefix.endsWith(path.sep) || prefix.endsWith('/'); + const providerPrefix = this.#toProviderPath(prefix); + const dir = (trailing && providerPrefix !== '/' ? providerPrefix + '/' : providerPrefix) + + randomBytes(6).toString('hex').slice(0, 6); + this[kProvider].mkdirSync(dir); + return this.#toMountedPath(dir); + } + // ==================== File Descriptor Operations ==================== openSync(filePath, flags = 'r', mode) { @@ -466,8 +548,10 @@ class VirtualFileSystem { options = undefined; } - this[kProvider].readdir(this.#toProviderPath(dirPath), options) - .then((entries) => callback(null, entries), (err) => callback(err)); + const providerPath = this.#toProviderPath(dirPath); + this[kProvider].readdir(providerPath, options) + .then((entries) => callback(null, this.#mapDirents(entries, options, dirPath, providerPath)), + (err) => callback(err)); } realpath(filePath, options, callback) { @@ -535,6 +619,37 @@ class VirtualFileSystem { fstatFd(fd, options, callback); } + rm(filePath, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + process.nextTick(() => { + try { this.rmSync(filePath, options); } catch (err) { callback(err); return; } + callback(null); + }); + } + + truncate(filePath, len, callback) { + if (typeof len === 'function') { + callback = len; + len = 0; + } + process.nextTick(() => { + try { this.truncateSync(filePath, len); } catch (err) { callback(err); return; } + callback(null); + }); + } + + mkdtemp(prefix, options, callback) { + if (typeof options === 'function') callback = options; + process.nextTick(() => { + let result; + try { result = this.mkdtempSync(prefix); } catch (err) { callback(err); return; } + callback(null, result); + }); + } + // ==================== Stream Operations ==================== createReadStream(filePath, options) { @@ -550,6 +665,7 @@ class VirtualFileSystem { } const providerPath = this.#toProviderPath(filePath); + this[kProvider].statSync(providerPath); const watcher = this[kProvider].watch(providerPath, options); if (listener) { @@ -585,6 +701,7 @@ class VirtualFileSystem { #createPromisesAPI() { const provider = this[kProvider]; + const thisVfs = this; const toProviderPath = (p) => this.#toProviderPath(p); const toMountedPath = (p) => this.#toMountedPath(p); @@ -617,7 +734,8 @@ class VirtualFileSystem { async readdir(dirPath, options) { const providerPath = toProviderPath(dirPath); - return provider.readdir(providerPath, options); + const entries = await provider.readdir(providerPath, options); + return thisVfs.#mapDirents(entries, options, dirPath, providerPath); }, async mkdir(dirPath, options) { @@ -629,6 +747,18 @@ class VirtualFileSystem { return undefined; }, + async rm(filePath, options) { + return thisVfs.rmSync(filePath, options); + }, + + async truncate(filePath, len) { + return thisVfs.truncateSync(filePath, len); + }, + + async mkdtemp(prefix) { + return thisVfs.mkdtempSync(prefix); + }, + async rmdir(dirPath) { const providerPath = toProviderPath(dirPath); return provider.rmdir(providerPath); @@ -674,6 +804,7 @@ class VirtualFileSystem { watch(filePath, options) { const providerPath = toProviderPath(filePath); + provider.statSync(providerPath); return provider.watchAsync(providerPath, options); }, }); diff --git a/lib/fs_patches.js b/lib/fs_patches.js new file mode 100644 index 0000000..311a6fd --- /dev/null +++ b/lib/fs_patches.js @@ -0,0 +1,97 @@ +'use strict'; + +const { fileURLToPath } = require('node:url'); + +function pathString(value) { + if (value instanceof URL && value.protocol === 'file:') return fileURLToPath(value); + if (Buffer.isBuffer(value)) return value.toString(); + return typeof value === 'string' ? value : null; +} + +function crossDeviceError(syscall, from, to) { + const err = new Error(`EXDEV: cross-device link not permitted, ${syscall} '${from}' -> '${to}'`); + err.code = 'EXDEV'; + err.errno = -18; + err.syscall = syscall; + err.path = from; + err.dest = to; + return err; +} + +// Only operations whose path arguments can be dispatched without a native +// libuv fd are intercepted here. Keep the originals for real paths. +function installAdditionalFsPatches(fs, findVFS) { + function patch(name, pathIndex = 0, otherIndex = -1) { + const syncName = `${name}Sync`; + const originalSync = fs[syncName]; + const originalCallback = fs[name]; + const originalPromise = fs.promises[name]; + if (typeof originalSync !== 'function') return; + + function select(args) { + const first = pathString(args[pathIndex]); + if (first === null) return null; + const vfs = findVFS(first); + if (otherIndex !== -1) { + const second = pathString(args[otherIndex]); + const other = second === null ? null : findVFS(second); + if (vfs !== other && (vfs || other)) { + throw crossDeviceError(name, args[pathIndex], args[otherIndex]); + } + } + return vfs; + } + + function dispatch(vfs, args) { + const converted = args.slice(); + converted[pathIndex] = pathString(args[pathIndex]); + if (otherIndex !== -1) converted[otherIndex] = pathString(args[otherIndex]); + return vfs[syncName](...converted); + } + + fs[syncName] = function(...args) { + const vfs = select(args); + return vfs ? dispatch(vfs, args) : originalSync.apply(fs, args); + }; + + if (typeof originalCallback === 'function') { + fs[name] = function(...args) { + const callback = args[args.length - 1]; + // Let Node validate malformed callback calls itself. + if (typeof callback !== 'function') return originalCallback.apply(fs, args); + let vfs; + try { + vfs = select(args); + } catch (err) { + process.nextTick(callback, err); + return; + } + if (!vfs) return originalCallback.apply(fs, args); + process.nextTick(() => { + let result; + try { + result = dispatch(vfs, args.slice(0, -1)); + } catch (err) { + callback(err); + return; + } + callback(null, result); + }); + }; + } + + if (typeof originalPromise === 'function') { + fs.promises[name] = async function(...args) { + const vfs = select(args); + return vfs ? dispatch(vfs, args) : originalPromise.apply(fs.promises, args); + }; + } + } + + for (const name of ['writeFile', 'appendFile', 'mkdir', 'rmdir', 'unlink', + 'rm', 'truncate', 'mkdtemp']) patch(name); + for (const name of ['rename', 'copyFile']) patch(name, 0, 1); + patch('symlink', 1); +} + +module.exports = { installAdditionalFsPatches, pathString }; diff --git a/lib/module_hooks.js b/lib/module_hooks.js index e870af7..f78074d 100644 --- a/lib/module_hooks.js +++ b/lib/module_hooks.js @@ -14,6 +14,7 @@ const { readFd, fstatFd, } = require('./fd.js'); +const { installAdditionalFsPatches, pathString } = require('./fs_patches.js'); const kEmptyObject = Object.freeze(Object.create(null)); const NodeModule = require('node:module'); @@ -47,6 +48,12 @@ function normalizeVFSPath(inputPath) { return path.normalize(inputPath); } +function linkTarget(target, options) { + if (Buffer.isBuffer(target)) return target; + return (typeof options === 'object' && options?.encoding === 'buffer') || + options === 'buffer' ? Buffer.from(target) : target; +} + function joinVFSParts(...parts) { if (parts[0]?.startsWith('/')) { return pathPosix.resolve(...parts); @@ -195,26 +202,13 @@ function findVFSForRealpath(filename) { return null; } -function findVFSForFsStat(filename) { - const normalized = normalizeVFSPath(filename); - for (let i = 0; i < activeVFSList.length; i++) { - const vfs = activeVFSList[i]; - if (vfs.shouldHandle(normalized)) { - if (vfs.existsSync(normalized)) { - try { - const stats = vfs.statSync(normalized); - return { vfs, stats }; - } catch (e) { - if (vfs.mounted) { - throw e; - } - } - } else if (vfs.mounted) { - throw createENOENT('stat', filename); - } - } +function statFromVFS(vfs, filename, options, lstat = false) { + try { + return lstat ? vfs.lstatSync(filename, options) : vfs.statSync(filename, options); + } catch (err) { + if (options?.throwIfNoEntry === false && err.code === 'ENOENT') return undefined; + throw err; } - return null; } function findVFSForReaddir(dirname, options) { @@ -1146,23 +1140,15 @@ function installFsPatches() { } fs.lstatSync = function lstatSync(path, options) { - if (typeof path === 'string') { - const vfsResult = findVFSForFsStat(path); - if (vfsResult !== null) { - return vfsResult.stats; - } - } - return originalLstatSync.call(fs, path, options); + const name = pathString(path); + const vfs = name === null ? null : findVFSForPath(normalizeVFSPath(name)); + return vfs ? statFromVFS(vfs, name, options, true) : originalLstatSync.call(fs, path, options); }; fs.statSync = function statSync(path, options) { - if (typeof path === 'string') { - const vfsResult = findVFSForFsStat(path); - if (vfsResult !== null) { - return vfsResult.stats; - } - } - return originalStatSync.call(fs, path, options); + const name = pathString(path); + const vfs = name === null ? null : findVFSForPath(normalizeVFSPath(name)); + return vfs ? statFromVFS(vfs, name, options) : originalStatSync.call(fs, path, options); }; originalReaddirSync = fs.readdirSync; @@ -1187,15 +1173,12 @@ function installFsPatches() { return originalExistsSync.call(fs, path); }; - // Patch fs.readlinkSync for VFS + // readlink must not follow the link (including dangling links). const originalReadlinkSync = fs.readlinkSync; fs.readlinkSync = function readlinkSync(path, options) { - if (typeof path === 'string') { - const vfsResult = findVFSForRealpath(path); - if (vfsResult !== null) { - return vfsResult.realpath; - } - } + const name = pathString(path); + const vfs = name === null ? null : findVFSForPath(normalizeVFSPath(name)); + if (vfs) return linkTarget(vfs.readlinkSync(name, options), options); return originalReadlinkSync.call(fs, path, options); }; @@ -1242,9 +1225,13 @@ function installFsPatches() { } if (typeof path === 'string') { try { - const vfsResult = findVFSForFsStat(path); - if (vfsResult !== null) { - if (callback) process.nextTick(callback, null, vfsResult.stats); + const vfs = findVFSForPath(normalizeVFSPath(path)); + if (vfs) { + process.nextTick(() => { + let stats; + try { stats = statFromVFS(vfs, path, options); } catch (err) { callback(err); return; } + callback(null, stats); + }); return; } } catch (err) { @@ -1263,9 +1250,13 @@ function installFsPatches() { } if (typeof path === 'string') { try { - const vfsResult = findVFSForFsStat(path); - if (vfsResult !== null) { - if (callback) process.nextTick(callback, null, vfsResult.stats); + const vfs = findVFSForPath(normalizeVFSPath(path)); + if (vfs) { + process.nextTick(() => { + let stats; + try { stats = statFromVFS(vfs, path, options, true); } catch (err) { callback(err); return; } + callback(null, stats); + }); return; } } catch (err) { @@ -1348,9 +1339,18 @@ function installFsPatches() { } if (typeof path === 'string') { try { - const vfsResult = findVFSForRealpath(path); - if (vfsResult !== null) { - if (callback) process.nextTick(callback, null, vfsResult.realpath); + const vfs = findVFSForPath(normalizeVFSPath(path)); + if (vfs) { + process.nextTick(() => { + let target; + try { + target = linkTarget(vfs.readlinkSync(path, options), options); + } catch (err) { + callback(err); + return; + } + callback(null, target); + }); return; } } catch (err) { @@ -1418,10 +1418,8 @@ function installFsPatches() { const origPStat = fs.promises.stat; fs.promises.stat = async function stat(path, options) { if (typeof path === 'string') { - const vfsResult = findVFSForFsStat(path); - if (vfsResult !== null) { - return vfsResult.stats; - } + const vfs = findVFSForPath(normalizeVFSPath(path)); + if (vfs) return statFromVFS(vfs, path, options); } return origPStat.call(fs.promises, path, options); }; @@ -1429,10 +1427,8 @@ function installFsPatches() { const origPLstat = fs.promises.lstat; fs.promises.lstat = async function lstat(path, options) { if (typeof path === 'string') { - const vfsResult = findVFSForFsStat(path); - if (vfsResult !== null) { - return vfsResult.stats; - } + const vfs = findVFSForPath(normalizeVFSPath(path)); + if (vfs) return statFromVFS(vfs, path, options, true); } return origPLstat.call(fs.promises, path, options); }; @@ -1451,10 +1447,8 @@ function installFsPatches() { const origPReadlink = fs.promises.readlink; fs.promises.readlink = async function readlink(path, options) { if (typeof path === 'string') { - const vfsResult = findVFSForRealpath(path); - if (vfsResult !== null) { - return vfsResult.realpath; - } + const vfs = findVFSForPath(normalizeVFSPath(path)); + if (vfs) return linkTarget(vfs.readlinkSync(path, options), options); } return origPReadlink.call(fs.promises, path, options); }; @@ -1486,6 +1480,16 @@ function installFsPatches() { return originalWatch.call(fs, filename, options, listener); }; + const originalPromisesWatch = fs.promises.watch; + if (typeof originalPromisesWatch === 'function') { + fs.promises.watch = function watch(filename, options) { + const name = pathString(filename); + const vfs = name === null ? null : findVFSForPath(normalizeVFSPath(name)); + if (vfs) return vfs.promises.watch(name, options); + return originalPromisesWatch.call(fs.promises, filename, options); + }; + } + originalWatchFile = fs.watchFile; fs.watchFile = function watchFile(filename, options, listener) { if (typeof options === 'function') { @@ -1502,6 +1506,8 @@ function installFsPatches() { return originalWatchFile.call(fs, filename, options, listener); }; + installAdditionalFsPatches(fs, (name) => findVFSForPath(normalizeVFSPath(name))); + originalUnwatchFile = fs.unwatchFile; fs.unwatchFile = function unwatchFile(filename, listener) { if (typeof filename === 'string') { diff --git a/lib/providers/memory.js b/lib/providers/memory.js index 7ce5241..ab9a6eb 100644 --- a/lib/providers/memory.js +++ b/lib/providers/memory.js @@ -52,6 +52,10 @@ class VirtualDirent { return this.#parentPath; } + set parentPath(value) { + this.#parentPath = value; + } + get path() { return this.#parentPath; } @@ -442,27 +446,29 @@ class MemoryProvider extends VirtualProvider { this.#ensurePopulated(entry, path); - const names = [...entry.children.keys()]; - - if (options?.withFileTypes) { - const normalized = this.#normalizePath(path); - const dirents = []; - for (const name of names) { - const childEntry = entry.children.get(name); - let type; - if (childEntry.isSymbolicLink()) { - type = UV_DIRENT_LINK; - } else if (childEntry.isDirectory()) { - type = UV_DIRENT_DIR; + const normalized = this.#normalizePath(path); + const results = []; + const visit = (directory, parentPath, prefix) => { + for (const [name, child] of directory.children) { + const relative = prefix + name; + if (options?.withFileTypes) { + const type = child.isSymbolicLink() ? UV_DIRENT_LINK : + child.isDirectory() ? UV_DIRENT_DIR : UV_DIRENT_FILE; + results.push(new VirtualDirent(name, type, parentPath)); } else { - type = UV_DIRENT_FILE; + results.push(relative); + } + // Do not follow symlinks during recursive enumeration: this also + // avoids cycles through a link to an ancestor. + if (options?.recursive && child.isDirectory()) { + const nextPath = pathPosix.join(parentPath, name); + this.#ensurePopulated(child, nextPath); + visit(child, nextPath, relative + '/'); } - dirents.push(new VirtualDirent(name, type, normalized)); } - return dirents; - } - - return names; + }; + visit(entry, normalized, ''); + return results; } async readdir(path, options) { @@ -488,27 +494,30 @@ class MemoryProvider extends VirtualProvider { if (recursive) { const segments = this.#splitPath(normalized); let current = this[kRoot]; + let currentPath = ''; + let firstCreated; for (const segment of segments) { + currentPath += '/' + segment; let entry = current.children.get(segment); if (!entry) { entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); entry.children = new Map(); current.children.set(segment, entry); + firstCreated ??= currentPath; } else if (!entry.isDirectory()) { throw createENOTDIR('mkdir', path); } current = entry; } - } else { - const parent = this.#ensureParent(normalized, false, 'mkdir'); - const name = this.#getBaseName(normalized); - const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); - entry.children = new Map(); - parent.children.set(name, entry); + return firstCreated; } - - return recursive ? normalized : undefined; + const parent = this.#ensureParent(normalized, false, 'mkdir'); + const name = this.#getBaseName(normalized); + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new Map(); + parent.children.set(name, entry); + return undefined; } async mkdir(path, options) { diff --git a/lib/providers/sqlite.js b/lib/providers/sqlite.js index 75cb62e..8247716 100644 --- a/lib/providers/sqlite.js +++ b/lib/providers/sqlite.js @@ -49,6 +49,10 @@ class VirtualDirent { return this.#parentPath; } + set parentPath(value) { + this.#parentPath = value; + } + get path() { return this.#parentPath; } diff --git a/lib/watcher.js b/lib/watcher.js index 1aef383..c8a8f66 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -243,15 +243,18 @@ class VFSStatWatcher extends EventEmitter { return false; } - addListener(listener) { - this.#listeners.add(listener); - this.on('change', listener); + addListener(eventOrListener, listener) { + if (listener) return super.addListener(eventOrListener, listener); + this.#listeners.add(eventOrListener); + this.on('change', eventOrListener); + return this; } - removeListener(listener) { - const had = this.#listeners.has(listener); - this.#listeners.delete(listener); - super.removeListener('change', listener); + removeListener(eventOrListener, listener) { + if (listener) return super.removeListener(eventOrListener, listener); + const had = this.#listeners.has(eventOrListener); + this.#listeners.delete(eventOrListener); + super.removeListener('change', eventOrListener); return had; } diff --git a/test/core-parity.test.js b/test/core-parity.test.js new file mode 100644 index 0000000..9bfcbff --- /dev/null +++ b/test/core-parity.test.js @@ -0,0 +1,148 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { pathToFileURL } = require('node:url'); +const { create, RealFSProvider } = require('../index.js'); + +describe('userland core VFS equivalents', () => { + it('mount() allocates unique reserved paths and exposes their URL', () => { + const first = create(); + const second = create(); + const mount = first.mount(); + try { + assert.equal(mount, first.mountPoint); + assert.ok(mount.startsWith(path.join(os.devNull, 'vfs') + path.sep)); + assert.equal(first.mountPointURL, pathToFileURL(mount).href); + assert.notEqual(second.mount(), mount); + assert.equal(second.mountPointURL, pathToFileURL(second.mountPoint).href); + } finally { + first.unmount(); + second.unmount(); + } + assert.equal(first.mountPointURL, null); + }); + + it('dispatches mutations through sync, callback and promise fs APIs', async () => { + const vfs = create(); + const mount = vfs.mount(); + const file = path.join(mount, 'dir', 'item.txt'); + try { + assert.equal(fs.mkdirSync(path.join(mount, 'dir'), { recursive: true }), path.join(mount, 'dir')); + fs.writeFileSync(pathToFileURL(file), 'abcdef'); + assert.equal(fs.readFileSync(file, 'utf8'), 'abcdef'); + await new Promise((resolve, reject) => fs.appendFile(file, '!', (err) => { + if (err) reject(err); + else resolve(); + })); + assert.equal(await fsp.readFile(file, 'utf8'), 'abcdef!'); + await fsp.truncate(file, 3); + assert.equal(vfs.readFileSync(file, 'utf8'), 'abc'); + await fsp.copyFile(file, path.join(mount, 'copy.txt')); + await fsp.rename(path.join(mount, 'copy.txt'), path.join(mount, 'moved.txt')); + assert.equal(fs.readFileSync(path.join(mount, 'moved.txt'), 'utf8'), 'abc'); + assert.throws(() => fs.copyFileSync(file, path.join(os.tmpdir(), 'not-a-vfs-file')), { code: 'EXDEV' }); + await fsp.rm(path.join(mount, 'dir'), { recursive: true }); + assert.equal(fs.existsSync(file), false); + const temp = fs.mkdtempSync(path.join(mount, 'tmp-')); + assert.ok(temp.startsWith(path.join(mount, 'tmp-'))); + fs.rmdirSync(temp); + } finally { + vfs.unmount(); + } + }); + + it('does not follow symlinks for lstat or readlink, including dangling targets', async () => { + const vfs = create(); + vfs.symlinkSync('/missing', '/link'); + const mount = vfs.mount(); + const link = path.join(mount, 'link'); + try { + assert.ok(fs.lstatSync(link).isSymbolicLink()); + assert.ok((await fsp.lstat(link)).isSymbolicLink()); + assert.equal(fs.statSync(path.join(mount, 'missing'), { throwIfNoEntry: false }), undefined); + assert.equal(await fsp.stat(path.join(mount, 'missing'), { throwIfNoEntry: false }), undefined); + assert.equal(fs.readlinkSync(link), '/missing'); + assert.equal(await fsp.readlink(link), '/missing'); + const callbackTarget = await new Promise((resolve, reject) => fs.readlink(link, (err, value) => { + if (err) reject(err); + else resolve(value); + })); + assert.equal(callbackTarget, '/missing'); + assert.throws(() => fs.statSync(link), { code: 'ENOENT' }); + fs.unlinkSync(link); + assert.equal(vfs.existsSync(link), false); + } finally { + vfs.unmount(); + } + }); + + it('enumerates nested memory directories and returns the first mkdir-created path', async () => { + const vfs = create(); + const mount = vfs.mount(); + try { + assert.equal(fs.mkdirSync(path.join(mount, 'a', 'b'), { recursive: true }), path.join(mount, 'a')); + fs.writeFileSync(path.join(mount, 'a', 'b', 'file'), 'data'); + assert.deepEqual(fs.readdirSync(mount, { recursive: true }), ['a', 'a/b', 'a/b/file']); + const entries = await fsp.readdir(mount, { recursive: true, withFileTypes: true }); + assert.deepEqual(entries.map((entry) => [entry.name, entry.parentPath]), [ + ['a', mount], ['b', path.join(mount, 'a')], ['file', path.join(mount, 'a', 'b')], + ]); + } finally { + vfs.unmount(); + } + }); + + it('maps real-provider dirent parent paths back under the mount', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vfs-provider-')); + fs.mkdirSync(path.join(root, 'sub')); + fs.writeFileSync(path.join(root, 'sub', 'item'), 'data'); + const vfs = create(new RealFSProvider(root)); + const mount = vfs.mount(); + try { + const entries = fs.readdirSync(mount, { recursive: true, withFileTypes: true }); + assert.deepEqual(entries.map((entry) => [entry.name, entry.parentPath]), [ + ['sub', mount], ['item', path.join(mount, 'sub')], + ]); + } finally { + vfs.unmount(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('routes fs.promises.watch and rejects missing watch targets', async () => { + const vfs = create(); + vfs.writeFileSync('/watched', 'a'); + const mount = vfs.mount(); + const file = path.join(mount, 'watched'); + try { + assert.throws(() => fs.watch(path.join(mount, 'missing')), { code: 'ENOENT' }); + const events = fsp.watch(file); + const next = events.next(); + fs.writeFileSync(file, 'longer'); + const result = await next; + assert.equal(result.value.filename, 'watched'); + await events.return(); + } finally { + vfs.unmount(); + } + }); + + it('removes only its own cached CJS modules when unmounted', () => { + const vfs = create(); + vfs.writeFileSync('/module.cjs', 'module.exports = 42'); + const mount = vfs.mount(); + const filename = path.join(mount, 'module.cjs'); + try { + assert.equal(require(filename), 42); + assert.ok(require.cache[filename]); + } finally { + vfs.unmount(); + } + assert.equal(require.cache[filename], undefined); + }); +}); diff --git a/test/fs-hooks.test.js b/test/fs-hooks.test.js index d7bbd72..05d84c1 100644 --- a/test/fs-hooks.test.js +++ b/test/fs-hooks.test.js @@ -4,6 +4,7 @@ const { describe, it, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); +const path = require('node:path'); const { create } = require('../index.js'); // These tests verify that the module hooks patch real fs/fs.promises methods @@ -22,36 +23,36 @@ describe('Module hooks — fs sync patches', () => { it('fs.readFileSync reads from VFS', () => { vfs = create(); vfs.writeFileSync('/data.txt', 'hello from vfs'); - vfs.mount('/vfs-test-sync-read'); + vfs.mount(); - const content = fs.readFileSync('/vfs-test-sync-read/data.txt', 'utf8'); + const content = fs.readFileSync(path.join(vfs.mountPoint, 'data.txt'), 'utf8'); assert.strictEqual(content, 'hello from vfs'); }); it('fs.existsSync returns true for VFS files', () => { vfs = create(); vfs.writeFileSync('/exists.txt', 'yes'); - vfs.mount('/vfs-test-sync-exists'); + vfs.mount(); - assert.strictEqual(fs.existsSync('/vfs-test-sync-exists/exists.txt'), true); - assert.strictEqual(fs.existsSync('/vfs-test-sync-exists/nope.txt'), false); + assert.strictEqual(fs.existsSync(path.join(vfs.mountPoint, 'exists.txt')), true); + assert.strictEqual(fs.existsSync(path.join(vfs.mountPoint, 'nope.txt')), false); }); it('fs.statSync returns stats for VFS files', () => { vfs = create(); vfs.writeFileSync('/stat.txt', 'data'); - vfs.mount('/vfs-test-sync-stat'); + vfs.mount(); - const stats = fs.statSync('/vfs-test-sync-stat/stat.txt'); + const stats = fs.statSync(path.join(vfs.mountPoint, 'stat.txt')); assert.ok(stats.isFile()); }); it('fs.lstatSync returns stats for VFS files', () => { vfs = create(); vfs.writeFileSync('/lstat.txt', 'data'); - vfs.mount('/vfs-test-sync-lstat'); + vfs.mount(); - const stats = fs.lstatSync('/vfs-test-sync-lstat/lstat.txt'); + const stats = fs.lstatSync(path.join(vfs.mountPoint, 'lstat.txt')); assert.ok(stats.isFile()); }); @@ -59,34 +60,34 @@ describe('Module hooks — fs sync patches', () => { vfs = create(); vfs.writeFileSync('/dir/a.txt', 'a'); vfs.writeFileSync('/dir/b.txt', 'b'); - vfs.mount('/vfs-test-sync-readdir'); + vfs.mount(); - const entries = fs.readdirSync('/vfs-test-sync-readdir/dir'); + const entries = fs.readdirSync(path.join(vfs.mountPoint, 'dir')); assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt']); }); it('fs.realpathSync resolves VFS paths', () => { vfs = create(); vfs.writeFileSync('/real.txt', 'data'); - vfs.mount('/vfs-test-sync-realpath'); + vfs.mount(); - const resolved = fs.realpathSync('/vfs-test-sync-realpath/real.txt'); - assert.strictEqual(resolved, '/vfs-test-sync-realpath/real.txt'); + const resolved = fs.realpathSync(path.join(vfs.mountPoint, 'real.txt')); + assert.strictEqual(resolved, path.join(vfs.mountPoint, 'real.txt')); }); it('fs.accessSync does not throw for existing VFS files', () => { vfs = create(); vfs.writeFileSync('/access.txt', 'data'); - vfs.mount('/vfs-test-sync-access'); + vfs.mount(); - assert.doesNotThrow(() => fs.accessSync('/vfs-test-sync-access/access.txt')); + assert.doesNotThrow(() => fs.accessSync(path.join(vfs.mountPoint, 'access.txt'))); }); it('fs.accessSync throws ENOENT for missing VFS files', () => { vfs = create(); - vfs.mount('/vfs-test-sync-access-miss'); + vfs.mount(); - assert.throws(() => fs.accessSync('/vfs-test-sync-access-miss/nope.txt'), { + assert.throws(() => fs.accessSync(path.join(vfs.mountPoint, 'nope.txt')), { code: 'ENOENT', }); }); @@ -95,10 +96,10 @@ describe('Module hooks — fs sync patches', () => { vfs = create(); vfs.writeFileSync('/link-target.txt', 'data'); vfs.symlinkSync('/link-target.txt', '/my-link.txt'); - vfs.mount('/vfs-test-sync-readlink'); + vfs.mount(); - const target = fs.readlinkSync('/vfs-test-sync-readlink/my-link.txt'); - assert.strictEqual(target, '/vfs-test-sync-readlink/link-target.txt'); + const target = fs.readlinkSync(path.join(vfs.mountPoint, 'my-link.txt')); + assert.strictEqual(target, '/link-target.txt'); }); }); @@ -114,9 +115,9 @@ describe('Module hooks — fs.access callback', () => { it('fs.access calls back without error for existing VFS files', (_, done) => { vfs = create(); vfs.writeFileSync('/cb.txt', 'data'); - vfs.mount('/vfs-test-cb-access'); + vfs.mount(); - fs.access('/vfs-test-cb-access/cb.txt', (err) => { + fs.access(path.join(vfs.mountPoint, 'cb.txt'), (err) => { assert.ifError(err); done(); }); @@ -124,9 +125,9 @@ describe('Module hooks — fs.access callback', () => { it('fs.access calls back with ENOENT for missing VFS files', (_, done) => { vfs = create(); - vfs.mount('/vfs-test-cb-access-miss'); + vfs.mount(); - fs.access('/vfs-test-cb-access-miss/nope.txt', (err) => { + fs.access(path.join(vfs.mountPoint, 'nope.txt'), (err) => { assert.ok(err); assert.strictEqual(err.code, 'ENOENT'); done(); @@ -146,9 +147,9 @@ describe('Module hooks — fs callback patches', () => { it('fs.stat calls back with stats for VFS files', (_, done) => { vfs = create(); vfs.writeFileSync('/cb-stat.txt', 'data'); - vfs.mount('/vfs-test-cb-stat'); + vfs.mount(); - fs.stat('/vfs-test-cb-stat/cb-stat.txt', (err, stats) => { + fs.stat(path.join(vfs.mountPoint, 'cb-stat.txt'), (err, stats) => { assert.ifError(err); assert.ok(stats.isFile()); done(); @@ -157,9 +158,9 @@ describe('Module hooks — fs callback patches', () => { it('fs.stat calls back with ENOENT for missing VFS files', (_, done) => { vfs = create(); - vfs.mount('/vfs-test-cb-stat-miss'); + vfs.mount(); - fs.stat('/vfs-test-cb-stat-miss/nope.txt', (err) => { + fs.stat(path.join(vfs.mountPoint, 'nope.txt'), (err) => { assert.ok(err); assert.strictEqual(err.code, 'ENOENT'); done(); @@ -169,9 +170,9 @@ describe('Module hooks — fs callback patches', () => { it('fs.lstat calls back with stats for VFS files', (_, done) => { vfs = create(); vfs.writeFileSync('/cb-lstat.txt', 'data'); - vfs.mount('/vfs-test-cb-lstat'); + vfs.mount(); - fs.lstat('/vfs-test-cb-lstat/cb-lstat.txt', (err, stats) => { + fs.lstat(path.join(vfs.mountPoint, 'cb-lstat.txt'), (err, stats) => { assert.ifError(err); assert.ok(stats.isFile()); done(); @@ -181,9 +182,9 @@ describe('Module hooks — fs callback patches', () => { it('fs.readFile calls back with VFS content', (_, done) => { vfs = create(); vfs.writeFileSync('/cb-read.txt', 'callback content'); - vfs.mount('/vfs-test-cb-readfile'); + vfs.mount(); - fs.readFile('/vfs-test-cb-readfile/cb-read.txt', 'utf8', (err, content) => { + fs.readFile(path.join(vfs.mountPoint, 'cb-read.txt'), 'utf8', (err, content) => { assert.ifError(err); assert.strictEqual(content, 'callback content'); done(); @@ -192,9 +193,9 @@ describe('Module hooks — fs callback patches', () => { it('fs.readFile calls back with ENOENT for missing VFS files', (_, done) => { vfs = create(); - vfs.mount('/vfs-test-cb-readfile-miss'); + vfs.mount(); - fs.readFile('/vfs-test-cb-readfile-miss/nope.txt', 'utf8', (err) => { + fs.readFile(path.join(vfs.mountPoint, 'nope.txt'), 'utf8', (err) => { assert.ok(err); assert.strictEqual(err.code, 'ENOENT'); done(); @@ -205,9 +206,9 @@ describe('Module hooks — fs callback patches', () => { vfs = create(); vfs.writeFileSync('/cbdir/a.txt', 'a'); vfs.writeFileSync('/cbdir/b.txt', 'b'); - vfs.mount('/vfs-test-cb-readdir'); + vfs.mount(); - fs.readdir('/vfs-test-cb-readdir/cbdir', (err, entries) => { + fs.readdir(path.join(vfs.mountPoint, 'cbdir'), (err, entries) => { assert.ifError(err); assert.deepStrictEqual(entries.sort(), ['a.txt', 'b.txt']); done(); @@ -218,11 +219,11 @@ describe('Module hooks — fs callback patches', () => { vfs = create(); vfs.writeFileSync('/cb-link-target.txt', 'data'); vfs.symlinkSync('/cb-link-target.txt', '/cb-link.txt'); - vfs.mount('/vfs-test-cb-readlink'); + vfs.mount(); - fs.readlink('/vfs-test-cb-readlink/cb-link.txt', (err, target) => { + fs.readlink(path.join(vfs.mountPoint, 'cb-link.txt'), (err, target) => { assert.ifError(err); - assert.strictEqual(target, '/vfs-test-cb-readlink/cb-link-target.txt'); + assert.strictEqual(target, '/cb-link-target.txt'); done(); }); }); @@ -230,11 +231,11 @@ describe('Module hooks — fs callback patches', () => { it('fs.realpath calls back with resolved VFS path', (_, done) => { vfs = create(); vfs.writeFileSync('/cb-real.txt', 'data'); - vfs.mount('/vfs-test-cb-realpath'); + vfs.mount(); - fs.realpath('/vfs-test-cb-realpath/cb-real.txt', (err, resolved) => { + fs.realpath(path.join(vfs.mountPoint, 'cb-real.txt'), (err, resolved) => { assert.ifError(err); - assert.strictEqual(resolved, '/vfs-test-cb-realpath/cb-real.txt'); + assert.strictEqual(resolved, path.join(vfs.mountPoint, 'cb-real.txt')); done(); }); }); @@ -242,10 +243,10 @@ describe('Module hooks — fs callback patches', () => { it('fs.createReadStream returns a readable stream for VFS files', (_, done) => { vfs = create(); vfs.writeFileSync('/stream.txt', 'streamed data'); - vfs.mount('/vfs-test-cb-stream'); + vfs.mount(); const chunks = []; - const stream = fs.createReadStream('/vfs-test-cb-stream/stream.txt'); + const stream = fs.createReadStream(path.join(vfs.mountPoint, 'stream.txt')); stream.on('data', (chunk) => chunks.push(chunk)); stream.on('end', () => { assert.strictEqual(Buffer.concat(chunks).toString(), 'streamed data'); @@ -267,16 +268,16 @@ describe('Module hooks — fs.promises patches', () => { it('fs.promises.access resolves for existing VFS files', async () => { vfs = create(); vfs.writeFileSync('/paccess.txt', 'data'); - vfs.mount('/vfs-test-p-access'); + vfs.mount(); - await assert.doesNotReject(fsp.access('/vfs-test-p-access/paccess.txt')); + await assert.doesNotReject(fsp.access(path.join(vfs.mountPoint, 'paccess.txt'))); }); it('fs.promises.access rejects with ENOENT for missing VFS files', async () => { vfs = create(); - vfs.mount('/vfs-test-p-access-miss'); + vfs.mount(); - await assert.rejects(fsp.access('/vfs-test-p-access-miss/nope.txt'), { + await assert.rejects(fsp.access(path.join(vfs.mountPoint, 'nope.txt')), { code: 'ENOENT', }); }); @@ -284,27 +285,27 @@ describe('Module hooks — fs.promises patches', () => { it('fs.promises.readFile reads from VFS', async () => { vfs = create(); vfs.writeFileSync('/pread.txt', 'async vfs content'); - vfs.mount('/vfs-test-p-readfile'); + vfs.mount(); - const content = await fsp.readFile('/vfs-test-p-readfile/pread.txt', 'utf8'); + const content = await fsp.readFile(path.join(vfs.mountPoint, 'pread.txt'), 'utf8'); assert.strictEqual(content, 'async vfs content'); }); it('fs.promises.stat returns stats for VFS files', async () => { vfs = create(); vfs.writeFileSync('/pstat.txt', 'data'); - vfs.mount('/vfs-test-p-stat'); + vfs.mount(); - const stats = await fsp.stat('/vfs-test-p-stat/pstat.txt'); + const stats = await fsp.stat(path.join(vfs.mountPoint, 'pstat.txt')); assert.ok(stats.isFile()); }); it('fs.promises.lstat returns stats for VFS files', async () => { vfs = create(); vfs.writeFileSync('/plstat.txt', 'data'); - vfs.mount('/vfs-test-p-lstat'); + vfs.mount(); - const stats = await fsp.lstat('/vfs-test-p-lstat/plstat.txt'); + const stats = await fsp.lstat(path.join(vfs.mountPoint, 'plstat.txt')); assert.ok(stats.isFile()); }); @@ -312,9 +313,9 @@ describe('Module hooks — fs.promises patches', () => { vfs = create(); vfs.writeFileSync('/pdir/x.txt', 'x'); vfs.writeFileSync('/pdir/y.txt', 'y'); - vfs.mount('/vfs-test-p-readdir'); + vfs.mount(); - const entries = await fsp.readdir('/vfs-test-p-readdir/pdir'); + const entries = await fsp.readdir(path.join(vfs.mountPoint, 'pdir')); assert.deepStrictEqual(entries.sort(), ['x.txt', 'y.txt']); }); @@ -322,29 +323,29 @@ describe('Module hooks — fs.promises patches', () => { vfs = create(); vfs.writeFileSync('/plink-target.txt', 'data'); vfs.symlinkSync('/plink-target.txt', '/plink.txt'); - vfs.mount('/vfs-test-p-readlink'); + vfs.mount(); - const target = await fsp.readlink('/vfs-test-p-readlink/plink.txt'); - assert.strictEqual(target, '/vfs-test-p-readlink/plink-target.txt'); + const target = await fsp.readlink(path.join(vfs.mountPoint, 'plink.txt')); + assert.strictEqual(target, '/plink-target.txt'); }); it('fs.promises.realpath resolves VFS paths', async () => { vfs = create(); vfs.writeFileSync('/prealpath.txt', 'data'); - vfs.mount('/vfs-test-p-realpath'); + vfs.mount(); - const resolved = await fsp.realpath('/vfs-test-p-realpath/prealpath.txt'); - assert.strictEqual(resolved, '/vfs-test-p-realpath/prealpath.txt'); + const resolved = await fsp.realpath(path.join(vfs.mountPoint, 'prealpath.txt')); + assert.strictEqual(resolved, path.join(vfs.mountPoint, 'prealpath.txt')); }); it('require("fs/promises") returns the same patched object', async () => { vfs = create(); vfs.writeFileSync('/shared.txt', 'shared content'); - vfs.mount('/vfs-test-p-shared'); + vfs.mount(); // Both import paths should see VFS content - const content1 = await fs.promises.readFile('/vfs-test-p-shared/shared.txt', 'utf8'); - const content2 = await fsp.readFile('/vfs-test-p-shared/shared.txt', 'utf8'); + const content1 = await fs.promises.readFile(path.join(vfs.mountPoint, 'shared.txt'), 'utf8'); + const content2 = await fsp.readFile(path.join(vfs.mountPoint, 'shared.txt'), 'utf8'); assert.strictEqual(content1, 'shared content'); assert.strictEqual(content2, 'shared content'); }); @@ -362,9 +363,9 @@ describe('Module hooks — fd family patches', () => { it('fs.openSync + fs.readSync + fs.closeSync read a VFS file', () => { vfs = create(); vfs.writeFileSync('/fd.txt', 'hello from vfs'); - vfs.mount('/vfs-test-fd-sync'); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-sync/fd.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'fd.txt')); const buffer = Buffer.alloc(5); const bytesRead = fs.readSync(fd, buffer, 0, 5, 0); fs.closeSync(fd); @@ -376,10 +377,10 @@ describe('Module hooks — fd family patches', () => { it('fs.openSync throws ENOENT for a missing VFS file', () => { vfs = create(); vfs.writeFileSync('/present.txt', 'x'); - vfs.mount('/vfs-test-fd-missing'); + vfs.mount(); assert.throws( - () => fs.openSync('/vfs-test-fd-missing/absent.txt'), + () => fs.openSync(path.join(vfs.mountPoint, 'absent.txt')), (err) => err.code === 'ENOENT', ); }); @@ -387,9 +388,9 @@ describe('Module hooks — fd family patches', () => { it('fs.readSync accepts the options-object overload', () => { vfs = create(); vfs.writeFileSync('/opts.txt', 'abcdefgh'); - vfs.mount('/vfs-test-fd-opts'); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-opts/opts.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'opts.txt')); const buffer = Buffer.alloc(3); const bytesRead = fs.readSync(fd, buffer, { offset: 0, length: 3, position: 2 }); fs.closeSync(fd); @@ -401,9 +402,9 @@ describe('Module hooks — fd family patches', () => { it('fs.fstatSync returns stats for a VFS fd', () => { vfs = create(); vfs.writeFileSync('/stat-fd.txt', 'data'); - vfs.mount('/vfs-test-fd-fstat'); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-fstat/stat-fd.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'stat-fd.txt')); const stats = fs.fstatSync(fd); fs.closeSync(fd); @@ -414,9 +415,9 @@ describe('Module hooks — fd family patches', () => { it('sequential fs.readSync calls advance the file position', () => { vfs = create(); vfs.writeFileSync('/seq.txt', 'abcdef'); - vfs.mount('/vfs-test-fd-seq'); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-seq/seq.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'seq.txt')); const first = Buffer.alloc(3); const second = Buffer.alloc(3); fs.readSync(fd, first, 0, 3, null); @@ -430,9 +431,9 @@ describe('Module hooks — fd family patches', () => { it('fs.closeSync on a stale VFS fd throws EBADF', () => { vfs = create(); vfs.writeFileSync('/stale.txt', 'x'); - vfs.mount('/vfs-test-fd-stale'); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-stale/stale.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'stale.txt')); fs.closeSync(fd); assert.throws(() => fs.closeSync(fd), (err) => err.code === 'EBADF'); @@ -441,9 +442,9 @@ describe('Module hooks — fd family patches', () => { it('fs.open + fs.read + fs.close read a VFS file', (_t, done) => { vfs = create(); vfs.writeFileSync('/cb.txt', 'callback content'); - vfs.mount('/vfs-test-fd-cb'); + vfs.mount(); - fs.open('/vfs-test-fd-cb/cb.txt', 'r', (openErr, fd) => { + fs.open(path.join(vfs.mountPoint, 'cb.txt'), 'r', (openErr, fd) => { assert.ifError(openErr); const buffer = Buffer.alloc(8); fs.read(fd, buffer, 0, 8, 0, (readErr, bytesRead) => { @@ -461,9 +462,9 @@ describe('Module hooks — fd family patches', () => { it('fs.fstat returns stats for a VFS fd', (_t, done) => { vfs = create(); vfs.writeFileSync('/fstat-cb.txt', 'seven..'); - vfs.mount('/vfs-test-fd-fstat-cb'); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-fstat-cb/fstat-cb.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'fstat-cb.txt')); fs.fstat(fd, (err, stats) => { assert.ifError(err); assert.ok(stats.isFile()); @@ -476,7 +477,7 @@ describe('Module hooks — fd family patches', () => { it('real-fs descriptors still work while a VFS is mounted', () => { vfs = create(); vfs.writeFileSync('/unused.txt', 'x'); - vfs.mount('/vfs-test-fd-passthrough'); + vfs.mount(); const fd = fs.openSync(__filename, 'r'); const buffer = Buffer.alloc(12); @@ -490,17 +491,17 @@ describe('Module hooks — fd family patches', () => { }); it('an overlay mount leaves non-VFS paths on the real fs', () => { - vfs = create(); + vfs = create({ overlay: true }); vfs.writeFileSync('/only-here.txt', 'vfs'); - vfs.mount('/vfs-test-fd-overlay', { overlay: true }); + vfs.mount(); - const fd = fs.openSync('/vfs-test-fd-overlay/only-here.txt'); + const fd = fs.openSync(path.join(vfs.mountPoint, 'only-here.txt')); assert.strictEqual(fs.fstatSync(fd).size, 3); fs.closeSync(fd); - assert.throws( - () => fs.openSync('/vfs-test-fd-overlay/not-here.txt'), - (err) => err.code === 'ENOENT', - ); + assert.strictEqual(vfs.shouldHandle(path.join(vfs.mountPoint, 'not-here.txt')), false); + const realFd = fs.openSync(__filename); + assert.ok(fs.fstatSync(realFd).size > 0); + fs.closeSync(realFd); }); }); diff --git a/test/module_resolution.test.js b/test/module_resolution.test.js index 9b43d5d..b9578b9 100644 --- a/test/module_resolution.test.js +++ b/test/module_resolution.test.js @@ -15,16 +15,10 @@ function withVFS(files, fn) { } vfs.writeFileSync(path, content); } - vfs.mount('/'); + const mountPoint = vfs.mount(); try { - fn(vfs); + fn(mountPoint); } finally { - // Clean up require cache for all VFS paths - for (const key of Object.keys(require.cache)) { - if (key.startsWith('/node_modules/') || key.startsWith('/app/')) { - delete require.cache[key]; - } - } vfs.unmount(); provider.close(); } @@ -71,8 +65,8 @@ describe('Module resolution — file-before-directory', () => { 'module.exports = require("./schema");', '/app/package.json': '{"name":"app","main":"entry.js"}', - }, () => { - const result = require('/app/entry.js'); + }, (mountPoint) => { + const result = require(`${mountPoint}/app/entry.js`); assert.strictEqual(result, 'file', 'file.js should take precedence over directory/index.js'); }); @@ -86,9 +80,9 @@ describe('Module resolution — require.resolve() interception', () => { 'module.exports = 42;', '/node_modules/vfs-resolve-test/package.json': '{"name":"vfs-resolve-test","main":"index.js"}', - }, () => { + }, (mountPoint) => { const resolved = require.resolve('vfs-resolve-test'); - assert.strictEqual(resolved, '/node_modules/vfs-resolve-test/index.js'); + assert.strictEqual(resolved, `${mountPoint}/node_modules/vfs-resolve-test/index.js`); }); }); }); diff --git a/test/mount.test.js b/test/mount.test.js index c73ff79..7c0ee36 100644 --- a/test/mount.test.js +++ b/test/mount.test.js @@ -2,6 +2,8 @@ const { describe, it, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); +const path = require('node:path'); +const { devNull } = require('node:os'); const { create } = require('../index.js'); describe('VirtualFileSystem - mount/unmount', () => { @@ -12,131 +14,91 @@ describe('VirtualFileSystem - mount/unmount', () => { }); afterEach(() => { - if (vfs.mounted) { - vfs.unmount(); - } + if (vfs.mounted) vfs.unmount(); }); - it('mount returns the VFS instance for chaining', () => { + it('mount returns a unique path in the reserved namespace', () => { vfs.writeFileSync('/file.txt', 'data'); - const result = vfs.mount('/app'); - assert.strictEqual(result, vfs); + const mountPoint = vfs.mount(); + assert.strictEqual(mountPoint, vfs.mountPoint); + assert.ok(mountPoint.startsWith(path.join(devNull, 'vfs') + path.sep)); + const other = create({ moduleHooks: false }); + try { + assert.notStrictEqual(other.mount(), mountPoint); + } finally { + other.unmount(); + } }); - it('mounted property reflects mount state', () => { + it('mounted and mountPoint reflect mount state', () => { assert.strictEqual(vfs.mounted, false); - vfs.mount('/app'); + assert.strictEqual(vfs.mountPoint, null); + const mountPoint = vfs.mount(); assert.strictEqual(vfs.mounted, true); + assert.strictEqual(vfs.mountPoint, mountPoint); vfs.unmount(); assert.strictEqual(vfs.mounted, false); - }); - - it('mountPoint returns the prefix', () => { - assert.strictEqual(vfs.mountPoint, null); - vfs.mount('/app'); - assert.strictEqual(vfs.mountPoint, '/app'); - vfs.unmount(); assert.strictEqual(vfs.mountPoint, null); }); - it('shouldHandle returns true for paths under mount point', () => { - vfs.mount('/app'); - assert.strictEqual(vfs.shouldHandle('/app/file.txt'), true); - assert.strictEqual(vfs.shouldHandle('/app'), true); + it('shouldHandle only paths under the mount point', () => { + const mountPoint = vfs.mount(); + assert.strictEqual(vfs.shouldHandle(path.join(mountPoint, 'file.txt')), true); + assert.strictEqual(vfs.shouldHandle(mountPoint), true); + assert.strictEqual(vfs.shouldHandle(mountPoint + '-other/file.txt'), false); assert.strictEqual(vfs.shouldHandle('/other/file.txt'), false); }); - it('readFileSync works through mount point', () => { + it('readFileSync works through the returned path', () => { vfs.writeFileSync('/file.txt', 'mounted content'); - vfs.mount('/mnt'); - const content = vfs.readFileSync('/mnt/file.txt', 'utf8'); - assert.strictEqual(content, 'mounted content'); + const mountPoint = vfs.mount(); + assert.strictEqual(vfs.readFileSync(path.join(mountPoint, 'file.txt'), 'utf8'), 'mounted content'); + }); + + it('rejects prefixes, even an explicit undefined', () => { + assert.throws(() => vfs.mount('/app'), TypeError); + assert.throws(() => vfs.mount(undefined), TypeError); + assert.strictEqual(vfs.mounted, false); }); it('throws when mounting twice', () => { - vfs.mount('/app'); - assert.throws(() => vfs.mount('/other'), { - code: 'ERR_INVALID_STATE', - }); + vfs.mount(); + assert.throws(() => vfs.mount(), { code: 'ERR_INVALID_STATE' }); }); it('throws ENOENT for paths outside mount point', () => { - vfs.mount('/app'); - assert.throws(() => vfs.readFileSync('/outside/file.txt'), { - code: 'ENOENT', - }); + vfs.mount(); + assert.throws(() => vfs.readFileSync('/outside/file.txt'), { code: 'ENOENT' }); }); it('emits vfs-mount event', (t, done) => { process.once('vfs-mount', (info) => { - assert.strictEqual(info.mountPoint, '/test-mount'); + assert.strictEqual(info.mountPoint, vfs.mountPoint); assert.strictEqual(info.overlay, false); assert.strictEqual(info.readonly, false); vfs.unmount(); done(); }); - vfs.mount('/test-mount'); + vfs.mount(); }); it('emits vfs-unmount event', (t, done) => { - vfs.mount('/test-unmount'); + const mountPoint = vfs.mount(); process.once('vfs-unmount', (info) => { - assert.strictEqual(info.mountPoint, '/test-unmount'); + assert.strictEqual(info.mountPoint, mountPoint); done(); }); vfs.unmount(); }); it('Symbol.dispose unmounts', () => { - vfs.mount('/disposable'); + vfs.mount(); assert.strictEqual(vfs.mounted, true); vfs[Symbol.dispose](); assert.strictEqual(vfs.mounted, false); }); }); -describe('VirtualFileSystem - backslash path mount', () => { - let vfs; - - beforeEach(() => { - vfs = create({ moduleHooks: false }); - }); - - afterEach(() => { - if (vfs.mounted) { - vfs.unmount(); - } - }); - - it('shouldHandle accepts backslash paths under mount point', () => { - vfs.mount('C:\\app'); - assert.strictEqual(vfs.shouldHandle('C:\\app\\file.txt'), true); - assert.strictEqual(vfs.shouldHandle('C:\\app'), true); - }); - - it('shouldHandle rejects backslash paths outside mount point', () => { - vfs.mount('C:\\app'); - assert.strictEqual(vfs.shouldHandle('C:\\other\\file.txt'), false); - }); - - it('shouldHandle rejects backslash paths that are a prefix but not a child', () => { - vfs.mount('C:\\app'); - assert.strictEqual(vfs.shouldHandle('C:\\application\\file.txt'), false); - }); - - it('shouldHandle works with a drive root mount', () => { - vfs.writeFileSync('/data.json', '{}'); - vfs.mount('C:\\'); - assert.strictEqual(vfs.shouldHandle('C:\\data.json'), true); - assert.strictEqual(vfs.shouldHandle('C:\\deep\\nested\\path'), true); - }); - - it('mountPoint preserves backslashes', () => { - vfs.mount('C:\\app'); - assert.strictEqual(vfs.mountPoint, 'C:\\app'); - }); -}); - describe('VirtualFileSystem - Windows path I/O', { skip: process.platform !== 'win32' }, () => { let vfs; @@ -145,23 +107,13 @@ describe('VirtualFileSystem - Windows path I/O', { skip: process.platform !== 'w }); afterEach(() => { - if (vfs.mounted) { - vfs.unmount(); - } + if (vfs.mounted) vfs.unmount(); }); - it('readFileSync works through a Windows mount point', () => { + it('readFileSync works through the Windows mount point', () => { vfs.writeFileSync('/file.txt', 'windows mount content'); - vfs.mount('C:\\mnt'); - const content = vfs.readFileSync('C:\\mnt\\file.txt', 'utf8'); - assert.strictEqual(content, 'windows mount content'); - }); - - it('throws ENOENT for paths outside Windows mount point', () => { - vfs.mount('C:\\app'); - assert.throws(() => vfs.readFileSync('C:\\outside\\file.txt'), { - code: 'ENOENT', - }); + const mountPoint = vfs.mount(); + assert.strictEqual(vfs.readFileSync(path.join(mountPoint, 'file.txt'), 'utf8'), 'windows mount content'); }); }); @@ -169,17 +121,15 @@ describe('VirtualFileSystem - overlay mode', () => { let vfs; afterEach(() => { - if (vfs?.mounted) { - vfs.unmount(); - } + if (vfs?.mounted) vfs.unmount(); }); it('shouldHandle returns true only for existing files in overlay mode', () => { vfs = create({ moduleHooks: false, overlay: true }); vfs.writeFileSync('/config.json', '{"test": true}'); - vfs.mount('/'); + const mountPoint = vfs.mount(); - assert.strictEqual(vfs.shouldHandle('/config.json'), true); - assert.strictEqual(vfs.shouldHandle('/nonexistent.txt'), false); + assert.strictEqual(vfs.shouldHandle(path.join(mountPoint, 'config.json')), true); + assert.strictEqual(vfs.shouldHandle(path.join(mountPoint, 'nonexistent.txt')), false); }); }); diff --git a/test/sqlite.test.js b/test/sqlite.test.js index e02c3cd..71666aa 100644 --- a/test/sqlite.test.js +++ b/test/sqlite.test.js @@ -738,12 +738,11 @@ describe('SqliteProvider - require/import with module hooks', () => { '/node_modules/sqlite-test-mod/package.json', '{"name":"sqlite-test-mod","main":"index.js"}', ); - vfs.mount('/'); + vfs.mount(); try { const mod = require('sqlite-test-mod'); assert.deepStrictEqual(mod, { answer: 42 }); } finally { - delete require.cache['/node_modules/sqlite-test-mod/index.js']; vfs.unmount(); provider.close(); } @@ -760,12 +759,11 @@ describe('SqliteProvider - require/import with module hooks', () => { '/node_modules/sqlite-test-json/package.json', '{"name":"sqlite-test-json","main":"data.json"}', ); - vfs.mount('/'); + vfs.mount(); try { const mod = require('sqlite-test-json'); assert.deepStrictEqual(mod, { hello: 'world' }); } finally { - delete require.cache['/node_modules/sqlite-test-json/data.json']; vfs.unmount(); provider.close(); } diff --git a/test/types.tst.ts b/test/types.tst.ts index 5d7ed21..f725f1f 100644 --- a/test/types.tst.ts +++ b/test/types.tst.ts @@ -65,12 +65,24 @@ test('fd operations', () => { const vfs = create(); expect(vfs.openSync('/file')).type.toBe(); expect(vfs.fstatSync(3)).type.toBe(); + expect(vfs.ftruncateSync(3)).type.toBe(); +}); + +test('additional fs operations', () => { + const vfs = create(); + expect(vfs.rmSync('/tree', { recursive: true })).type.toBe(); + expect(vfs.mkdtempSync('/tmp-')).type.toBe(); + expect(vfs.promises.rm('/tree', { force: true })).type.toBe>(); + expect(vfs.promises.truncate('/file')).type.toBe>(); + expect(vfs.promises.mkdtemp('/tmp-')).type.toBe>(); }); // mount / unmount -test('mount returns this', () => { +test('mount returns a path and accepts no prefix', () => { const vfs = create(); - expect(vfs.mount('/mnt')).type.toBe(); + expect(vfs.mount()).type.toBe(); + expect(vfs.mountPointURL).type.toBe(); + expect(vfs.mount).type.toBe<() => string>(); }); test('unmount returns void', () => {