-
Notifications
You must be signed in to change notification settings - Fork 86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Helper to bypass mock FS & expose real files/directories #304
Changes from all commits
26bfdf7
be7bf43
a39712e
266569b
95d3ff2
34a4503
91ab56a
fdeed02
0ad9857
c3eddcb
decb5de
135ec12
61b8ac1
b195d8e
3b58f79
6950d39
9f284af
562c6ae
2ca7d0e
10f61da
92ca9c8
172e698
9087580
0c07e57
8e11bec
4b579c0
8dfdc85
2cc92f3
0c7bbbb
f431af2
768224f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would be better handled with |
||
} else { | ||
exports.enable(); | ||
} | ||
|
||
return res; | ||
} catch (e) { | ||
exports.enable(); | ||
throw e; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could be in a |
||
} | ||
}; | ||
|
||
/** | ||
* 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; | ||
} | ||
}; |
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); | ||
} | ||
}); | ||
}; |
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. | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this could just be called |
||
| 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' | ||
}); | ||
``` | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 It feels like we could try to minimize the changes to the API while still providing most of the same functionality. All of mock({
'/path/to/dir': mock.load('/path/to/dir'),
'/path/to/file': mock.load('/path/to/file')
}) That implies adding a single 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 Having a single There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I agree that As for mapPaths, this is a simple way to quickly load multiple real paths. (though we can rename - // 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
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: Otherwise, many (myself included) will end up replicating helper logic to convert an array of real locations for each package we use this in.
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.
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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not at all. If we have multiple assets directories or even hard-coded paths on the system, this allows 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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). | ||
|
@@ -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`: | ||
|
@@ -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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
data2 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
data3 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
data1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will throw a
TypeError
iffn
returnsnull
orundefined
.