diff --git a/docs/node/relative-paths.md b/docs/node/relative-paths.md index 33d0d0285..f25e06107 100644 --- a/docs/node/relative-paths.md +++ b/docs/node/relative-paths.md @@ -23,3 +23,14 @@ is one folder that exists in all `memfs` volumes: ```js process.chdir('/'); ``` + +# Experimental support + +PR [1124](https://github.com/streamich/memfs/pull/1224) adds partial support for +relative paths. Tests are passing on the following methods: + +- `fs.promises.readFile` +- `fs.readFileSync` +- `vol.readdirSync` +- `vol.opendir` +- `vol.statSync` diff --git a/packages/fs-core/src/Superblock.ts b/packages/fs-core/src/Superblock.ts index ae884c07b..b5d4e4806 100644 --- a/packages/fs-core/src/Superblock.ts +++ b/packages/fs-core/src/Superblock.ts @@ -101,6 +101,14 @@ export class Superblock { this.root = root; } + protected _cwd: string = '/'; + get cwd(): string { + return this._cwd; + } + set cwd(value: string) { + this._cwd = value; + } + createLink(): Link; createLink(parent: Link, name: string, isDirectory?: boolean, mode?: number): Link; createLink(parent?: Link, name?: string, isDirectory: boolean = false, mode?: number): Link { @@ -202,7 +210,7 @@ export class Superblock { steps = stepsOrFilenameOrLink.steps; filename = pathSep + steps.join(pathSep); } else if (typeof stepsOrFilenameOrLink === 'string') { - steps = filenameToSteps(stepsOrFilenameOrLink); + steps = filenameToSteps(stepsOrFilenameOrLink, this.cwd); filename = stepsOrFilenameOrLink; } else { steps = stepsOrFilenameOrLink; @@ -243,7 +251,7 @@ export class Superblock { if (node.isSymlink() && (resolveSymlinks || i < steps.length - 1)) { const resolvedPath = isAbsolute(node.symlink) ? node.symlink : pathJoin(dirname(curr.getPath()), node.symlink); // Relative to symlink's parent - steps = filenameToSteps(resolvedPath).concat(steps.slice(i + 1)); + steps = filenameToSteps(resolvedPath, this.cwd).concat(steps.slice(i + 1)); curr = this.root; i = 0; continue; @@ -331,7 +339,7 @@ export class Superblock { getLinkParentAsDirOrThrow(filenameOrSteps: string | string[], funcName?: string): Link { const steps: string[] = ( - filenameOrSteps instanceof Array ? filenameOrSteps : filenameToSteps(filenameOrSteps) + filenameOrSteps instanceof Array ? filenameOrSteps : filenameToSteps(filenameOrSteps, this.cwd) ).slice(0, -1); const filename: string = pathSep + steps.join(pathSep); const link = this.getLinkOrThrow(filename, funcName); @@ -412,10 +420,11 @@ export class Superblock { return json; } - fromJSON(json: DirectoryJSON, cwd: string = this.process.cwd()) { + fromJSON(json: DirectoryJSON, cwd: string = "/") { + this.cwd = cwd; for (let filename in json) { const data = json[filename]; - filename = resolve(filename, cwd); + filename = resolve(filename, this.cwd); if (typeof data === 'string' || data instanceof Buffer) { const dir = dirname(filename); this.mkdirp(dir, MODE.DIR); @@ -500,7 +509,7 @@ export class Superblock { modeNum: number | undefined, resolveSymlinks: boolean = true, ): File { - const steps = filenameToSteps(filename); + const steps = filenameToSteps(filename, this.cwd); let link: Link | null; try { link = resolveSymlinks ? this.getResolvedLinkOrThrow(filename, 'open') : this.getLinkOrThrow(filename, 'open'); @@ -639,7 +648,7 @@ export class Superblock { }; public readonly symlink = (targetFilename: string, pathFilename: string): Link => { - const pathSteps = filenameToSteps(pathFilename); + const pathSteps = filenameToSteps(pathFilename, this.cwd); // Check if directory exists, where we about to create a symlink. let dirLink; try { @@ -715,7 +724,7 @@ export class Superblock { }; public readonly mkdir = (filename: string, modeNum: number): void => { - const steps = filenameToSteps(filename); + const steps = filenameToSteps(filename, this.cwd); // This will throw if user tries to create root dir `fs.mkdirSync('/')`. if (!steps.length) throw createError(ERROR_CODE.EEXIST, 'mkdir', filename); const dir = this.getLinkParentAsDirOrThrow(filename, 'mkdir'); @@ -732,7 +741,7 @@ export class Superblock { */ public readonly mkdirp = (filename: string, modeNum: number): string | undefined => { let created = false; - const steps = filenameToSteps(filename); + const steps = filenameToSteps(filename, this.cwd); let curr: Link | null = null; let i = steps.length; // Find the longest subpath of filename that still exists: diff --git a/packages/fs-core/src/util.ts b/packages/fs-core/src/util.ts index 265d5714c..75e373acf 100644 --- a/packages/fs-core/src/util.ts +++ b/packages/fs-core/src/util.ts @@ -113,6 +113,7 @@ export function pathToFilename(path: misc.PathLike): string { path = getPathFromURLPosix(path); } const pathString = String(path); + if (pathString === '.') return './'; nullCheck(pathString); return pathString; } diff --git a/packages/fs-node/src/__tests__/util.ts b/packages/fs-node/src/__tests__/util.ts index bbb5250d1..b1514cf3a 100644 --- a/packages/fs-node/src/__tests__/util.ts +++ b/packages/fs-node/src/__tests__/util.ts @@ -9,17 +9,17 @@ export const multitest = (_done: (err?: Error) => void, times: number) => { }; }; -export const create = (json: { [s: string]: string } = { '/foo': 'bar' }) => { - const vol = Volume.fromJSON(json); +export const create = (json: { [s: string]: string } = { '/foo': 'bar' }, cwd?: string) => { + const vol = Volume.fromJSON(json, cwd); return vol; }; -export const createFs = (json?) => { - const vol = create(json); +export const createFs = (json?: any, cwd?: string) => { + const vol = create(json, cwd); return vol; }; -export const memfs = (json: NestedDirectoryJSON = {}, cwd: string = '/') => { +export const memfs = (json: NestedDirectoryJSON = {}, cwd?: string) => { const vol = Volume.fromNestedJSON(json, cwd); return { fs: vol, vol }; }; diff --git a/packages/fs-node/src/__tests__/volume.test.ts b/packages/fs-node/src/__tests__/volume.test.ts index a49db509f..840b1608b 100644 --- a/packages/fs-node/src/__tests__/volume.test.ts +++ b/packages/fs-node/src/__tests__/volume.test.ts @@ -463,7 +463,7 @@ describe('volume', () => { expect(fd).toBeGreaterThan(0); done(); }); - }, 100); + }); it('Error on file not found', done => { vol.open('/non-existing-file.txt', 'r', (err, fd) => { expect(err).toHaveProperty('code', 'ENOENT'); diff --git a/packages/fs-node/src/__tests__/volume/cwd.test.ts b/packages/fs-node/src/__tests__/volume/cwd.test.ts new file mode 100644 index 000000000..76a63d0b2 --- /dev/null +++ b/packages/fs-node/src/__tests__/volume/cwd.test.ts @@ -0,0 +1,34 @@ +import { create } from '../util'; + +describe('Volume.cwd', () => { + it('Supports absolute path changes', () => { + const vol = create( + { + '/file': 'root', + '/a/file': 'a', + '/a/b/file': 'b', + }, + '/', + ); + expect(vol.readFileSync('./file', 'utf8')).toEqual('root'); + vol.cwd = '/a'; + expect(vol.readFileSync('./file', 'utf8')).toEqual('a'); + vol.cwd = '/a/b'; + expect(vol.readFileSync('./file', 'utf8')).toEqual('b'); + }); + it('Supports relative path changes', () => { + const vol = create( + { + file: 'root', + 'a/file': 'a', + 'a/b/file': 'b', + }, + process.cwd(), + ); + expect(vol.readFileSync('./file', 'utf8')).toEqual('root'); + vol.cwd = 'a'; + expect(vol.readFileSync('./file', 'utf8')).toEqual('a'); + vol.cwd = 'a/b'; + expect(vol.readFileSync('./file', 'utf8')).toEqual('b'); + }); +}); diff --git a/packages/fs-node/src/__tests__/volume/opendir.test.ts b/packages/fs-node/src/__tests__/volume/opendir.test.ts index 36c00b52b..a32924ff6 100644 --- a/packages/fs-node/src/__tests__/volume/opendir.test.ts +++ b/packages/fs-node/src/__tests__/volume/opendir.test.ts @@ -23,6 +23,35 @@ describe('opendir', () => { }); }); + it('should provide relative fs.opendirSync (synchronous)', () => { + const vol = Volume.fromJSON( + { + '/test/file1.txt': 'content1', + '/test/file2.txt': 'content2', + }, + '/test', + ); + + const dir = vol.opendirSync('.'); + expect(dir).toBeDefined(); + expect(typeof dir.read).toBe('function'); + expect(typeof dir.close).toBe('function'); + expect(typeof dir.readSync).toBe('function'); + expect(typeof dir.closeSync).toBe('function'); + + const entries: string[] = []; + let entry; + while ((entry = dir.readSync()) !== null) { + entries.push(entry.name); + } + + expect(entries).toContain('file1.txt'); + expect(entries).toContain('file2.txt'); + expect(entries.length).toBe(2); + + dir.closeSync(); + }); + it('should provide fs.opendirSync (synchronous)', () => { const vol = new Volume(); vol.mkdirSync('/test'); diff --git a/packages/fs-node/src/__tests__/volume/readFile.test.ts b/packages/fs-node/src/__tests__/volume/readFile.test.ts index 7256e280d..ffebdc854 100644 --- a/packages/fs-node/src/__tests__/volume/readFile.test.ts +++ b/packages/fs-node/src/__tests__/volume/readFile.test.ts @@ -2,6 +2,20 @@ import { of } from 'thingies'; import { memfs } from '../util'; describe('.readFile()', () => { + it('can read a file in cwd', async () => { + const { fs } = memfs({ 'test.txt': '01234567' }, '/dir'); + await expect(fs.promises.readFile('/dir/test.txt', { encoding: 'utf8' })).resolves.toBe('01234567'); + await expect(fs.readFileSync('/dir/test.txt', { encoding: 'utf8' })).toBe('01234567'); + }); + + it('can read a relative file in cwd', async () => { + const { fs } = memfs({ 'test.txt': '01234567' }, '/dir'); + await expect(fs.promises.readFile('test.txt', { encoding: 'utf8' })).resolves.toBe('01234567'); + await expect(fs.promises.readFile('./test.txt', { encoding: 'utf8' })).resolves.toBe('01234567'); + await expect(fs.readFileSync('test.txt', { encoding: 'utf8' })).toBe('01234567'); + await expect(fs.readFileSync('./test.txt', { encoding: 'utf8' })).toBe('01234567'); + }); + it('can read a file', async () => { const { fs } = memfs({ '/dir/test.txt': '01234567' }); const data = await fs.promises.readFile('/dir/test.txt', { encoding: 'utf8' }); diff --git a/packages/fs-node/src/__tests__/volume/readdirSync.test.ts b/packages/fs-node/src/__tests__/volume/readdirSync.test.ts index 5d29d2884..1c6f07e37 100644 --- a/packages/fs-node/src/__tests__/volume/readdirSync.test.ts +++ b/packages/fs-node/src/__tests__/volume/readdirSync.test.ts @@ -30,6 +30,19 @@ describe('readdirSync()', () => { expect(dirs).toEqual([]); }); + it('reads relative dir', () => { + const vol = create( + { + '/foo/bar/file': 'content', + '/foo/bar/file2': 'content2', + }, + '/foo', + ); + const files = vol.readdirSync('bar'); + + expect(files).toEqual(['file', 'file2']); + }); + it('respects symlinks', () => { const vol = create({ '/a/a': 'a', diff --git a/packages/fs-node/src/__tests__/volume/statSync.test.ts b/packages/fs-node/src/__tests__/volume/statSync.test.ts index 1559b2d5b..75ad0c9f5 100644 --- a/packages/fs-node/src/__tests__/volume/statSync.test.ts +++ b/packages/fs-node/src/__tests__/volume/statSync.test.ts @@ -8,8 +8,8 @@ describe('.statSync(...)', () => { vol.writeFileSync('/c/index.js', 'alert(123);'); vol.symlinkSync('/c', '/a/b'); - const stats = vol.statSync('/a/b/index.js'); - expect(stats.size).toBe(11); + expect(vol.statSync('/a/b/index.js').size).toBe(11); + expect(vol.statSync('a/b/index.js').size).toBe(11); }); it('returns rdev', () => { diff --git a/packages/fs-node/src/util.ts b/packages/fs-node/src/util.ts index 5ab246534..8b0160088 100644 --- a/packages/fs-node/src/util.ts +++ b/packages/fs-node/src/util.ts @@ -82,6 +82,7 @@ export function pathToFilename(path: misc.PathLike): string { } const pathString = String(path); + if (pathString === '.') return './'; nullCheck(pathString); // return slash(pathString); return pathString; diff --git a/packages/fs-node/src/volume.ts b/packages/fs-node/src/volume.ts index b122fdc66..aa4617b55 100644 --- a/packages/fs-node/src/volume.ts +++ b/packages/fs-node/src/volume.ts @@ -257,6 +257,13 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { this.realpathSync.native = realpathSyncImpl as any; } + get cwd(): string { + return this._core.cwd; + } + set cwd(value: string) { + this._core.cwd = value; + } + private wrapAsync(method: (...args: Args) => void, args: Args, callback: misc.TCallback) { validateCallback(callback); Promise.resolve().then(() => { @@ -988,7 +995,6 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { }; private readonly _readdir = (filename: string, options: opts.IReaddirOptions): TDataOut[] | Dirent[] => { - const steps = filenameToSteps(filename); const link: Link = this._core.getResolvedLinkOrThrow(filename, 'scandir'); const node = link.getNode(); if (!node.isDirectory()) throw createError(ERROR_CODE.ENOTDIR, 'scandir', filename);