From 581475d83e1835508900ead9c43994ff26e2d4cd Mon Sep 17 00:00:00 2001 From: Diogo de Souza Date: Thu, 22 Jan 2026 00:57:01 -0300 Subject: [PATCH 1/7] test_runner: add mock file system support --- lib/internal/test_runner/mock/mock.js | 19 + .../test_runner/mock/mock_file_system.js | 1202 +++++++++++++++++ src/node_options.cc | 6 + src/node_options.h | 1 + .../test-runner-mock-file-system-dirs.js | 240 ++++ .../test-runner-mock-file-system-no-flag.js | 14 + test/parallel/test-runner-mock-file-system.js | 614 +++++++++ 7 files changed, 2096 insertions(+) create mode 100644 lib/internal/test_runner/mock/mock_file_system.js create mode 100644 test/parallel/test-runner-mock-file-system-dirs.js create mode 100644 test/parallel/test-runner-mock-file-system-no-flag.js create mode 100644 test/parallel/test-runner-mock-file-system.js diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 1af24c77a10731..572f85fc99384e 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -48,10 +48,12 @@ const { validateOneOf, } = require('internal/validators'); const { MockTimers } = require('internal/test_runner/mock/mock_timers'); +const { MockFileSystem } = require('internal/test_runner/mock/mock_file_system'); const { Module } = require('internal/modules/cjs/loader'); const { _load, _nodeModulePaths, _resolveFilename, isBuiltin } = Module; function kDefaultFunction() {} const enableModuleMocking = getOptionValue('--experimental-test-module-mocks'); +const enableFsMocking = getOptionValue('--experimental-test-fs-mocks'); const kSupportedFormats = [ 'builtin', 'commonjs-typescript', @@ -405,6 +407,7 @@ const { restore: restoreProperty } = MockPropertyContext.prototype; class MockTracker { #mocks = []; #timers; + #fs; /** * Returns the mock timers of this MockTracker instance. @@ -415,6 +418,21 @@ class MockTracker { return this.#timers; } + /** + * Returns the mock file system of this MockTracker instance. + * @returns {MockFileSystem} The mock file system instance. + */ + get fs() { + if (!enableFsMocking) { + throw new ERR_INVALID_STATE( + 'File system mocking is not enabled. ' + + 'Use --experimental-test-fs-mocks to enable it.', + ); + } + this.#fs ??= new MockFileSystem(); + return this.#fs; + } + /** * Creates a mock function tracker. * @param {Function} [original] - The original function to be tracked. @@ -731,6 +749,7 @@ class MockTracker { reset() { this.restoreAll(); this.#timers?.reset(); + this.#fs?.reset(); this.#mocks = []; } diff --git a/lib/internal/test_runner/mock/mock_file_system.js b/lib/internal/test_runner/mock/mock_file_system.js new file mode 100644 index 00000000000000..de319e25ab88ba --- /dev/null +++ b/lib/internal/test_runner/mock/mock_file_system.js @@ -0,0 +1,1202 @@ +'use strict'; + +const { + ArrayFrom, + ArrayPrototypeForEach, + ArrayPrototypeIncludes, + ArrayPrototypeMap, + ArrayPrototypeUnshift, + Date, + DateNow, + FunctionPrototypeCall, + MathCeil, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + ObjectGetPrototypeOf, + ObjectKeys, + ObjectPrototype, + ObjectPrototypeHasOwnProperty, + ReflectConstruct, + RegExpPrototypeSymbolSplit, + SafeMap, + SafeSet, + String, + StringPrototypeEndsWith, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, + SymbolDispose, + hardenRegExp, +} = primordials; + +const { + UVException, + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + }, +} = require('internal/errors'); + +const { + validateBoolean, + validateObject, + validateStringArray, +} = require('internal/validators'); + +const { kEmptyObject, emitExperimentalWarning } = require('internal/util'); +const { isUint8Array } = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { fileURLToPath, isURL } = require('internal/url'); + +const BufferPrototypeToString = Buffer.prototype.toString; +const BufferFrom = Buffer.from; +const BufferConcat = Buffer.concat; +const BufferAlloc = Buffer.alloc; +const { UV_ENOENT, UV_ENOTDIR, UV_ENOTEMPTY, UV_EEXIST, UV_EISDIR } = + internalBinding('uv'); + +const fs = require('fs'); +const fsPromises = require('fs/promises'); +const { + resolve: pathResolve, + dirname: pathDirname, + sep: pathSep, +} = require('path'); +const { + Stats, +} = require('internal/fs/utils'); + +/** + * @enum {('readFile'|'writeFile'|'appendFile'|'stat'|'lstat'|'access'| + * 'exists'|'unlink'|'mkdir'|'rmdir'|'readdir')[]} Supported fs APIs + */ +const SUPPORTED_APIS = [ + 'readFile', + 'writeFile', + 'appendFile', + 'stat', + 'lstat', + 'access', + 'exists', + 'unlink', + 'mkdir', + 'rmdir', + 'readdir', +]; + +// Path segments that could lead to prototype pollution attacks. +const kDangerousPathSegments = new SafeSet(['__proto__', 'constructor', 'prototype']); + +// Regex for splitting paths on forward or back slashes. +const kPathSeparatorRegex = hardenRegExp(/[\\/]/); + +/** + * @typedef {object} MockFileSystemOptions + * @property {{[path: string]: string|Buffer|Uint8Array}} [files] Virtual files to create. + * @property {boolean} [isolate] If true, block access to real file system. + * @property {SUPPORTED_APIS} [apis] Which fs APIs to mock. + */ + +// File mode constants from POSIX standard. +const S_IFDIR = 0o40000; // Directory. +const S_IFREG = 0o100000; // Regular file. + +// Standard block sizes for Unix-like systems. +const DEFAULT_BLKSIZE = 4096; // Default filesystem block size (4 KiB). +const BLOCK_SIZE = 512; // Standard Unix block size for st_blocks. + +/** + * Creates a mock Stats object that properly inherits from fs.Stats.prototype. + * This ensures that `stats instanceof fs.Stats` returns true. + * @param {{isDirectory?: boolean, size?: number, mode?: number}} options + * @returns {fs.Stats} + */ +function createMockStats({ isDirectory = false, size = 0, mode = 0o644 }) { + const nowMs = DateNow(); + const nowDate = ReflectConstruct(Date, [nowMs]); + + // Create object that inherits from Stats.prototype so instanceof checks work. + // We use Object.create to avoid calling the deprecated Stats constructor. + const stats = { __proto__: Stats.prototype }; + + // Set all the standard stats properties. + stats.dev = 0; + stats.ino = 0; + stats.mode = isDirectory ? S_IFDIR | mode : S_IFREG | mode; + stats.nlink = 1; + stats.uid = 0; + stats.gid = 0; + stats.rdev = 0; + stats.size = size; + stats.blksize = DEFAULT_BLKSIZE; + stats.blocks = MathCeil(size / BLOCK_SIZE); + stats.atimeMs = nowMs; + stats.mtimeMs = nowMs; + stats.ctimeMs = nowMs; + stats.birthtimeMs = nowMs; + stats.atime = nowDate; + stats.mtime = nowDate; + stats.ctime = nowDate; + stats.birthtime = nowDate; + + return stats; +} + +/** + * @param {string} syscall + * @param {string} filepath + * @returns {Error} + */ +function createENOENT(syscall, filepath) { + return new UVException({ + __proto__: null, + errno: UV_ENOENT, + syscall, + path: filepath, + message: 'no such file or directory', + }); +} + +/** + * @param {string} syscall + * @param {string} filepath + * @returns {Error} + */ +function createENOTDIR(syscall, filepath) { + return new UVException({ + __proto__: null, + errno: UV_ENOTDIR, + syscall, + path: filepath, + message: 'not a directory', + }); +} + +/** + * @param {string} syscall + * @param {string} filepath + * @returns {Error} + */ +function createENOTEMPTY(syscall, filepath) { + return new UVException({ + __proto__: null, + errno: UV_ENOTEMPTY, + syscall, + path: filepath, + message: 'directory not empty', + }); +} + +/** + * @param {string} syscall + * @param {string} filepath + * @returns {Error} + */ +function createEEXIST(syscall, filepath) { + return new UVException({ + __proto__: null, + errno: UV_EEXIST, + syscall, + path: filepath, + message: 'file already exists', + }); +} + +/** + * @param {string} syscall + * @param {string} filepath + * @returns {Error} + */ +function createEISDIR(syscall, filepath) { + return new UVException({ + __proto__: null, + errno: UV_EISDIR, + syscall, + path: filepath, + message: 'illegal operation on a directory', + }); +} + +/** + * @param {string} base + * @param {string} name + * @returns {string} + */ +function safePathJoin(base, name) { + if (StringPrototypeEndsWith(base, pathSep)) { + return base + name; + } + return base + pathSep + name; +} + +/** + * @param {string} str + * @returns {string} + */ +function getFirstPathSegment(str) { + const sepIndex = StringPrototypeIndexOf(str, pathSep); + if (sepIndex === -1) { + return str; + } + return StringPrototypeSlice(str, 0, sepIndex); +} + +class MockFileSystem { + #isEnabled = false; + #files = new SafeMap(); // Normalized path -> { content: Buffer, stat: {...} }. + #directories = new SafeSet(); // Normalized paths of virtual directories. + #isolate = false; + #apisInContext = []; + + // Original method descriptors for restoration. + #originals = { __proto__: null }; + + #normalizePath(filepath) { + if (typeof filepath === 'string') { + return pathResolve(filepath); + } + if (isUint8Array(filepath)) { + const bufferPath = BufferFrom(filepath); + return pathResolve( + FunctionPrototypeCall(BufferPrototypeToString, bufferPath, 'utf8'), + ); + } + if (isURL(filepath)) { + if (filepath.protocol !== 'file:') { + throw new ERR_INVALID_ARG_VALUE('path', filepath, 'must be a file URL'); + } + return fileURLToPath(filepath); + } + return pathResolve(String(filepath)); + } + + #virtualExists(normalizedPath) { + return ( + this.#files.has(normalizedPath) || this.#directories.has(normalizedPath) + ); + } + + #populateFiles(files) { + // Check if __proto__ was used in the object literal (which modifies prototype). + if (ObjectGetPrototypeOf(files) !== ObjectPrototype) { + throw new ERR_INVALID_ARG_VALUE( + 'options.files', + '__proto__', + 'cannot use __proto__ as a key in the files object', + ); + } + + const filePaths = ObjectKeys(files); + for (let i = 0; i < filePaths.length; i++) { + const filepath = filePaths[i]; + + // Ensure the property is own (not inherited). + if (!ObjectPrototypeHasOwnProperty(files, filepath)) { + continue; + } + + // Check for dangerous path segments that could lead to prototype pollution. + const segments = RegExpPrototypeSymbolSplit(kPathSeparatorRegex, filepath); + for (let j = 0; j < segments.length; j++) { + if (kDangerousPathSegments.has(segments[j])) { + throw new ERR_INVALID_ARG_VALUE( + 'options.files', + filepath, + 'cannot contain __proto__, constructor, or prototype in path', + ); + } + } + + const content = files[filepath]; + const normalizedPath = this.#normalizePath(filepath); + + // Convert content to Buffer. + let buffer; + if (typeof content === 'string') { + buffer = BufferFrom(content, 'utf8'); + } else if (isUint8Array(content)) { + buffer = BufferFrom(content); + } else { + throw new ERR_INVALID_ARG_TYPE( + `options.files['${filepath}']`, + ['string', 'Buffer', 'Uint8Array'], + content, + ); + } + + // Store the file. + this.#files.set(normalizedPath, { + __proto__: null, + content: buffer, + stat: createMockStats({ __proto__: null, size: buffer.length }), + }); + + // Ensure parent directories exist in the virtual fs. + let dir = pathDirname(normalizedPath); + while (dir !== pathDirname(dir)) { + this.#directories.add(dir); + dir = pathDirname(dir); + } + } + } + + #toggleEnableApis(activate) { + const self = this; + + const options = { + __proto__: null, + toFake: { + '__proto__': null, + 'readFile': () => { + this.#originals.readFile = ObjectGetOwnPropertyDescriptor(fs, 'readFile'); + this.#originals.readFileSync = ObjectGetOwnPropertyDescriptor(fs, 'readFileSync'); + this.#originals.promisesReadFile = ObjectGetOwnPropertyDescriptor(fsPromises, 'readFile'); + + const origReadFileSync = this.#originals.readFileSync.value; + const origReadFile = this.#originals.readFile.value; + const origPromisesReadFile = this.#originals.promisesReadFile.value; + + fs.readFileSync = function readFileSync(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const file = self.#files.get(normalizedPath); + + if (file) { + const encoding = typeof opts === 'string' ? opts : opts?.encoding; + if (encoding) { + return FunctionPrototypeCall(BufferPrototypeToString, file.content, encoding); + } + return BufferFrom(file.content); + } + + if (self.#directories.has(normalizedPath)) { + throw createEISDIR('open', normalizedPath); + } + + if (self.#isolate) { + throw createENOENT('open', normalizedPath); + } + + return origReadFileSync(filepath, opts); + }; + + fs.readFile = function readFile(filepath, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#files.has(normalizedPath) || self.#directories.has(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + const result = fs.readFileSync(filepath, opts); + process.nextTick(callback, null, result); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origReadFile(filepath, opts, callback); + } + }; + + fsPromises.readFile = async function readFile(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#files.has(normalizedPath) || self.#directories.has(normalizedPath); + + if (isVirtual || self.#isolate) { + return fs.readFileSync(filepath, opts); + } + + return origPromisesReadFile(filepath, opts); + }; + }, + + 'writeFile': () => { + this.#originals.writeFile = ObjectGetOwnPropertyDescriptor(fs, 'writeFile'); + this.#originals.writeFileSync = ObjectGetOwnPropertyDescriptor(fs, 'writeFileSync'); + this.#originals.promisesWriteFile = ObjectGetOwnPropertyDescriptor(fsPromises, 'writeFile'); + + fs.writeFileSync = function writeFileSync(filepath, data, opts) { + const normalizedPath = self.#normalizePath(filepath); + + if (self.#directories.has(normalizedPath)) { + throw createEISDIR('open', normalizedPath); + } + + // Always write to virtual fs when mock is enabled. + // This keeps test environments isolated from the real filesystem. + let buffer; + if (typeof data === 'string') { + const encoding = typeof opts === 'string' ? opts : opts?.encoding || 'utf8'; + buffer = BufferFrom(data, encoding); + } else if (isUint8Array(data)) { + buffer = BufferFrom(data); + } else { + buffer = BufferFrom(String(data)); + } + + self.#files.set(normalizedPath, { + __proto__: null, + content: buffer, + stat: createMockStats({ __proto__: null, size: buffer.length }), + }); + + let dir = pathDirname(normalizedPath); + while (dir !== pathDirname(dir)) { + self.#directories.add(dir); + dir = pathDirname(dir); + } + }; + + fs.writeFile = function writeFile(filepath, data, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + // Always write to virtual fs when mock is enabled. + try { + fs.writeFileSync(filepath, data, opts); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + }; + + fsPromises.writeFile = async function writeFile(filepath, data, opts) { + // Always write to virtual fs when mock is enabled. + fs.writeFileSync(filepath, data, opts); + }; + }, + + 'appendFile': () => { + this.#originals.appendFile = ObjectGetOwnPropertyDescriptor(fs, 'appendFile'); + this.#originals.appendFileSync = ObjectGetOwnPropertyDescriptor(fs, 'appendFileSync'); + this.#originals.promisesAppendFile = ObjectGetOwnPropertyDescriptor(fsPromises, 'appendFile'); + + fs.appendFileSync = function appendFileSync(filepath, data, opts) { + const normalizedPath = self.#normalizePath(filepath); + + if (self.#directories.has(normalizedPath)) { + throw createEISDIR('open', normalizedPath); + } + + const existingFile = self.#files.get(normalizedPath); + + // Always append to virtual fs when mock is enabled. + let buffer; + if (typeof data === 'string') { + const encoding = typeof opts === 'string' ? opts : opts?.encoding || 'utf8'; + buffer = BufferFrom(data, encoding); + } else if (isUint8Array(data)) { + buffer = BufferFrom(data); + } else { + buffer = BufferFrom(String(data)); + } + + const existingContent = existingFile ? existingFile.content : BufferAlloc(0); + const newContent = BufferConcat([existingContent, buffer]); + + self.#files.set(normalizedPath, { + __proto__: null, + content: newContent, + stat: createMockStats({ __proto__: null, size: newContent.length }), + }); + + let dir = pathDirname(normalizedPath); + while (dir !== pathDirname(dir)) { + self.#directories.add(dir); + dir = pathDirname(dir); + } + }; + + fs.appendFile = function appendFile(filepath, data, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + // Always append to virtual fs when mock is enabled. + try { + fs.appendFileSync(filepath, data, opts); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + }; + + fsPromises.appendFile = async function appendFile(filepath, data, opts) { + // Always append to virtual fs when mock is enabled. + fs.appendFileSync(filepath, data, opts); + }; + }, + + 'stat': () => { + this.#originals.stat = ObjectGetOwnPropertyDescriptor(fs, 'stat'); + this.#originals.statSync = ObjectGetOwnPropertyDescriptor(fs, 'statSync'); + this.#originals.promisesStat = ObjectGetOwnPropertyDescriptor(fsPromises, 'stat'); + + const origStatSync = this.#originals.statSync.value; + const origStat = this.#originals.stat.value; + const origPromisesStat = this.#originals.promisesStat.value; + + fs.statSync = function statSync(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const file = self.#files.get(normalizedPath); + + if (file) { + return file.stat; + } + + if (self.#directories.has(normalizedPath)) { + return createMockStats({ __proto__: null, isDirectory: true }); + } + + if (self.#isolate) { + if (opts?.throwIfNoEntry === false) { + return undefined; + } + throw createENOENT('stat', normalizedPath); + } + + return origStatSync(filepath, opts); + }; + + fs.stat = function stat(filepath, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + const result = fs.statSync(filepath, opts); + process.nextTick(callback, null, result); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origStat(filepath, opts, callback); + } + }; + + fsPromises.stat = async function stat(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + return fs.statSync(filepath, opts); + } + + return origPromisesStat(filepath, opts); + }; + }, + + 'lstat': () => { + this.#originals.lstat = ObjectGetOwnPropertyDescriptor(fs, 'lstat'); + this.#originals.lstatSync = ObjectGetOwnPropertyDescriptor(fs, 'lstatSync'); + this.#originals.promisesLstat = ObjectGetOwnPropertyDescriptor(fsPromises, 'lstat'); + + const origLstatSync = this.#originals.lstatSync.value; + const origLstat = this.#originals.lstat.value; + const origPromisesLstat = this.#originals.promisesLstat.value; + + fs.lstatSync = function lstatSync(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + + // No symlink support - lstat behaves like stat for virtual files. + const file = self.#files.get(normalizedPath); + if (file) { + return file.stat; + } + + if (self.#directories.has(normalizedPath)) { + return createMockStats({ __proto__: null, isDirectory: true }); + } + + if (self.#isolate) { + if (opts?.throwIfNoEntry === false) { + return undefined; + } + throw createENOENT('lstat', normalizedPath); + } + + return origLstatSync(filepath, opts); + }; + + fs.lstat = function lstat(filepath, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + const result = fs.lstatSync(filepath, opts); + process.nextTick(callback, null, result); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origLstat(filepath, opts, callback); + } + }; + + fsPromises.lstat = async function lstat(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + return fs.lstatSync(filepath, opts); + } + + return origPromisesLstat(filepath, opts); + }; + }, + + 'access': () => { + this.#originals.access = ObjectGetOwnPropertyDescriptor(fs, 'access'); + this.#originals.accessSync = ObjectGetOwnPropertyDescriptor(fs, 'accessSync'); + this.#originals.promisesAccess = ObjectGetOwnPropertyDescriptor(fsPromises, 'access'); + + const origAccessSync = this.#originals.accessSync.value; + const origAccess = this.#originals.access.value; + const origPromisesAccess = this.#originals.promisesAccess.value; + + fs.accessSync = function accessSync(filepath, mode) { + const normalizedPath = self.#normalizePath(filepath); + + if (self.#virtualExists(normalizedPath)) { + return undefined; + } + + if (self.#isolate) { + throw createENOENT('access', normalizedPath); + } + + return origAccessSync(filepath, mode); + }; + + fs.access = function access(filepath, mode, callback) { + if (typeof mode === 'function') { + callback = mode; + mode = undefined; + } + + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + fs.accessSync(filepath, mode); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origAccess(filepath, mode, callback); + } + }; + + fsPromises.access = async function access(filepath, mode) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + fs.accessSync(filepath, mode); + return; + } + + return origPromisesAccess(filepath, mode); + }; + }, + + 'exists': () => { + this.#originals.exists = ObjectGetOwnPropertyDescriptor(fs, 'exists'); + this.#originals.existsSync = ObjectGetOwnPropertyDescriptor(fs, 'existsSync'); + + const origExists = this.#originals.exists.value; + const origExistsSync = this.#originals.existsSync.value; + + fs.existsSync = function existsSync(filepath) { + const normalizedPath = self.#normalizePath(filepath); + + if (self.#virtualExists(normalizedPath)) { + return true; + } + + if (self.#isolate) { + return false; + } + + return origExistsSync(filepath); + }; + + fs.exists = function exists(filepath, callback) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + process.nextTick(callback, isVirtual); + } else { + origExists(filepath, callback); + } + }; + }, + + 'unlink': () => { + this.#originals.unlink = ObjectGetOwnPropertyDescriptor(fs, 'unlink'); + this.#originals.unlinkSync = ObjectGetOwnPropertyDescriptor(fs, 'unlinkSync'); + this.#originals.promisesUnlink = ObjectGetOwnPropertyDescriptor(fsPromises, 'unlink'); + + const origUnlinkSync = this.#originals.unlinkSync.value; + const origUnlink = this.#originals.unlink.value; + const origPromisesUnlink = this.#originals.promisesUnlink.value; + + fs.unlinkSync = function unlinkSync(filepath) { + const normalizedPath = self.#normalizePath(filepath); + + if (self.#files.has(normalizedPath)) { + self.#files.delete(normalizedPath); + return undefined; + } + + if (self.#directories.has(normalizedPath)) { + throw createEISDIR('unlink', normalizedPath); + } + + if (self.#isolate) { + throw createENOENT('unlink', normalizedPath); + } + + return origUnlinkSync(filepath); + }; + + fs.unlink = function unlink(filepath, callback) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + fs.unlinkSync(filepath); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origUnlink(filepath, callback); + } + }; + + fsPromises.unlink = async function unlink(filepath) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + fs.unlinkSync(filepath); + return; + } + + return origPromisesUnlink(filepath); + }; + }, + + 'mkdir': () => { + this.#originals.mkdir = ObjectGetOwnPropertyDescriptor(fs, 'mkdir'); + this.#originals.mkdirSync = ObjectGetOwnPropertyDescriptor(fs, 'mkdirSync'); + this.#originals.promisesMkdir = ObjectGetOwnPropertyDescriptor(fsPromises, 'mkdir'); + + fs.mkdirSync = function mkdirSync(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const recursive = opts?.recursive ?? false; + + if (self.#virtualExists(normalizedPath)) { + if (recursive) { + return undefined; + } + throw createEEXIST('mkdir', normalizedPath); + } + + // Always create directories in virtual fs when mock is enabled. + // This keeps test environments isolated from the real filesystem. + const parentDir = pathDirname(normalizedPath); + const isParentRoot = parentDir === pathDirname(parentDir); + + // For non-recursive mkdir, require parent to exist (or be root). + if (!recursive && !self.#directories.has(parentDir) && !isParentRoot) { + throw createENOENT('mkdir', normalizedPath); + } + + if (recursive) { + // Collect directories that need to be created, from root to target. + const dirsToCreate = []; + let dir = normalizedPath; + while (dir !== pathDirname(dir) && !self.#directories.has(dir)) { + ArrayPrototypeUnshift(dirsToCreate, dir); + dir = pathDirname(dir); + } + + // Create all directories. + for (let i = 0; i < dirsToCreate.length; i++) { + self.#directories.add(dirsToCreate[i]); + } + + // Return the first directory created, or undefined if none. + return dirsToCreate.length > 0 ? dirsToCreate[0] : undefined; + } + + self.#directories.add(normalizedPath); + return undefined; + }; + + fs.mkdir = function mkdir(filepath, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + // Always use virtual fs when mock is enabled + try { + const result = fs.mkdirSync(filepath, opts); + process.nextTick(callback, null, result); + } catch (err) { + process.nextTick(callback, err); + } + }; + + fsPromises.mkdir = async function mkdir(filepath, opts) { + // Always use virtual fs when mock is enabled + return fs.mkdirSync(filepath, opts); + }; + }, + + 'rmdir': () => { + this.#originals.rmdir = ObjectGetOwnPropertyDescriptor(fs, 'rmdir'); + this.#originals.rmdirSync = ObjectGetOwnPropertyDescriptor(fs, 'rmdirSync'); + this.#originals.promisesRmdir = ObjectGetOwnPropertyDescriptor(fsPromises, 'rmdir'); + + const origRmdir = this.#originals.rmdir.value; + const origRmdirSync = this.#originals.rmdirSync.value; + const origPromisesRmdir = this.#originals.promisesRmdir.value; + + fs.rmdirSync = function rmdirSync(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + + if (self.#directories.has(normalizedPath)) { + const prefix = normalizedPath + pathSep; + const fileKeys = ArrayFrom(self.#files.keys()); + for (let i = 0; i < fileKeys.length; i++) { + if (StringPrototypeStartsWith(fileKeys[i], prefix)) { + throw createENOTEMPTY('rmdir', normalizedPath); + } + } + const dirKeys = ArrayFrom(self.#directories); + for (let i = 0; i < dirKeys.length; i++) { + if (dirKeys[i] !== normalizedPath && StringPrototypeStartsWith(dirKeys[i], prefix)) { + throw createENOTEMPTY('rmdir', normalizedPath); + } + } + self.#directories.delete(normalizedPath); + return undefined; + } + + if (self.#files.has(normalizedPath)) { + throw createENOTDIR('rmdir', normalizedPath); + } + + if (self.#isolate) { + throw createENOENT('rmdir', normalizedPath); + } + + return origRmdirSync(filepath, opts); + }; + + fs.rmdir = function rmdir(filepath, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + fs.rmdirSync(filepath, opts); + process.nextTick(callback, null); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origRmdir(filepath, opts, callback); + } + }; + + fsPromises.rmdir = async function rmdir(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + fs.rmdirSync(filepath, opts); + return; + } + + return origPromisesRmdir(filepath, opts); + }; + }, + + 'readdir': () => { + this.#originals.readdir = ObjectGetOwnPropertyDescriptor(fs, 'readdir'); + this.#originals.readdirSync = ObjectGetOwnPropertyDescriptor(fs, 'readdirSync'); + this.#originals.promisesReaddir = ObjectGetOwnPropertyDescriptor(fsPromises, 'readdir'); + + const origReaddir = this.#originals.readdir.value; + const origReaddirSync = this.#originals.readdirSync.value; + const origPromisesReaddir = this.#originals.promisesReaddir.value; + + fs.readdirSync = function readdirSync(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + + const isRoot = normalizedPath === pathDirname(normalizedPath); + const isVirtualDir = self.#directories.has(normalizedPath); + + if (isVirtualDir || isRoot) { + // Check if recursive option is used - not supported in mock fs + if (opts?.recursive) { + throw new ERR_INVALID_ARG_VALUE( + 'options.recursive', + true, + 'is not supported by mock file system', + ); + } + + const entries = new SafeSet(); + // For root path, don't double up the separator. + const prefix = StringPrototypeEndsWith(normalizedPath, pathSep) ? + normalizedPath : + normalizedPath + pathSep; + + const fileKeys = ArrayFrom(self.#files.keys()); + for (let i = 0; i < fileKeys.length; i++) { + const filePath = fileKeys[i]; + if (StringPrototypeStartsWith(filePath, prefix)) { + const relativePath = StringPrototypeSlice(filePath, prefix.length); + const firstSegment = getFirstPathSegment(relativePath); + entries.add(firstSegment); + } + } + + const dirKeys = ArrayFrom(self.#directories); + for (let i = 0; i < dirKeys.length; i++) { + const dirPath = dirKeys[i]; + if (StringPrototypeStartsWith(dirPath, prefix)) { + const relativePath = StringPrototypeSlice(dirPath, prefix.length); + const firstSegment = getFirstPathSegment(relativePath); + entries.add(firstSegment); + } + } + + const result = ArrayFrom(entries); + + if (opts?.withFileTypes) { + return ArrayPrototypeMap(result, (name) => { + const fullPath = safePathJoin(normalizedPath, name); + const isDir = self.#directories.has(fullPath); + return { + __proto__: null, + name, + parentPath: normalizedPath, + path: normalizedPath, + isFile() { return !isDir; }, + isDirectory() { return isDir; }, + isBlockDevice() { return false; }, + isCharacterDevice() { return false; }, + isSymbolicLink() { return false; }, + isFIFO() { return false; }, + isSocket() { return false; }, + }; + }); + } + + return result; + } + + if (self.#files.has(normalizedPath)) { + throw createENOTDIR('scandir', normalizedPath); + } + + if (self.#isolate) { + // Check if recursive option is used - not supported in mock fs + if (opts?.recursive) { + throw new ERR_INVALID_ARG_VALUE( + 'options.recursive', + true, + 'is not supported by mock file system', + ); + } + throw createENOENT('scandir', normalizedPath); + } + + return origReaddirSync(filepath, opts); + }; + + fs.readdir = function readdir(filepath, opts, callback) { + if (typeof opts === 'function') { + callback = opts; + opts = undefined; + } + + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + try { + const result = fs.readdirSync(filepath, opts); + process.nextTick(callback, null, result); + } catch (err) { + process.nextTick(callback, err); + } + } else { + origReaddir(filepath, opts, callback); + } + }; + + fsPromises.readdir = async function readdir(filepath, opts) { + const normalizedPath = self.#normalizePath(filepath); + const isVirtual = self.#virtualExists(normalizedPath); + + if (isVirtual || self.#isolate) { + return fs.readdirSync(filepath, opts); + } + + return origPromisesReaddir(filepath, opts); + }; + }, + }, + + toReal: { + '__proto__': null, + 'readFile': () => { + ObjectDefineProperty(fs, 'readFile', this.#originals.readFile); + ObjectDefineProperty(fs, 'readFileSync', this.#originals.readFileSync); + ObjectDefineProperty(fsPromises, 'readFile', this.#originals.promisesReadFile); + }, + 'writeFile': () => { + ObjectDefineProperty(fs, 'writeFile', this.#originals.writeFile); + ObjectDefineProperty(fs, 'writeFileSync', this.#originals.writeFileSync); + ObjectDefineProperty(fsPromises, 'writeFile', this.#originals.promisesWriteFile); + }, + 'appendFile': () => { + ObjectDefineProperty(fs, 'appendFile', this.#originals.appendFile); + ObjectDefineProperty(fs, 'appendFileSync', this.#originals.appendFileSync); + ObjectDefineProperty(fsPromises, 'appendFile', this.#originals.promisesAppendFile); + }, + 'stat': () => { + ObjectDefineProperty(fs, 'stat', this.#originals.stat); + ObjectDefineProperty(fs, 'statSync', this.#originals.statSync); + ObjectDefineProperty(fsPromises, 'stat', this.#originals.promisesStat); + }, + 'lstat': () => { + ObjectDefineProperty(fs, 'lstat', this.#originals.lstat); + ObjectDefineProperty(fs, 'lstatSync', this.#originals.lstatSync); + ObjectDefineProperty(fsPromises, 'lstat', this.#originals.promisesLstat); + }, + 'access': () => { + ObjectDefineProperty(fs, 'access', this.#originals.access); + ObjectDefineProperty(fs, 'accessSync', this.#originals.accessSync); + ObjectDefineProperty(fsPromises, 'access', this.#originals.promisesAccess); + }, + 'exists': () => { + ObjectDefineProperty(fs, 'exists', this.#originals.exists); + ObjectDefineProperty(fs, 'existsSync', this.#originals.existsSync); + }, + 'unlink': () => { + ObjectDefineProperty(fs, 'unlink', this.#originals.unlink); + ObjectDefineProperty(fs, 'unlinkSync', this.#originals.unlinkSync); + ObjectDefineProperty(fsPromises, 'unlink', this.#originals.promisesUnlink); + }, + 'mkdir': () => { + ObjectDefineProperty(fs, 'mkdir', this.#originals.mkdir); + ObjectDefineProperty(fs, 'mkdirSync', this.#originals.mkdirSync); + ObjectDefineProperty(fsPromises, 'mkdir', this.#originals.promisesMkdir); + }, + 'rmdir': () => { + ObjectDefineProperty(fs, 'rmdir', this.#originals.rmdir); + ObjectDefineProperty(fs, 'rmdirSync', this.#originals.rmdirSync); + ObjectDefineProperty(fsPromises, 'rmdir', this.#originals.promisesRmdir); + }, + 'readdir': () => { + ObjectDefineProperty(fs, 'readdir', this.#originals.readdir); + ObjectDefineProperty(fs, 'readdirSync', this.#originals.readdirSync); + ObjectDefineProperty(fsPromises, 'readdir', this.#originals.promisesReaddir); + }, + }, + }; + + const target = activate ? options.toFake : options.toReal; + ArrayPrototypeForEach(this.#apisInContext, (api) => target[api]()); + } + + /** + * @param {MockFileSystemOptions} [options] + */ + enable(options = kEmptyObject) { + emitExperimentalWarning('File system mocking'); + + if (this.#isEnabled) { + throw new ERR_INVALID_STATE('MockFileSystem is already enabled'); + } + + validateObject(options, 'options'); + const { + files = kEmptyObject, + isolate = false, + apis = SUPPORTED_APIS, + } = options; + validateObject(files, 'options.files'); + validateBoolean(isolate, 'options.isolate'); + validateStringArray(apis, 'options.apis'); + + // Validate that all specified APIs are supported. + ArrayPrototypeForEach(apis, (api) => { + if (!ArrayPrototypeIncludes(SUPPORTED_APIS, api)) { + throw new ERR_INVALID_ARG_VALUE( + 'options.apis', + api, + `option ${api} is not supported`, + ); + } + }); + + this.#apisInContext = apis; + this.#isolate = isolate; + this.#populateFiles(files); + this.#toggleEnableApis(true); + this.#isEnabled = true; + } + + reset() { + if (!this.#isEnabled) return; + + this.#toggleEnableApis(false); + this.#apisInContext = []; + this.#files.clear(); + this.#directories.clear(); + this.#isolate = false; + this.#isEnabled = false; + } + + [SymbolDispose]() { + this.reset(); + } +} + +module.exports = { MockFileSystem }; diff --git a/src/node_options.cc b/src/node_options.cc index cd8387068df5ac..f376f77833ffb6 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -905,6 +905,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { kDisallowedInEnvvar, false, OptionNamespaces::kTestRunnerNamespace); + AddOption("--experimental-test-fs-mocks", + "enable file system mocking in the test runner", + &EnvironmentOptions::test_runner_fs_mocks, + kDisallowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); AddOption("--experimental-test-snapshots", "", NoOp{}, diff --git a/src/node_options.h b/src/node_options.h index 5aee848802558b..6469192e415da1 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -197,6 +197,7 @@ class EnvironmentOptions : public Options { uint64_t test_coverage_functions = 0; uint64_t test_coverage_lines = 0; bool test_runner_module_mocks = false; + bool test_runner_fs_mocks = false; bool test_runner_update_snapshots = false; std::vector test_name_pattern; std::vector test_reporter; diff --git a/test/parallel/test-runner-mock-file-system-dirs.js b/test/parallel/test-runner-mock-file-system-dirs.js new file mode 100644 index 00000000000000..ab3dfd2b1fc6b6 --- /dev/null +++ b/test/parallel/test-runner-mock-file-system-dirs.js @@ -0,0 +1,240 @@ +// Flags: --experimental-test-fs-mocks +'use strict'; + +// Tests directory operations of the mock file system feature. + +const common = require('../common'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const fsPromises = require('node:fs/promises'); +const { it, describe } = require('node:test'); + +describe('MockFileSystem - directories', () => { + describe('mkdir', () => { + it('should create directory', (t) => { + t.mock.fs.enable({ files: {} }); + fs.mkdirSync('/virtual/newdir', { recursive: true }); + assert.ok(fs.statSync('/virtual/newdir').isDirectory()); + }); + + it('should create nested directories with recursive', (t) => { + t.mock.fs.enable({ files: {} }); + fs.mkdirSync('/virtual/a/b/c', { recursive: true }); + assert.ok(fs.existsSync('/virtual/a/b/c')); + }); + + it('should throw EEXIST if exists', (t) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + assert.throws(() => fs.mkdirSync('/virtual/dir'), { code: 'EEXIST' }); + }); + + it('should not throw with recursive if exists', (t) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + fs.mkdirSync('/virtual/dir', { recursive: true }); + }); + + it('should not create real directories in non-isolate mode (sync)', (t) => { + t.mock.fs.enable({ files: {} }); + const testPath = '/tmp/mock-fs-test-no-leak-' + Date.now(); + + // Should fail because parent doesn't exist in virtual fs + assert.throws(() => fs.mkdirSync(testPath), { code: 'ENOENT' }); + + // Verify nothing was created on real fs + t.mock.fs.reset(); + assert.strictEqual(fs.existsSync(testPath), false); + }); + + it('should not create real directories in non-isolate mode (callback)', (t, done) => { + t.mock.fs.enable({ files: {} }); + const testPath = '/tmp/mock-fs-test-no-leak-cb-' + Date.now(); + + fs.mkdir(testPath, common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + t.mock.fs.reset(); + assert.strictEqual(fs.existsSync(testPath), false); + done(); + })); + }); + + it('should not create real directories in non-isolate mode (promises)', async (t) => { + t.mock.fs.enable({ files: {} }); + const testPath = '/tmp/mock-fs-test-no-leak-promise-' + Date.now(); + + // Should fail because parent doesn't exist in virtual fs + await assert.rejects(fsPromises.mkdir(testPath), { code: 'ENOENT' }); + + // Verify nothing was created on real fs + t.mock.fs.reset(); + assert.strictEqual(fs.existsSync(testPath), false); + }); + + it('should restore original mkdirSync after reset', (t) => { + const originalMkdirSync = fs.mkdirSync; + t.mock.fs.enable({ files: {} }); + + // mkdirSync should be mocked now + assert.notStrictEqual(fs.mkdirSync, originalMkdirSync); + + t.mock.fs.reset(); + + // mkdirSync should be restored + assert.strictEqual(fs.mkdirSync, originalMkdirSync); + }); + }); + + describe('rmdir', () => { + it('should remove empty directory', (t) => { + t.mock.fs.enable({ files: {} }); + fs.mkdirSync('/virtual/empty', { recursive: true }); + fs.rmdirSync('/virtual/empty'); + assert.strictEqual(fs.existsSync('/virtual/empty'), false); + }); + + it('should throw ENOTEMPTY if not empty', (t) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + assert.throws(() => fs.rmdirSync('/virtual/dir'), { code: 'ENOTEMPTY' }); + }); + + it('should throw ENOTDIR for file', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'x' } }); + assert.throws(() => fs.rmdirSync('/virtual/file.txt'), { code: 'ENOTDIR' }); + }); + + it('should work with promises', async (t) => { + t.mock.fs.enable({ files: {} }); + fs.mkdirSync('/virtual/async-rm', { recursive: true }); + await fsPromises.rmdir('/virtual/async-rm'); + assert.strictEqual(fs.existsSync('/virtual/async-rm'), false); + }); + }); + + describe('readdir', () => { + it('should list directory contents', (t) => { + t.mock.fs.enable({ + files: { + '/virtual/dir/a.txt': 'a', + '/virtual/dir/b.txt': 'b', + '/virtual/dir/sub/c.txt': 'c', + }, + }); + const entries = fs.readdirSync('/virtual/dir').sort(); + assert.deepStrictEqual(entries, ['a.txt', 'b.txt', 'sub']); + }); + + it('should return dirents with withFileTypes', (t) => { + t.mock.fs.enable({ + files: { + '/virtual/dir/file.txt': 'x', + '/virtual/dir/sub/nested.txt': 'y', + }, + }); + const entries = fs.readdirSync('/virtual/dir', { withFileTypes: true }); + const file = entries.find((e) => e.name === 'file.txt'); + const dir = entries.find((e) => e.name === 'sub'); + assert.ok(file.isFile()); + assert.ok(dir.isDirectory()); + }); + + it('should return correct path and parentPath in dirents', (t) => { + t.mock.fs.enable({ + files: { + '/virtual/parent/file.txt': 'content', + '/virtual/parent/subdir/nested.txt': 'nested', + }, + }); + const entries = fs.readdirSync('/virtual/parent', { withFileTypes: true }); + + for (const entry of entries) { + // Both path and parentPath should point to the parent directory + assert.strictEqual(entry.path, '/virtual/parent'); + assert.strictEqual(entry.parentPath, '/virtual/parent'); + } + + // Verify the file entry + const file = entries.find((e) => e.name === 'file.txt'); + assert.ok(file); + assert.strictEqual(file.name, 'file.txt'); + + // Verify the directory entry + const subdir = entries.find((e) => e.name === 'subdir'); + assert.ok(subdir); + assert.strictEqual(subdir.name, 'subdir'); + }); + + it('dirents should have all expected methods', (t) => { + t.mock.fs.enable({ + files: { '/virtual/dir/file.txt': 'x' }, + }); + const entries = fs.readdirSync('/virtual/dir', { withFileTypes: true }); + const entry = entries[0]; + + // Verify all dirent methods exist and return correct types + assert.strictEqual(typeof entry.isFile, 'function'); + assert.strictEqual(typeof entry.isDirectory, 'function'); + assert.strictEqual(typeof entry.isBlockDevice, 'function'); + assert.strictEqual(typeof entry.isCharacterDevice, 'function'); + assert.strictEqual(typeof entry.isSymbolicLink, 'function'); + assert.strictEqual(typeof entry.isFIFO, 'function'); + assert.strictEqual(typeof entry.isSocket, 'function'); + + // For a file, these should return expected values + assert.strictEqual(entry.isFile(), true); + assert.strictEqual(entry.isDirectory(), false); + assert.strictEqual(entry.isBlockDevice(), false); + assert.strictEqual(entry.isCharacterDevice(), false); + assert.strictEqual(entry.isSymbolicLink(), false); + assert.strictEqual(entry.isFIFO(), false); + assert.strictEqual(entry.isSocket(), false); + }); + + it('should throw ENOTDIR for file', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'x' } }); + assert.throws(() => fs.readdirSync('/virtual/file.txt'), { code: 'ENOTDIR' }); + }); + + it('should return empty array for empty dir', (t) => { + t.mock.fs.enable({ files: {} }); + fs.mkdirSync('/virtual/empty', { recursive: true }); + assert.deepStrictEqual(fs.readdirSync('/virtual/empty'), []); + }); + + it('should work with callback', (t, done) => { + t.mock.fs.enable({ files: { '/virtual/cb/file.txt': 'x' } }); + fs.readdir('/virtual/cb', common.mustSucceed((entries) => { + assert.deepStrictEqual(entries, ['file.txt']); + done(); + })); + }); + + it('should work with callback and withFileTypes', (t, done) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + fs.readdir('/virtual/dir', { withFileTypes: true }, common.mustSucceed((entries) => { + assert.ok(entries[0].isFile()); + done(); + })); + }); + }); + + describe('auto-created parent directories', () => { + it('should create all parent directories', (t) => { + t.mock.fs.enable({ files: { '/a/b/c/d/file.txt': 'deep' } }); + assert.ok(fs.existsSync('/a')); + assert.ok(fs.existsSync('/a/b')); + assert.ok(fs.existsSync('/a/b/c')); + assert.ok(fs.existsSync('/a/b/c/d')); + assert.ok(fs.statSync('/a/b/c').isDirectory()); + }); + }); + + describe('root path', () => { + it('should handle root path readdir', { skip: common.isWindows }, (t) => { + t.mock.fs.enable({ + files: { '/virtual/file.txt': 'content' }, + isolate: true, + }); + const entries = fs.readdirSync('/'); + assert.ok(entries.includes('virtual')); + }); + }); +}); diff --git a/test/parallel/test-runner-mock-file-system-no-flag.js b/test/parallel/test-runner-mock-file-system-no-flag.js new file mode 100644 index 00000000000000..309470a011fc2d --- /dev/null +++ b/test/parallel/test-runner-mock-file-system-no-flag.js @@ -0,0 +1,14 @@ +'use strict'; + +// Tests that mock.fs throws when --experimental-test-fs-mocks is not enabled. + +require('../common'); +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('mock.fs throws without --experimental-test-fs-mocks flag', (t) => { + assert.throws(() => t.mock.fs, { + code: 'ERR_INVALID_STATE', + message: /File system mocking is not enabled/, + }); +}); diff --git a/test/parallel/test-runner-mock-file-system.js b/test/parallel/test-runner-mock-file-system.js new file mode 100644 index 00000000000000..5e2e877d9e5043 --- /dev/null +++ b/test/parallel/test-runner-mock-file-system.js @@ -0,0 +1,614 @@ +// Flags: --experimental-test-fs-mocks +'use strict'; + +// Tests core file operations of the mock file system feature. + +const common = require('../common'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const fsPromises = require('node:fs/promises'); +const { it, describe, mock } = require('node:test'); + +describe('MockFileSystem', () => { + describe('enable/reset', () => { + it('should throw if enabling twice', (t) => { + t.mock.fs.enable({ files: {} }); + assert.throws(() => t.mock.fs.enable({ files: {} }), { + code: 'ERR_INVALID_STATE', + }); + }); + + it('should not throw if reset without enable', (t) => { + t.mock.fs.reset(); + }); + + it('should restore fs after reset', (t) => { + t.mock.fs.enable({ + files: { '/virtual/test.txt': 'content' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('/virtual/test.txt', 'utf8'), 'content'); + t.mock.fs.reset(); + assert.throws(() => fs.readFileSync('/virtual/test.txt'), { code: 'ENOENT' }); + }); + + it('should support multiple enable/reset cycles', (t) => { + // First cycle + t.mock.fs.enable({ + files: { '/virtual/first.txt': 'first content' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('/virtual/first.txt', 'utf8'), 'first content'); + t.mock.fs.reset(); + + // Second cycle with different files + t.mock.fs.enable({ + files: { '/virtual/second.txt': 'second content' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('/virtual/second.txt', 'utf8'), 'second content'); + // First file should not exist + assert.throws(() => fs.readFileSync('/virtual/first.txt'), { code: 'ENOENT' }); + t.mock.fs.reset(); + + // Third cycle + t.mock.fs.enable({ + files: { '/virtual/third.txt': 'third content' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('/virtual/third.txt', 'utf8'), 'third content'); + }); + }); + + describe('validation', () => { + it('should throw for invalid files option', (t) => { + assert.throws(() => t.mock.fs.enable({ files: 'invalid' }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('should throw for invalid isolate option', (t) => { + assert.throws(() => t.mock.fs.enable({ files: {}, isolate: 'true' }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('should throw for invalid file content', (t) => { + assert.throws(() => t.mock.fs.enable({ files: { '/test': 123 } }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('should throw for unsupported api option', (t) => { + assert.throws(() => t.mock.fs.enable({ files: {}, apis: ['unsupported'] }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); + + it('should prevent prototype pollution in paths', (t) => { + for (const name of ['__proto__', 'constructor', 'prototype']) { + assert.throws(() => t.mock.fs.enable({ files: { [`/path/${name}/file`]: 'x' } }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + t.mock.fs.reset(); + } + }); + + it('should prevent prototype pollution via modified prototype', (t) => { + const maliciousFiles = { __proto__: null }; + Object.setPrototypeOf(maliciousFiles, { polluted: true }); + assert.throws(() => t.mock.fs.enable({ files: maliciousFiles }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + assert.strictEqual(Object.prototype.polluted, undefined); + }); + }); + + describe('readFile', () => { + it('should read virtual file with string content', (t) => { + t.mock.fs.enable({ files: { '/virtual/test.txt': 'Hello' } }); + assert.strictEqual(fs.readFileSync('/virtual/test.txt', 'utf8'), 'Hello'); + }); + + it('should read virtual file with Buffer content', (t) => { + const buf = Buffer.from('binary'); + t.mock.fs.enable({ files: { '/virtual/bin': buf } }); + assert.deepStrictEqual(fs.readFileSync('/virtual/bin'), buf); + }); + + it('should work with callback', (t, done) => { + t.mock.fs.enable({ files: { '/virtual/cb.txt': 'callback' } }); + fs.readFile('/virtual/cb.txt', 'utf8', common.mustSucceed((data) => { + assert.strictEqual(data, 'callback'); + done(); + })); + }); + + it('should work with promises', async (t) => { + t.mock.fs.enable({ files: { '/virtual/promise.txt': 'async' } }); + assert.strictEqual(await fsPromises.readFile('/virtual/promise.txt', 'utf8'), 'async'); + }); + + it('should throw EISDIR for directory', (t) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + assert.throws(() => fs.readFileSync('/virtual/dir'), { code: 'EISDIR' }); + }); + + it('should fall back to real fs in non-isolate mode', (t) => { + t.mock.fs.enable({ files: { '/virtual/mock.txt': 'mocked' } }); + assert.ok(fs.readFileSync(__filename, 'utf8').includes('MockFileSystem')); + }); + + it('should throw ENOENT in isolate mode', (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + assert.throws(() => fs.readFileSync(__filename), { code: 'ENOENT' }); + }); + + it('should pass ENOENT to callback in isolate mode', (t, done) => { + t.mock.fs.enable({ files: {}, isolate: true }); + fs.readFile('/nonexistent/file.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + done(); + })); + }); + }); + + describe('writeFile', () => { + it('should write to virtual fs', (t) => { + t.mock.fs.enable({ files: {} }); + fs.writeFileSync('/virtual/new.txt', 'new content'); + assert.strictEqual(fs.readFileSync('/virtual/new.txt', 'utf8'), 'new content'); + }); + + it('should overwrite existing file', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'old' } }); + fs.writeFileSync('/virtual/file.txt', 'new'); + assert.strictEqual(fs.readFileSync('/virtual/file.txt', 'utf8'), 'new'); + }); + + it('should work with promises', async (t) => { + t.mock.fs.enable({ files: {} }); + await fsPromises.writeFile('/virtual/async.txt', 'async'); + assert.strictEqual(fs.readFileSync('/virtual/async.txt', 'utf8'), 'async'); + }); + + it('should always write to virtual fs even for real paths in non-isolate mode', (t) => { + // In non-isolate mode, writes should still go to virtual fs + // to prevent accidental writes to the real file system during tests + t.mock.fs.enable({ files: {} }); + const testPath = '/tmp/mock-fs-test-write.txt'; + + // Write to a path that could be real + fs.writeFileSync(testPath, 'virtual content'); + + // Should be readable from mock + assert.strictEqual(fs.readFileSync(testPath, 'utf8'), 'virtual content'); + + // After reset, the file should not exist (it was only in virtual fs) + t.mock.fs.reset(); + // If the file existed before, this would read real content + // If it didn't exist, this would throw ENOENT + // Either way, it should NOT contain 'virtual content' + try { + const content = fs.readFileSync(testPath, 'utf8'); + // If we can read the file, it should NOT contain 'virtual content' + // because writes should have gone to virtual fs, not real fs + assert.ok( + content !== 'virtual content', + 'Write should not have gone to real filesystem' + ); + } catch (err) { + assert.strictEqual(err.code, 'ENOENT'); + } + }); + + it('should work with callback', (t, done) => { + t.mock.fs.enable({ files: {} }); + fs.writeFile('/virtual/cb.txt', 'callback content', common.mustSucceed(() => { + assert.strictEqual(fs.readFileSync('/virtual/cb.txt', 'utf8'), 'callback content'); + done(); + })); + }); + }); + + describe('appendFile', () => { + it('should append to existing file', (t) => { + t.mock.fs.enable({ files: { '/virtual/log.txt': 'line1\n' } }); + fs.appendFileSync('/virtual/log.txt', 'line2\n'); + assert.strictEqual(fs.readFileSync('/virtual/log.txt', 'utf8'), 'line1\nline2\n'); + }); + + it('should create file if not exists', (t) => { + t.mock.fs.enable({ files: {} }); + fs.appendFileSync('/virtual/new.txt', 'first'); + assert.strictEqual(fs.readFileSync('/virtual/new.txt', 'utf8'), 'first'); + }); + }); + + describe('stat/lstat', () => { + it('should return stats for file', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'content' } }); + const stats = fs.statSync('/virtual/file.txt'); + assert.ok(stats.isFile()); + assert.strictEqual(stats.size, 7); + }); + + it('should return stats for directory', (t) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + assert.ok(fs.statSync('/virtual/dir').isDirectory()); + }); + + it('should return undefined with throwIfNoEntry: false', (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + assert.strictEqual(fs.statSync('/none', { throwIfNoEntry: false }), undefined); + }); + + it('lstat should behave like stat (no symlink support)', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'x' } }); + const stat = fs.statSync('/virtual/file.txt'); + const lstat = fs.lstatSync('/virtual/file.txt'); + assert.strictEqual(stat.isFile(), lstat.isFile()); + assert.strictEqual(lstat.isSymbolicLink(), false); + }); + + it('should return stats object that is instanceof fs.Stats', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'content' } }); + const stats = fs.statSync('/virtual/file.txt'); + + // Mock stats should pass instanceof check + assert.strictEqual(stats instanceof fs.Stats, true); + + // And should still have all the expected methods + assert.strictEqual(typeof stats.isFile, 'function'); + assert.strictEqual(typeof stats.isDirectory, 'function'); + assert.strictEqual(typeof stats.isBlockDevice, 'function'); + assert.strictEqual(typeof stats.isCharacterDevice, 'function'); + assert.strictEqual(typeof stats.isSymbolicLink, 'function'); + assert.strictEqual(typeof stats.isFIFO, 'function'); + assert.strictEqual(typeof stats.isSocket, 'function'); + + // And expected properties + assert.strictEqual(typeof stats.dev, 'number'); + assert.strictEqual(typeof stats.ino, 'number'); + assert.strictEqual(typeof stats.mode, 'number'); + assert.strictEqual(typeof stats.size, 'number'); + assert.ok(stats.atime instanceof Date); + assert.ok(stats.mtime instanceof Date); + }); + }); + + describe('exists/access', () => { + it('existsSync should return true for virtual file', (t) => { + t.mock.fs.enable({ files: { '/virtual/exists.txt': 'x' } }); + assert.strictEqual(fs.existsSync('/virtual/exists.txt'), true); + assert.strictEqual(fs.existsSync('/virtual'), true); + }); + + it('existsSync should return false in isolate mode', (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + assert.strictEqual(fs.existsSync('/nonexistent'), false); + }); + + it('exists callback should return true for virtual file', (t, done) => { + t.mock.fs.enable({ files: { '/virtual/exists.txt': 'x' } }); + fs.exists('/virtual/exists.txt', common.mustCall((exists) => { + assert.strictEqual(exists, true); + done(); + })); + }); + + it('exists callback should return true for virtual directory', (t, done) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + fs.exists('/virtual/dir', common.mustCall((exists) => { + assert.strictEqual(exists, true); + done(); + })); + }); + + it('exists callback should return false in isolate mode', (t, done) => { + t.mock.fs.enable({ files: {}, isolate: true }); + fs.exists('/nonexistent', common.mustCall((exists) => { + assert.strictEqual(exists, false); + done(); + })); + }); + + it('accessSync should not throw for virtual file', (t) => { + t.mock.fs.enable({ files: { '/virtual/access.txt': 'x' } }); + fs.accessSync('/virtual/access.txt'); + }); + + it('accessSync should throw ENOENT in isolate mode', (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + assert.throws(() => fs.accessSync('/nonexistent'), { code: 'ENOENT' }); + }); + + it('should pass ENOENT to access callback in isolate mode', (t, done) => { + t.mock.fs.enable({ files: {}, isolate: true }); + fs.access('/nonexistent', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + done(); + })); + }); + }); + + describe('unlink', () => { + it('should delete virtual file', (t) => { + t.mock.fs.enable({ files: { '/virtual/delete.txt': 'x' } }); + fs.unlinkSync('/virtual/delete.txt'); + assert.strictEqual(fs.existsSync('/virtual/delete.txt'), false); + }); + + it('should throw EISDIR for directory', (t) => { + t.mock.fs.enable({ files: { '/virtual/dir/file.txt': 'x' } }); + assert.throws(() => fs.unlinkSync('/virtual/dir'), { code: 'EISDIR' }); + }); + + it('should pass ENOENT to unlink callback in isolate mode', (t, done) => { + t.mock.fs.enable({ files: {}, isolate: true }); + fs.unlink('/nonexistent/file.txt', common.mustCall((err) => { + assert.strictEqual(err.code, 'ENOENT'); + done(); + })); + }); + }); + + describe('apis option', () => { + it('should only mock specified apis', (t) => { + t.mock.fs.enable({ + files: { '/virtual/file.txt': 'content' }, + apis: ['readFile'], + }); + // readFile should be mocked + assert.strictEqual(fs.readFileSync('/virtual/file.txt', 'utf8'), 'content'); + // existsSync should NOT be mocked (not in apis list) + assert.strictEqual(fs.existsSync('/virtual/file.txt'), false); + }); + + it('should mock multiple specified apis', (t) => { + t.mock.fs.enable({ + files: { '/virtual/file.txt': 'content' }, + apis: ['readFile', 'writeFile', 'stat', 'exists'], + }); + + // All specified apis should work + assert.strictEqual(fs.readFileSync('/virtual/file.txt', 'utf8'), 'content'); + assert.strictEqual(fs.existsSync('/virtual/file.txt'), true); + assert.ok(fs.statSync('/virtual/file.txt').isFile()); + + fs.writeFileSync('/virtual/new.txt', 'new content'); + assert.strictEqual(fs.readFileSync('/virtual/new.txt', 'utf8'), 'new content'); + + // Unlink is NOT in the apis list, so it should use real fs + // Since /virtual/new.txt doesn't exist on real fs, this would fail differently + // We just verify it doesn't delete from our virtual fs by checking the file still exists + // after trying to unlink (which will either fail or do nothing to virtual fs) + try { + fs.unlinkSync('/virtual/new.txt'); + } catch { + // Expected - real fs doesn't have this file + } + // File should still exist in virtual fs since unlink wasn't mocked + assert.strictEqual(fs.readFileSync('/virtual/new.txt', 'utf8'), 'new content'); + }); + + it('should work with only directory-related apis', (t) => { + t.mock.fs.enable({ + files: {}, + apis: ['mkdir', 'readdir', 'stat', 'exists'], + }); + + fs.mkdirSync('/virtual/dir', { recursive: true }); + assert.ok(fs.existsSync('/virtual/dir')); + assert.ok(fs.statSync('/virtual/dir').isDirectory()); + assert.deepStrictEqual(fs.readdirSync('/virtual/dir'), []); + }); + }); + + describe('path handling', () => { + it('should handle file:// URLs', (t) => { + t.mock.fs.enable({ files: { '/virtual/url.txt': 'url content' } }); + const url = new URL('file:///virtual/url.txt'); + assert.strictEqual(fs.readFileSync(url, 'utf8'), 'url content'); + }); + + it('should throw for non-file:// URLs', (t) => { + t.mock.fs.enable({ files: { '/virtual/file.txt': 'content' } }); + assert.throws(() => fs.readFileSync(new URL('http://example.com/file.txt')), { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); + + it('should handle Buffer paths', (t) => { + t.mock.fs.enable({ files: { '/virtual/buf.txt': 'buffer' } }); + assert.strictEqual(fs.readFileSync(Buffer.from('/virtual/buf.txt'), 'utf8'), 'buffer'); + }); + + it('should handle Windows-style paths with backslashes', { skip: !common.isWindows }, (t) => { + t.mock.fs.enable({ + files: { 'C:\\virtual\\test.txt': 'windows content' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('C:\\virtual\\test.txt', 'utf8'), 'windows content'); + assert.ok(fs.existsSync('C:\\virtual')); + }); + + it('should handle Windows-style paths with forward slashes', { skip: !common.isWindows }, (t) => { + t.mock.fs.enable({ + files: { 'C:/virtual/forward.txt': 'forward slash content' }, + isolate: true, + }); + // Node.js normalizes forward slashes to backslashes on Windows + assert.strictEqual(fs.readFileSync('C:/virtual/forward.txt', 'utf8'), 'forward slash content'); + assert.strictEqual(fs.readFileSync('C:\\virtual\\forward.txt', 'utf8'), 'forward slash content'); + }); + + it('should handle Windows file:// URLs', { skip: !common.isWindows }, (t) => { + t.mock.fs.enable({ + files: { 'C:\\virtual\\url.txt': 'windows url content' }, + isolate: true, + }); + const url = new URL('file:///C:/virtual/url.txt'); + assert.strictEqual(fs.readFileSync(url, 'utf8'), 'windows url content'); + }); + }); + + describe('Symbol.dispose', () => { + it('should support using syntax for automatic cleanup', (t) => { + { + using mockFs = t.mock.fs; + mockFs.enable({ + files: { '/virtual/dispose.txt': 'content' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('/virtual/dispose.txt', 'utf8'), 'content'); + } + // After scope ends, mock should be reset + assert.throws(() => fs.readFileSync('/virtual/dispose.txt'), { code: 'ENOENT' }); + }); + }); + + describe('auto-reset', () => { + it('first test creates a virtual file', (t) => { + t.mock.fs.enable({ + files: { '/virtual/auto-reset-test.txt': 'first test' }, + isolate: true, + }); + assert.strictEqual(fs.readFileSync('/virtual/auto-reset-test.txt', 'utf8'), 'first test'); + }); + + it('second test should not see file from first test', (t) => { + // The mock from the previous test should have been automatically reset + // If we enable mock with isolate, the file from previous test should not exist + t.mock.fs.enable({ + files: {}, + isolate: true, + }); + assert.throws(() => fs.readFileSync('/virtual/auto-reset-test.txt'), { code: 'ENOENT' }); + }); + }); + + describe('readdir recursive option', () => { + it('should throw error when recursive option is used', (t) => { + t.mock.fs.enable({ + files: { '/virtual/dir/file.txt': 'content' }, + }); + assert.throws( + () => fs.readdirSync('/virtual/dir', { recursive: true }), + { code: 'ERR_INVALID_ARG_VALUE' } + ); + }); + }); + + describe('promise rejections in isolate mode', () => { + it('should reject readFile with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.readFile('/nonexistent/file.txt'), + { code: 'ENOENT' } + ); + }); + + it('should reject stat with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.stat('/nonexistent/file.txt'), + { code: 'ENOENT' } + ); + }); + + it('should reject lstat with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.lstat('/nonexistent/file.txt'), + { code: 'ENOENT' } + ); + }); + + it('should reject access with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.access('/nonexistent/file.txt'), + { code: 'ENOENT' } + ); + }); + + it('should reject unlink with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.unlink('/nonexistent/file.txt'), + { code: 'ENOENT' } + ); + }); + + it('should reject readdir with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.readdir('/nonexistent/dir'), + { code: 'ENOENT' } + ); + }); + + it('should reject rmdir with ENOENT', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.rmdir('/nonexistent/dir'), + { code: 'ENOENT' } + ); + }); + + it('should reject mkdir with ENOENT when parent missing', async (t) => { + t.mock.fs.enable({ files: {}, isolate: true }); + await assert.rejects( + fsPromises.mkdir('/nonexistent/parent/dir'), + { code: 'ENOENT' } + ); + }); + }); + + describe('top-level mock.fs export', () => { + it('should be accessible from top-level mock export', () => { + // The mock.fs should be accessible from the top-level export + assert.ok(mock.fs); + assert.strictEqual(typeof mock.fs.enable, 'function'); + assert.strictEqual(typeof mock.fs.reset, 'function'); + }); + + it('should work with top-level mock.fs', () => { + mock.fs.enable({ + files: { '/virtual/top-level.txt': 'top level content' }, + isolate: true, + }); + + try { + assert.strictEqual( + fs.readFileSync('/virtual/top-level.txt', 'utf8'), + 'top level content' + ); + } finally { + // Important: must reset manually since not using test context + mock.fs.reset(); + } + }); + + it('should require manual reset when using top-level mock', () => { + mock.fs.enable({ + files: { '/virtual/manual-reset.txt': 'content' }, + isolate: true, + }); + + assert.strictEqual( + fs.readFileSync('/virtual/manual-reset.txt', 'utf8'), + 'content' + ); + + mock.fs.reset(); + + // After reset, file should not exist + assert.throws( + () => fs.readFileSync('/virtual/manual-reset.txt'), + { code: 'ENOENT' } + ); + }); + }); +}); From c12a9b7115f8feb5acb05d4aabfa27c59ff6f274 Mon Sep 17 00:00:00 2001 From: Diogo de Souza Date: Thu, 22 Jan 2026 00:57:17 -0300 Subject: [PATCH 2/7] doc: document mock file system for test runner --- doc/api/cli.md | 18 +++ doc/api/test.md | 335 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) diff --git a/doc/api/cli.md b/doc/api/cli.md index 7ee7c4860260c0..d80ca8e7670cd0 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1294,6 +1294,23 @@ Enable module mocking in the test runner. This feature requires `--allow-worker` if used with the [Permission Model][]. +### `--experimental-test-fs-mocks` + + + +> Stability: 1.0 - Early development + +Enable file system mocking in the test runner. + +This feature allows tests to mock file system operations without actually +reading from or writing to the disk. By default, virtual files take precedence +but real file system operations are still allowed for paths not defined in +the virtual file system. Use the `isolate: true` option to completely isolate +tests from the real file system. See the documentation on +[mocking the file system][] for more details. + ### `--experimental-transform-types` + +> Stability: 1.0 - Early development + +Mocking the file system is a technique commonly used in software testing to +simulate file operations without actually writing to or reading from the disk. +This allows for safer, faster, and more predictable tests when working with +file system operations. + +Refer to the [`MockFileSystem`][] class for a full list of methods and features. + +**Note:** This feature requires the `--experimental-test-fs-mocks` flag. + +The example below shows how to mock file system operations. Using +`.enable({ files: {...} })` it will mock the file system methods in the +[node:fs](./fs.md) and [node:fs/promises](./fs.md#promises-api) modules. + +```mjs +import assert from 'node:assert'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +test('mocks file system operations', (context) => { + // Enable file system mocking with virtual files + context.mock.fs.enable({ + files: { + '/virtual/test.txt': 'Hello, World!', + '/virtual/data.json': '{"key": "value"}', + }, + }); + + // Read virtual files + const content = fs.readFileSync('/virtual/test.txt', 'utf8'); + assert.strictEqual(content, 'Hello, World!'); + + // Write to virtual file system + fs.writeFileSync('/virtual/new.txt', 'New content'); + assert.strictEqual(fs.readFileSync('/virtual/new.txt', 'utf8'), 'New content'); + + // Check if virtual file exists + assert.strictEqual(fs.existsSync('/virtual/test.txt'), true); +}); +``` + +```cjs +const assert = require('node:assert'); +const fs = require('node:fs'); +const { test } = require('node:test'); + +test('mocks file system operations', (context) => { + // Enable file system mocking with virtual files + context.mock.fs.enable({ + files: { + '/virtual/test.txt': 'Hello, World!', + '/virtual/data.json': '{"key": "value"}', + }, + }); + + // Read virtual files + const content = fs.readFileSync('/virtual/test.txt', 'utf8'); + assert.strictEqual(content, 'Hello, World!'); + + // Write to virtual file system + fs.writeFileSync('/virtual/new.txt', 'New content'); + assert.strictEqual(fs.readFileSync('/virtual/new.txt', 'utf8'), 'New content'); + + // Check if virtual file exists + assert.strictEqual(fs.existsSync('/virtual/test.txt'), true); +}); +``` + +By default, the mock file system allows access to both virtual and real files, +with virtual files taking precedence. When file system mocking is enabled, +**all write operations go to the virtual file system**, regardless of whether +the path exists in the real file system. This prevents tests from accidentally +modifying the real file system. + +You can enable isolation mode to completely isolate tests from the real file +system for read operations as well: + +```mjs +import assert from 'node:assert'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +test('complete file system isolation', (context) => { + context.mock.fs.enable({ + files: { + '/virtual/only.txt': 'Only this file exists', + }, + isolate: true, // Enable full isolation mode + }); + + // Virtual file works + assert.strictEqual(fs.readFileSync('/virtual/only.txt', 'utf8'), 'Only this file exists'); + + // Real files are not accessible + assert.throws(() => { + fs.readFileSync('/etc/passwd'); + }, { + code: 'ENOENT', + }); +}); +``` + +```cjs +const assert = require('node:assert'); +const fs = require('node:fs'); +const { test } = require('node:test'); + +test('complete file system isolation', (context) => { + context.mock.fs.enable({ + files: { + '/virtual/only.txt': 'Only this file exists', + }, + isolate: true, // Enable full isolation mode + }); + + // Virtual file works + assert.strictEqual(fs.readFileSync('/virtual/only.txt', 'utf8'), 'Only this file exists'); + + // Real files are not accessible + assert.throws(() => { + fs.readFileSync('/etc/passwd'); + }, { + code: 'ENOENT', + }); +}); +``` + +#### Windows path handling + +On Windows, use appropriate path separators or forward slashes: + +```js +test('Windows path handling', (t) => { + t.mock.fs.enable({ + files: { + 'C:/virtual/test.txt': 'Hello, World!', + // or using backslashes + // 'C:\\virtual\\test.txt': 'Hello, World!', + }, + }); +}); +``` + ## Snapshot testing + +> Stability: 1.0 - Early development + +Mocking the file system allows tests to simulate file operations without +actually reading from or writing to the disk. This makes tests safer, faster, +and more predictable. + +The [`MockTracker`][] provides a top-level `fs` export +which is a `MockFileSystem` instance. + +**Note:** This class requires the `--experimental-test-fs-mocks` flag. + +### `fs.enable([options])` + + + +Enables file system mocking. + +* `options` {Object} Optional configuration options for enabling file system + mocking. The following properties are supported: + * `files` {Object} An object mapping file paths to their content. Content + can be a string, `Buffer`, or `Uint8Array`. Strings are automatically + converted to `Buffer` using UTF-8 encoding. **Default:** `{}`. + * `isolate` {boolean} If `true`, only virtual files are accessible and + any access to paths not in `files` will throw `ENOENT`. If `false` + (the default), virtual files take precedence but real file system + operations are still allowed for other paths. **Note:** When mocking is + enabled, write operations always go to the virtual file system regardless + of this setting. **Default:** `false`. + * `apis` {Array} An optional array specifying which fs API families to mock. + Each value mocks the synchronous, callback, and promise versions of that + API (e.g., `'readFile'` mocks `fs.readFileSync()`, `fs.readFile()`, and + `fsPromises.readFile()`). The supported values are `'readFile'`, + `'writeFile'`, `'appendFile'`, `'stat'`, `'lstat'`, `'access'`, `'exists'`, + `'unlink'`, `'mkdir'`, `'rmdir'`, and `'readdir'`. **Default:** all + supported APIs. + +**Note:** When file system mocking is enabled, the mock automatically +creates parent directories for all virtual files. + +Example usage: + +```mjs +import { mock } from 'node:test'; +import { Buffer } from 'node:buffer'; + +mock.fs.enable({ + files: { + '/path/to/file.txt': 'file content', + '/path/to/binary.bin': Buffer.from([0x00, 0x01, 0x02]), + }, +}); +``` + +```cjs +const { mock } = require('node:test'); + +mock.fs.enable({ + files: { + '/path/to/file.txt': 'file content', + '/path/to/binary.bin': Buffer.from([0x00, 0x01, 0x02]), + }, +}); +``` + +### `fs.reset()` + + + +Restores the original file system functions and clears all virtual files. +This function is automatically called when a test using the mock file system +completes. + +### Supported `fs` methods + +The following methods are intercepted by the mock file system: + +**Synchronous methods:** + +* `fs.readFileSync()` +* `fs.writeFileSync()` +* `fs.appendFileSync()` +* `fs.statSync()` +* `fs.lstatSync()` +* `fs.existsSync()` +* `fs.accessSync()` +* `fs.unlinkSync()` +* `fs.mkdirSync()` +* `fs.rmdirSync()` +* `fs.readdirSync()` + +**Callback methods:** + +* `fs.readFile()` +* `fs.writeFile()` +* `fs.appendFile()` +* `fs.stat()` +* `fs.lstat()` +* `fs.exists()` +* `fs.access()` +* `fs.unlink()` +* `fs.mkdir()` +* `fs.rmdir()` +* `fs.readdir()` + +**Promise methods (`fs/promises`):** + +* `fsPromises.readFile()` +* `fsPromises.writeFile()` +* `fsPromises.appendFile()` +* `fsPromises.stat()` +* `fsPromises.lstat()` +* `fsPromises.access()` +* `fsPromises.unlink()` +* `fsPromises.mkdir()` +* `fsPromises.rmdir()` +* `fsPromises.readdir()` + +### Limitations + +The mock file system has the following limitations: + +* **Symbolic links are not supported.** `lstat()` behaves identically to + `stat()`, and `isSymbolicLink()` always returns `false`. +* **Dirent objects are not `fs.Dirent` instances.** The objects returned by + `readdir({ withFileTypes: true })` have the same properties and methods as + `fs.Dirent`, but `dirent instanceof fs.Dirent` will return `false`. +* **The following methods are not mocked:** + * `fs.copyFile()` / `fs.copyFileSync()` + * `fs.rename()` / `fs.renameSync()` + * `fs.chmod()` / `fs.chmodSync()` / `fs.chown()` / `fs.chownSync()` + * `fs.realpath()` / `fs.realpathSync()` + * `fs.watch()` / `fs.watchFile()` / `fs.unwatchFile()` + * `fs.open()` / `fs.openSync()` and file descriptor operations + * `fs.createReadStream()` / `fs.createWriteStream()` +* **File permissions are not enforced.** All virtual files are created with + mode `0o644` and permission checks are not performed. +* **File descriptors are not supported.** Operations that require file + descriptors will not work with virtual files. +* **`mkdir()` return value.** When called with `{ recursive: true }`, + `mkdir()` returns the first directory path created (matching real `fs` + behavior). Without `recursive`, it returns `undefined`. +* **Recursive `readdir()` is not supported.** Calling `readdir()` with + `{ recursive: true }` will throw `ERR_INVALID_ARG_VALUE`. + +### Stats object + +Mock stats objects have the following properties with default values: + +| Property | Type | Default | +| ------------------------------------------- | -------- | -------------------------------- | +| `dev` | `number` | `0` | +| `ino` | `number` | `0` | +| `mode` | `number` | File: `0o100644`, Dir: `0o40644` | +| `nlink` | `number` | `1` | +| `uid` | `number` | `0` | +| `gid` | `number` | `0` | +| `rdev` | `number` | `0` | +| `size` | `number` | Content length | +| `blksize` | `number` | `4096` | +| `blocks` | `number` | `ceil(size / 512)` | +| `atime`/`mtime`/`ctime`/`birthtime` | `Date` | Creation time | +| `atimeMs`/`mtimeMs`/`ctimeMs`/`birthtimeMs` | `number` | Creation time (ms) | + +The stats object includes the following methods that return the expected +values for virtual files and directories: + +* `isFile()` +* `isDirectory()` +* `isBlockDevice()` - always returns `false` +* `isCharacterDevice()` - always returns `false` +* `isSymbolicLink()` - always returns `false` +* `isFIFO()` - always returns `false` +* `isSocket()` - always returns `false` + ## Class: `TestsStream` + +> Stability: 1.0 - Early development + +Enable file system mocking in the test runner. + +This feature allows tests to mock file system operations without actually +reading from or writing to the disk. By default, virtual files take precedence +but real file system operations are still allowed for paths not defined in +the virtual file system. Use the `isolate: true` option to completely isolate +tests from the real file system. See the documentation on +[mocking the file system][] for more details. + ### `--experimental-test-module-mocks` - -> Stability: 1.0 - Early development - -Enable file system mocking in the test runner. - -This feature allows tests to mock file system operations without actually -reading from or writing to the disk. By default, virtual files take precedence -but real file system operations are still allowed for paths not defined in -the virtual file system. Use the `isolate: true` option to completely isolate -tests from the real file system. See the documentation on -[mocking the file system][] for more details. - ### `--experimental-transform-types`