Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Helper to bypass mock FS & expose real files/directories #304

Merged
merged 31 commits into from
Aug 9, 2020
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
26bfdf7
Added mock.bypass() and mock.createDirectoryInfoFromPaths()
nonara Jul 27, 2020
be7bf43
Fix broken `xdescribe`
nonara Jul 27, 2020
a39712e
Node 6 support
nonara Jul 27, 2020
266569b
Investigate missing entry (squash)
nonara Jul 27, 2020
95d3ff2
Fixed + improved test
nonara Jul 27, 2020
34a4503
Added normalize path
nonara Jul 27, 2020
91ab56a
Added coverage for non-string error
nonara Jul 27, 2020
fdeed02
Remove artifact
nonara Jul 27, 2020
0ad9857
prettier
nonara Jul 27, 2020
c3eddcb
LazyLoad Fixes
nonara Jul 27, 2020
decb5de
Updated readme
nonara Jul 27, 2020
135ec12
Make automatically created directories inherit stats (permissions, da…
nonara Jul 27, 2020
61b8ac1
Move badge to top of readme
nonara Jul 27, 2020
b195d8e
Fix: Make non-lazy loaded files retain stats
nonara Jul 27, 2020
3b58f79
Prefer existing permissions const
nonara Jul 27, 2020
6950d39
Re-engineered API
nonara Jul 28, 2020
9f284af
Update readme
nonara Jul 28, 2020
562c6ae
Added tests for integration with mock() & fs.readFileSync
nonara Jul 28, 2020
2ca7d0e
Correct typo
nonara Jul 28, 2020
10f61da
Fix: fixWin32Permissions missing platform check
nonara Jul 28, 2020
92ca9c8
Address 3cp's comments
nonara Jul 28, 2020
172e698
Clarified bypassing methods & added restoration test
nonara Jul 29, 2020
9087580
Update mock.bypass test
nonara Jul 29, 2020
0c07e57
Moved mapping helpers to outside module
nonara Jul 29, 2020
8e11bec
Fix circular issue
nonara Jul 29, 2020
4b579c0
Made modules according to repo convention
nonara Jul 29, 2020
8dfdc85
Applied review fixes
nonara Aug 4, 2020
2cc92f3
Remove async await in test (Node 6/8 support)
nonara Aug 4, 2020
0c7bbbb
Prettier correction
nonara Aug 4, 2020
f431af2
Add withPromise to test
nonara Aug 4, 2020
768224f
Changed to typeof function check
nonara Aug 4, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (res.then) {
nonara marked this conversation as resolved.
Show resolved Hide resolved
res.then(exports.enable);
res.catch(exports.enable);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be better handled with res.finally(exports.enable).

} else {
exports.enable();
}

return res;
} catch (e) {
exports.enable();
throw e;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be in a finally block.

}
};

/**
* 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]) {
nonara marked this conversation as resolved.
Show resolved Hide resolved
// 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could just be called lazy - avoiding a bit of repetition: load(dir, {lazy: true}).

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

Copy link
Owner

@tschaub tschaub Jul 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate all that you've put together here, @nonara.

I have some concern about the size of the change to the API (adding bypass, enable, disable, mapPaths, mapFile, and mapDir). And I think the subtle difference in usage of mapPaths vs mapFile or mapDir is error prone.

It feels like we could try to minimize the changes to the API while still providing most of the same functionality.

All of mapPaths, mapFile, and mapDir look to be about providing a convenient way to load up a mock filesystem with data from the real filesystem. Words like load, clone, and read come to mind for function names.

mock({
  '/path/to/dir': mock.load('/path/to/dir'),
  '/path/to/file': mock.load('/path/to/file')
})

That implies adding a single load function that takes either a path to a directory or a path to a file and copies contents and metadata into a corresponding mock filesystem entry. I assume we should also make it work for symlinks.

If we find that usage patterns make it very awkward to repeat the real path in the mock path, we could add a function that makes that more convenient later.

The bypass, enable, and disable functions feel like they could be boiled down to a single function that works with either an async/promise returning function or a sync function. In the docs below, you warn against using async calls with the mock disabled - that same warning could be included in the bypass docs without adding the enable and disable functions.

Having a single bypass function also gets around the issue that calling enable twice makes disable not work (this is fixable, but could also be avoided by not adding these functions at all).

Copy link
Contributor Author

@nonara nonara Jul 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of mapPaths, mapFile, and mapDir look to be about providing a convenient way to load up a mock filesystem with data from the real filesystem. Words like load, clone, and read come to mind for function names.

I agree that mapDir and mapFile could be merged, and load is a great name.

As for mapPaths, this is a simple way to quickly load multiple real paths. (though we can rename - loadPaths or mount?)

// With (simple)
mock(mock.loadPaths(realPaths));

// Without (simple)
mock(realPaths.reduce((p,c) => { p[c] = map.load(p); return p }, {})));

// With (extended)
mock{{
  ...mock.loadPaths(realPaths),
  'extra': 'content'
});

// Without (extended)
mock({
  ...realPaths.reduce((p,c) => { p[c] = map.load(p; return p) }, {})),
  'extra': 'content'
});

Bear in mind that while reduce is ugly and difficult to read, this is the shortest solution. Most people will end up using multi-line for loops, etc, all to accomplish what many have requested - a feature to simply 'mount' real areas. Worse yet, some will end up doing

{ 
  '/path/to/my/location': map.load('/path/to/my/location'),
  '/path/to/other.location': map.load('/path/to/other/location'),
  ...
}

Tedious.

The decision lies with you. I really have spent more time on this than I am able, so I'll go with whatever you want, but I hope you'll consider it reasonable to allow the API to have three new items: load, bypass, and loadPaths

Otherwise, many (myself included) will end up replicating helper logic to convert an array of real locations for each package we use this in.

The bypass, enable, and disable functions feel like they could be boiled down to a single function that works with either an async/promise returning function or a sync function. In the docs below, you warn against using async calls with the mock disabled - that same warning could be included in the bypass docs without adding the enable and disable functions.

Agreed. The consideration was that many people will simply use the function without noticing the warning. It was set up to be more strict to prevent people using it and filing issues.

But this is entirely your call, if you're good with it, then it's no problem. I'll add async support, and we can add the warning to both the readme and JSDoc for the types package to make sure it's seen.

Having a single bypass function also gets around the issue that calling enable twice makes disable not work (this is fixable, but could also be avoided by not adding these functions at all).

Didn't see that. I was up quite late last night. I'll correct the issue. If you're alright with it, I'd like to leave them attached to exports and simply not document in readme or in the @types package. That way there is zero-footprint, but people who know the source can use them.

I can always replicate the behaviour to do it, but this way if anything changes my code doesn't break.

Let me know! Hopefully we can get this wrapped up. I'll wait until your responses before updating anything.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel tediousness is an issue. I cannot imagine people use mock-fs to load lots of real dirs, it seems defeating the purpose of mocking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot imagine people use mock-fs to load lots of real dirs, it seems defeating the purpose of mocking.

Not at all. If we have multiple assets directories or even hard-coded paths on the system, this allows mock-fs to be used in a way that lets us 'mount' those areas and do whatever we like without actually modifying them. This is tremendously useful with a broad range of use-cases.

That is, in fact, the reason I started this PR, but that's really all I can say on that. I don't want to belabor it further.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without actually modifying them

That's a good use case.

### 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