Skip to content

Commit

Permalink
Merge pull request #304 from nonara/master
Browse files Browse the repository at this point in the history
Helper to bypass mock FS & expose real files/directories
  • Loading branch information
tschaub authored Aug 9, 2020
2 parents e42fdbb + 768224f commit ee50c67
Show file tree
Hide file tree
Showing 10 changed files with 477 additions and 5 deletions.
55 changes: 55 additions & 0 deletions lib/bypass.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
2 changes: 2 additions & 0 deletions lib/filesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
16 changes: 16 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
17 changes: 15 additions & 2 deletions lib/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions lib/loader.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
};
69 changes: 66 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

#### <a id='mappingoptions'>options</a>

| 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).
Expand Down Expand Up @@ -187,6 +225,33 @@ beforeEach(function() {
afterEach(mock.restore);
```

### Bypassing the mock file system

#### <a id='mockbypass'>`mock.bypass(fn)`</a>

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'));
```

#### <a id='bypassasync'>Async Warning</a>

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`:
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions test/assets/dir/file2.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data2
1 change: 1 addition & 0 deletions test/assets/dir/subdir/file3.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data3
1 change: 1 addition & 0 deletions test/assets/file1.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data1
Loading

0 comments on commit ee50c67

Please sign in to comment.