diff --git a/lib/bypass.js b/lib/bypass.js new file mode 100755 index 00000000..770e81bd --- /dev/null +++ b/lib/bypass.js @@ -0,0 +1,55 @@ +const realBinding = process.binding('fs'); +let storedBinding; + +/** + * Perform action, bypassing mock FS + * @example + * // This file exists on the real FS, not on the mocked FS + * const filePath = '/path/file.json'; + * const data = mock.bypass(() => fs.readFileSync(filePath, 'utf-8')); + */ +exports = module.exports = function bypass(fn) { + if (typeof fn !== 'function') { + throw new Error(`Must provide a function to perform for mock.bypass()`); + } + + exports.disable(); + + try { + // Perform action + const res = fn(); + + // Handle promise return + if (typeof res.then === 'function') { + res.then(exports.enable); + res.catch(exports.enable); + } else { + exports.enable(); + } + + return res; + } catch (e) { + exports.enable(); + throw e; + } +}; + +/** + * Temporarily disable Mocked FS + */ +exports.disable = () => { + if (realBinding._mockedBinding) { + storedBinding = realBinding._mockedBinding; + delete realBinding._mockedBinding; + } +}; + +/** + * Enables Mocked FS after being disabled by mock.disable() + */ +exports.enable = () => { + if (storedBinding) { + realBinding._mockedBinding = storedBinding; + storedBinding = undefined; + } +}; diff --git a/lib/filesystem.js b/lib/filesystem.js index a860a7f8..268a8b2e 100644 --- a/lib/filesystem.js +++ b/lib/filesystem.js @@ -112,6 +112,8 @@ FileSystem.prototype.getItem = function(filepath) { if (item) { if (item instanceof Directory && name !== currentParts[i]) { // make sure traversal is allowed + // This fails for Windows directories which do not have execute permission, by default. It may be a good idea + // to change this logic to windows-friendly. See notes in mock.createDirectoryInfoFromPaths() if (!item.canExecute()) { throw new FSError('EACCES', filepath); } diff --git a/lib/index.js b/lib/index.js index 04ac665a..0f78d734 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,6 +5,8 @@ const FSError = require('./error'); const FileSystem = require('./filesystem'); const realBinding = process.binding('fs'); const path = require('path'); +const loader = require('./loader'); +const bypass = require('./bypass'); const fs = require('fs'); const toNamespacedPath = FileSystem.toNamespacedPath; @@ -183,3 +185,17 @@ exports.directory = FileSystem.directory; * Create a symbolic link factory. */ exports.symlink = FileSystem.symlink; + +/** + * Automatically maps specified paths (for use with `mock()`) + */ +exports.load = loader.load; + +/** + * Perform action, bypassing mock FS + * @example + * // This file exists on the real FS, not on the mocked FS + * const filePath = '/path/file.json'; + * const data = mock.bypass(() => fs.readFileSync(filePath, 'utf-8')); + */ +exports.bypass = bypass; diff --git a/lib/item.js b/lib/item.js index 019c0529..1ec3e75d 100644 --- a/lib/item.js +++ b/lib/item.js @@ -88,6 +88,19 @@ function Item() { this.links = 0; } +/** + * Add execute if read allowed + * See notes in index.js -> mapping#addDir + */ +// prettier-ignore +Item.fixWin32Permissions = mode => + (process.platform !== 'win32') + ? mode + : mode | + ((mode & permissions.USER_READ) && permissions.USER_EXEC) | + ((mode & permissions.GROUP_READ) && permissions.GROUP_EXEC) | + ((mode & permissions.OTHER_READ) && permissions.OTHER_EXEC); + /** * Determine if the current user has read permission. * @return {boolean} The current user can read. @@ -140,8 +153,8 @@ Item.prototype.canExecute = function() { let can = false; if (uid === 0) { can = true; - } else if (uid === this._uid || uid !== uid) { - // (uid !== uid) means uid is NaN, only for windows + } else if (uid === this._uid || isNaN(uid)) { + // NaN occurs on windows can = (permissions.USER_EXEC & this._mode) === permissions.USER_EXEC; } else if (gid === this._gid) { can = (permissions.GROUP_EXEC & this._mode) === permissions.GROUP_EXEC; diff --git a/lib/loader.js b/lib/loader.js new file mode 100755 index 00000000..f2d549dc --- /dev/null +++ b/lib/loader.js @@ -0,0 +1,118 @@ +const {fixWin32Permissions} = require('./item'); +const path = require('path'); +const FileSystem = require('./filesystem'); +const fs = require('fs'); +const bypass = require('./bypass'); + +const createContext = ({output, options = {}, target}, newContext) => + Object.assign( + { + // Assign options and set defaults if needed + options: { + recursive: options.recursive !== false, + lazyLoad: options.lazyLoad !== false + }, + output, + target + }, + newContext + ); + +function addFile(context, stats, isRoot) { + const {output, target} = context; + const {lazyLoad} = context.options; + + if (!stats.isFile()) { + throw new Error(`${target} is not a valid file!`); + } + + const outputPropKey = isRoot ? target : path.basename(target); + + output[outputPropKey] = () => { + const content = !lazyLoad ? fs.readFileSync(target) : ''; + const file = FileSystem.file(Object.assign({}, stats, {content}))(); + + if (lazyLoad) { + Object.defineProperty(file, '_content', { + get() { + const res = bypass(() => fs.readFileSync(target)); + Object.defineProperty(file, '_content', { + value: res, + writable: true + }); + return res; + }, + set(data) { + Object.defineProperty(file, '_content', { + value: data, + writable: true + }); + }, + configurable: true + }); + } + + return file; + }; + + return output[outputPropKey]; +} + +function addDir(context, stats, isRoot) { + const {target, output} = context; + const {recursive} = context.options; + + if (!stats.isDirectory()) { + throw new Error(`${target} is not a valid directory!`); + } + + stats = Object.assign({}, stats); + const outputPropKey = isRoot ? target : path.basename(target); + + // On windows platforms, directories do not have the executable flag, which causes FileSystem.prototype.getItem + // to think that the directory cannot be traversed. This is a workaround, however, a better solution may be to + // re-think the logic in FileSystem.prototype.getItem + // This workaround adds executable privileges if read privileges are found + stats.mode = fixWin32Permissions(stats.mode); + + // Create directory factory + const directoryItems = {}; + output[outputPropKey] = FileSystem.directory( + Object.assign(stats, {items: directoryItems}) + ); + + fs.readdirSync(target).forEach(p => { + const absPath = path.join(target, p); + const stats = fs.statSync(absPath); + const newContext = createContext(context, { + target: absPath, + output: directoryItems + }); + + if (recursive && stats.isDirectory()) { + addDir(newContext, stats); + } else if (stats.isFile()) { + addFile(newContext, stats); + } + }); + + return output[outputPropKey]; +} + +/** + * Load directory or file from real FS + */ +exports.load = function(p, options) { + return bypass(() => { + p = path.resolve(p); + + const stats = fs.statSync(p); + const context = createContext({output: {}, options, target: p}); + + if (stats.isDirectory()) { + return addDir(context, stats, true); + } else if (stats.isFile()) { + return addFile(context, stats, true); + } + }); +}; diff --git a/readme.md b/readme.md index 61dca207..15901b21 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,5 @@ +[![Build Status](https://github.com/tschaub/mock-fs/workflows/Test/badge.svg)](https://github.com/tschaub/mock-fs/actions?workflow=Test) + # `mock-fs` The `mock-fs` module allows Node's built-in [`fs` module](http://nodejs.org/api/fs.html) to be backed temporarily by an in-memory, mock file system. This lets you run tests against a set of mock files and directories instead of lugging around a bunch of test fixtures. @@ -58,6 +60,42 @@ The second (optional) argument may include the properties below. * `createCwd` - `boolean` Create a directory for `process.cwd()`. This is `true` by default. * `createTmp` - `boolean` Create a directory for `os.tmpdir()`. This is `true` by default. +### Loading real files & directories + +You can load real files and directories into the mock system using `mock.load()` + +#### Notes + +- All stat information is duplicated (dates, permissions, etc) +- By default, all files are lazy-loaded, unless you specify the `{ lazyLoad: false }` option + +#### options + +| Option | Type | Default | Description | +| --------- | ------- | ------- | ------------ +| lazyLoad | boolean | true | File content isn't loaded until explicitly read +| recursive | boolean | true | Load all files and directories recursively + +#### `mock.load(path, options)` + +```js +mock({ + // Lazy-load file + 'my-file.txt': mock.load(path.resolve(__dirname, 'assets/special-file.txt')), + + // Pre-load js file + 'ready.js': mock.load(path.resolve(__dirname, 'scripts/ready.js'), { lazyLoad: false }), + + // Recursively loads all node_modules + 'node_modules': mock.load(path.resolve(__dirname, '../node_modules')), + + // Creates a directory named /tmp with only the files in /tmp/special_tmp_files (no subdirectories), pre-loading all content + '/tmp': mock.load('/tmp/special_tmp_files', { recursive: false, lazyLoad:false }), + + 'fakefile.txt': 'content here' +}); +``` + ### Creating files When `config` property values are a `string` or `Buffer`, a file is created with the provided content. For example, the following configuration creates a single file with string content (in addition to the two default directories). @@ -187,6 +225,33 @@ beforeEach(function() { afterEach(mock.restore); ``` +### Bypassing the mock file system + +#### `mock.bypass(fn)` + +Execute calls to the real filesystem with mock.bypass() + +```js +// This file exists only on the real FS, not on the mocked FS +const realFilePath = '/path/to/real/file.txt'; +const myData = mock.bypass(() => fs.readFileSync(realFilePath, 'utf-8')); +``` + +#### Async Warning + +Asynchronous calls are supported, however, they are not recommended as they could produce unintended consequences if +anything else tries to access the mocked filesystem before they've completed. + +```js +async function getFileInfo(fileName) { + return await mock.bypass(async () => { + const stats = await fs.promises.stat(fileName); + const data = await fs.promises.readFile(fileName); + return { stats, data }; + }); +} +``` + ## Install Using `npm`: @@ -222,6 +287,4 @@ expect(actual).toMatchSnapshot() ``` Note: it's safe to call `mock.restore` multiple times, so it can still be called in `afterEach` and then manually -in test cases which use snapshot testing. - -[![Build Status](https://github.com/tschaub/mock-fs/workflows/Test/badge.svg)](https://github.com/tschaub/mock-fs/actions?workflow=Test) +in test cases which use snapshot testing. \ No newline at end of file diff --git a/test/assets/dir/file2.txt b/test/assets/dir/file2.txt new file mode 100755 index 00000000..fee93d14 --- /dev/null +++ b/test/assets/dir/file2.txt @@ -0,0 +1 @@ +data2 \ No newline at end of file diff --git a/test/assets/dir/subdir/file3.txt b/test/assets/dir/subdir/file3.txt new file mode 100755 index 00000000..cdca2c1e --- /dev/null +++ b/test/assets/dir/subdir/file3.txt @@ -0,0 +1 @@ +data3 \ No newline at end of file diff --git a/test/assets/file1.txt b/test/assets/file1.txt new file mode 100755 index 00000000..0abc8f19 --- /dev/null +++ b/test/assets/file1.txt @@ -0,0 +1 @@ +data1 \ No newline at end of file diff --git a/test/lib/index.spec.js b/test/lib/index.spec.js index 42a95b08..017dcb11 100644 --- a/test/lib/index.spec.js +++ b/test/lib/index.spec.js @@ -5,8 +5,13 @@ const fs = require('fs'); const mock = require('../../lib/index'); const os = require('os'); const path = require('path'); +const File = require('../../lib/file'); +const {fixWin32Permissions} = require('../../lib/item'); +const Directory = require('../../lib/directory'); +const withPromise = helper.withPromise; const assert = helper.assert; +const assetsPath = path.resolve(__dirname, '../assets'); describe('The API', function() { describe('mock()', function() { @@ -180,6 +185,203 @@ describe('The API', function() { }); }); + describe(`mock.bypass()`, () => { + afterEach(mock.restore); + + it('(synchronous) bypasses mock FS & restores after', () => { + mock({'/path/to/file': 'content'}); + + assert.equal(fs.readFileSync('/path/to/file', 'utf8'), 'content'); + assert.isNotOk(fs.existsSync(__filename)); + assert.isOk(mock.bypass(() => fs.existsSync(__filename))); + + assert.isNotOk(fs.existsSync(__filename)); + }); + + withPromise.it('(async) bypasses mock FS & restores after', done => { + mock({'/path/to/file': 'content'}); + + assert.equal(fs.readFileSync('/path/to/file', 'utf8'), 'content'); + assert.isNotOk(fs.existsSync(__filename)); + + mock.bypass(() => + fs.promises + .stat(__filename) + .then(stat => { + assert.isTrue(stat.isFile()); + return fs.promises.stat(__filename); + }) + .then(stat => assert.isTrue(stat.isFile())) + .then(() => { + setTimeout(() => { + assert.isNotOk(fs.existsSync(__filename)); + done(); + }, 0); + }) + .catch(err => done(err)) + ); + }); + }); + + describe(`mock.load()`, () => { + const statsCompareKeys = [ + 'birthtime', + 'ctime', + 'mtime', + 'gid', + 'uid', + 'mtime', + 'mode' + ]; + const filterStats = stats => { + const res = {}; + for (const key of statsCompareKeys) { + const k = + (stats.hasOwnProperty(key) && key) || + (stats.hasOwnProperty(`_${key}`) && `_${key}`); + + if (k) { + res[key] = + k === 'mode' && stats.isDirectory() + ? fixWin32Permissions(stats[k]) + : stats[k]; + } + } + return res; + }; + + describe(`File`, () => { + const filePath = path.join(assetsPath, 'file1.txt'); + + it('creates a File factory with correct attributes', () => { + const file = mock.load(filePath)(); + const stats = fs.statSync(filePath); + + assert.instanceOf(file, File); + assert.deepEqual(filterStats(file), filterStats(stats)); + }); + describe('lazyLoad=true', () => { + let file; + beforeEach(() => (file = mock.load(filePath)())); + + it('creates accessors', () => { + assert.typeOf( + Object.getOwnPropertyDescriptor(file, '_content').get, + 'function' + ); + assert.typeOf( + Object.getOwnPropertyDescriptor(file, '_content').set, + 'function' + ); + }); + it('read file loads data and replaces accessors', () => { + assert.equal(file._content.toString(), 'data1'); + + assert.instanceOf( + Object.getOwnPropertyDescriptor(file, '_content').value, + Buffer + ); + assert.isNotOk( + Object.getOwnPropertyDescriptor(file, '_content').get, + 'function' + ); + assert.isNotOk( + Object.getOwnPropertyDescriptor(file, '_content').set, + 'function' + ); + }); + it('write file updates content and replaces accessors', () => { + file._content = Buffer.from('new data'); + + assert.equal(file._content.toString(), 'new data'); + assert.instanceOf( + Object.getOwnPropertyDescriptor(file, '_content').value, + Buffer + ); + assert.isNotOk( + Object.getOwnPropertyDescriptor(file, '_content').get, + 'function' + ); + assert.isNotOk( + Object.getOwnPropertyDescriptor(file, '_content').set, + 'function' + ); + }); + }); + + it('lazyLoad=false loads file content', () => { + const file = mock.load(path.join(assetsPath, 'file1.txt'), { + lazyLoad: false + })(); + + assert.equal( + Object.getOwnPropertyDescriptor(file, '_content').value.toString(), + 'data1' + ); + }); + + it('can read file from mocked FS', () => { + mock({'/file': mock.load(filePath)}); + assert.equal(fs.readFileSync('/file'), 'data1'); + mock.restore(); + }); + }); + + describe(`Dir`, () => { + it('creates a Directory factory with correct attributes', () => { + const dir = mock.load(assetsPath)(); + const stats = fs.statSync(assetsPath); + + assert.instanceOf(dir, Directory); + assert.deepEqual(filterStats(dir), filterStats(stats)); + }); + describe('recursive=true', () => { + it('creates all files & dirs', () => { + const base = mock.load(assetsPath, {recursive: true})(); + const baseDir = base._items.dir; + const baseDirSubdir = baseDir._items.subdir; + + assert.instanceOf(base, Directory); + assert.instanceOf(base._items['file1.txt'], File); + assert.instanceOf(baseDir, Directory); + assert.instanceOf(baseDir._items['file2.txt'], File); + assert.instanceOf(baseDirSubdir, Directory); + assert.instanceOf(baseDirSubdir._items['file3.txt'], File); + }); + it('respects lazyLoad setting', () => { + let dir; + const getFile = () => + dir._items.dir._items.subdir._items['file3.txt']; + + dir = mock.load(assetsPath, {recursive: true, lazyLoad: true})(); + assert.typeOf( + Object.getOwnPropertyDescriptor(getFile(), '_content').get, + 'function' + ); + + dir = mock.load(assetsPath, {recursive: true, lazyLoad: false})(); + assert.instanceOf( + Object.getOwnPropertyDescriptor(getFile(), '_content').value, + Buffer + ); + }); + }); + + it('recursive=false creates files & does not recurse', () => { + const base = mock.load(assetsPath, {recursive: false})(); + assert.instanceOf(base, Directory); + assert.instanceOf(base._items['file1.txt'], File); + assert.isNotOk(base._items.dir); + }); + + it('can read file from mocked FS', () => { + mock({'/dir': mock.load(assetsPath, {recursive: true})}); + assert.equal(fs.readFileSync('/dir/file1.txt'), 'data1'); + mock.restore(); + }); + }); + }); + xdescribe('mock.fs()', function() { it('generates a mock fs module with a mock file system', function(done) { const mockFs = mock.fs({