diff --git a/packages/metro-file-map/src/__tests__/FileSystem-test.js b/packages/metro-file-map/src/__tests__/FileSystem-test.js new file mode 100644 index 0000000000..7f8c887ddd --- /dev/null +++ b/packages/metro-file-map/src/__tests__/FileSystem-test.js @@ -0,0 +1,136 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {FileMetaData, Path} from '../flow-types'; +let mockPathModule; +jest.mock('path', () => mockPathModule); +jest.mock('../lib/fast_path', () => mockPathModule); + +describe.each([['win32'], ['posix']])( + 'FileSystem implementations on %s', + platform => { + beforeAll(() => { + mockPathModule = jest.requireActual<{}>('path')[platform]; + }); + + // Convenience function to write paths with posix separators but convert them + // to system separators + const p: string => string = filePath => + platform === 'win32' + ? filePath.replace(/\//g, '\\').replace(/^\\/, 'C:\\') + : filePath; + + describe.each([['TreeFS'], ['HasteFS']])('%s', label => { + let FileSystem; + let fs; + + beforeAll(() => { + jest.resetModules(); + FileSystem = + label === 'HasteFS' + ? require('../HasteFS').default + : require('../lib/TreeFS').default; + + fs = new FileSystem({ + rootDir: p('/project'), + files: new Map([ + [p('foo/another.js'), ['', 0, 0, 1, '', '', 0]], + [p('../outside/external.js'), ['', 0, 0, 1, '', '', 0]], + [p('bar.js'), ['', 234, 0, 1, '', '', 0]], + ]), + }); + }); + + test('getAllFiles returns all files by absolute path', () => { + expect(fs.getAllFiles().sort()).toEqual([ + p('/outside/external.js'), + p('/project/bar.js'), + p('/project/foo/another.js'), + ]); + }); + + test.each([ + p('/outside/external.js'), + p('/project/bar.js'), + p('/project/foo/another.js'), + ])('existence check passes: %s', filePath => { + expect(fs.exists(filePath)).toBe(true); + }); + + test('existence check fails for directories', () => { + expect(fs.exists(p('/project/foo'))).toBe(false); + }); + + test('implements linkStats()', () => { + expect(fs.linkStats(p('./bar.js'))).toEqual({ + fileType: 'f', + modifiedTime: 234, + }); + }); + + describe('matchFiles', () => { + test('matches files against a pattern', async () => { + expect( + fs.matchFiles( + mockPathModule.sep === mockPathModule.win32.sep + ? /project\\foo/ + : /project\/foo/, + ), + ).toEqual([p('/project/foo/another.js')]); + }); + }); + + describe('matchFilesWithContext', () => { + test('matches files against context', () => { + const fs = new FileSystem({ + rootDir: p('/root'), + files: new Map([ + [p('foo/another.js'), ['', 0, 0, 0, '', '', 0]], + [p('bar.js'), ['', 0, 0, 0, '', '', 0]], + ]), + }); + + expect(fs.getAllFiles()).toEqual([ + p('/root/foo/another.js'), + p('/root/bar.js'), + ]); + + // Test non-recursive skipping deep paths + expect( + fs.matchFilesWithContext(p('/root'), { + filter: new RegExp( + // Test starting with `./` since this is mandatory for parity with Webpack. + /^\.\/.*/, + ), + recursive: false, + }), + ).toEqual([p('/root/bar.js')]); + + // Test inner directory + expect( + fs.matchFilesWithContext(p('/root/foo'), { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual([p('/root/foo/another.js')]); + + // Test recursive + expect( + fs.matchFilesWithContext(p('/root'), { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual([p('/root/foo/another.js'), p('/root/bar.js')]); + }); + }); + }); + }, +); diff --git a/packages/metro-file-map/src/__tests__/HasteFS-test.js b/packages/metro-file-map/src/__tests__/HasteFS-test.js deleted file mode 100644 index 15eae70c3a..0000000000 --- a/packages/metro-file-map/src/__tests__/HasteFS-test.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - * @oncall react_native - */ - -import type {FileMetaData, Path} from '../flow-types'; -let mockPathModule; -jest.mock('path', () => mockPathModule); -jest.mock('../lib/fast_path', () => mockPathModule); - -describe.each([['win32'], ['posix']])('HasteFS on %s', platform => { - let HasteFS; - let fs; - - // Convenience function to write paths with posix separators but convert them - // to system separators - const p: string => string = filePath => - platform === 'win32' - ? filePath.replaceAll('/', '\\').replace(/^\\/, 'C:\\') - : filePath; - - beforeAll(() => { - mockPathModule = jest.requireActual<{}>('path')[platform]; - jest.resetModules(); - HasteFS = require('../HasteFS').default; - fs = new HasteFS({ - rootDir: p('/project'), - files: new Map([ - [p('foo/another.js'), ['', 0, 0, 1, '', '', 0]], - [p('../outside/external.js'), ['', 0, 0, 1, '', '', 0]], - [p('bar.js'), ['', 234, 0, 1, '', '', 0]], - ]), - }); - }); - - test('getAllFiles returns all files by absolute path', () => { - expect(fs.getAllFiles().sort()).toEqual([ - p('/outside/external.js'), - p('/project/bar.js'), - p('/project/foo/another.js'), - ]); - }); - - test.each([ - p('/outside/external.js'), - p('/project/bar.js'), - p('/project/foo/another.js'), - ])('existence check passes: %s', filePath => { - expect(fs.exists(filePath)).toBe(true); - }); - - test('existence check fails for directories', () => { - expect(fs.exists(p('/project/foo'))).toBe(false); - }); - - test('implements linkStats()', () => { - expect(fs.linkStats(p('./bar.js'))).toEqual({ - fileType: 'f', - modifiedTime: 234, - }); - }); - - describe('matchFiles', () => { - test('matches files against a pattern', async () => { - expect( - fs.matchFiles( - mockPathModule.sep === mockPathModule.win32.sep - ? /project\\foo/ - : /project\/foo/, - ), - ).toEqual([p('/project/foo/another.js')]); - }); - }); - - describe('matchFilesWithContext', () => { - test('matches files against context', () => { - const fs = new HasteFS({ - rootDir: p('/root'), - files: new Map([ - [p('foo/another.js'), ['', 0, 0, 0, '', '', 0]], - [p('bar.js'), ['', 0, 0, 0, '', '', 0]], - ]), - }); - - expect([...fs.getAbsoluteFileIterator()]).toEqual([ - p('/root/foo/another.js'), - p('/root/bar.js'), - ]); - - // Test non-recursive skipping deep paths - expect( - fs.matchFilesWithContext(p('/root'), { - filter: new RegExp( - // Test starting with `./` since this is mandatory for parity with Webpack. - /^\.\/.*/, - ), - recursive: false, - }), - ).toEqual([p('/root/bar.js')]); - - // Test inner directory - expect( - fs.matchFilesWithContext(p('/root/foo'), { - filter: new RegExp(/.*/), - recursive: true, - }), - ).toEqual([p('/root/foo/another.js')]); - - // Test recursive - expect( - fs.matchFilesWithContext(p('/root'), { - filter: new RegExp(/.*/), - recursive: true, - }), - ).toEqual([p('/root/foo/another.js'), p('/root/bar.js')]); - }); - }); -}); diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index db0e799085..ceab380c7e 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -1663,6 +1663,8 @@ describe('HasteMap', () => { fileType: 'l', modifiedTime: 46, }); + // getModuleName traverses the symlink, verifying the link is read. + expect(fileSystem.getModuleName(filePath)).toEqual('Strawberry'); }, {config: {enableSymlinks: true}}, ); diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 4fb0320d3b..e5737d91c3 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -47,6 +47,7 @@ import deepCloneRawModuleMap from './lib/deepCloneRawModuleMap'; import * as fastPath from './lib/fast_path'; import getPlatformExtension from './lib/getPlatformExtension'; import normalizePathSep from './lib/normalizePathSep'; +import TreeFS from './lib/TreeFS'; import HasteModuleMap from './ModuleMap'; import {Watcher} from './Watcher'; import {worker} from './worker'; @@ -358,10 +359,13 @@ export default class HasteMap extends EventEmitter { const rootDir = this._options.rootDir; const fileData = initialData.files; - const fileSystem = new HasteFS({ + this._startupPerfLogger?.point('constructFileSystem_start'); + const FileSystem = this._options.enableSymlinks ? TreeFS : HasteFS; + const fileSystem = new FileSystem({ files: fileData, rootDir, }); + this._startupPerfLogger?.point('constructFileSystem_end'); const {map, mocks, duplicates} = initialData; const rawModuleMap: RawModuleMap = { duplicates, diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js new file mode 100644 index 0000000000..193aadd4cf --- /dev/null +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -0,0 +1,386 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +import type { + FileData, + FileMetaData, + FileStats, + MutableFileSystem, + Path, +} from '../flow-types'; + +import H from '../constants'; +import * as fastPath from '../lib/fast_path'; +import invariant from 'invariant'; +import path from 'path'; + +type DirectoryNode = Map; +type FileNode = FileMetaData; +type LinkNode = string; +type AnyNode = FileNode | DirectoryNode | LinkNode; + +export default class TreeFS implements MutableFileSystem { + +#rootDir: Path; + +#files: FileData; + +#rootNode: DirectoryNode = new Map(); + + constructor({rootDir, files}: {rootDir: Path, files: FileData}) { + this.#rootDir = rootDir; + this.#files = files; + this.bulkAddOrModify(files); + } + + getSerializableSnapshot(): FileData { + return new Map( + Array.from(this.#files.entries(), ([k, v]: [Path, FileMetaData]) => [ + k, + [...v], + ]), + ); + } + + getModuleName(file: Path): ?string { + const fileMetadata = this._getFileData(file); + return (fileMetadata && fileMetadata[H.ID]) ?? null; + } + + getSize(file: Path): ?number { + const fileMetadata = this._getFileData(file); + return (fileMetadata && fileMetadata[H.SIZE]) ?? null; + } + + getDependencies(file: Path): ?Array { + const fileMetadata = this._getFileData(file); + + if (fileMetadata) { + return fileMetadata[H.DEPENDENCIES] + ? fileMetadata[H.DEPENDENCIES].split(H.DEPENDENCY_DELIM) + : []; + } else { + return null; + } + } + + getSha1(file: Path): ?string { + const fileMetadata = this._getFileData(file); + return (fileMetadata && fileMetadata[H.SHA1]) ?? null; + } + + exists(file: Path): boolean { + const result = this._getFileData(file); + return result != null; + } + + getAllFiles(): Array { + return Array.from(this._regularFileIterator(), normalPath => + this._normalToAbsolutePath(normalPath), + ); + } + + linkStats(file: Path): ?FileStats { + const fileMetadata = this._getFileData(file, {follow: false}); + if (fileMetadata == null) { + return null; + } + const fileType = fileMetadata[H.SYMLINK] === 0 ? 'f' : 'l'; + const modifiedTime = fileMetadata[H.MTIME]; + invariant( + typeof modifiedTime === 'number', + 'File in TreeFS missing modified time', + ); + return { + fileType, + modifiedTime, + }; + } + + matchFiles(pattern: RegExp | string): Array { + const regexpPattern = + pattern instanceof RegExp ? pattern : new RegExp(pattern); + const files = []; + for (const filePath of this._pathIterator()) { + const absolutePath = this._normalToAbsolutePath(filePath); + if (regexpPattern.test(absolutePath)) { + files.push(absolutePath); + } + } + return files; + } + + /** + * Given a search context, return a list of file paths matching the query. + * The query matches against normalized paths which start with `./`, + * for example: `a/b.js` -> `./a/b.js` + */ + matchFilesWithContext( + root: Path, + context: $ReadOnly<{ + /* Should search for files recursively. */ + recursive: boolean, + /* Filter relative paths against a pattern. */ + filter: RegExp, + }>, + ): Array { + const normalRoot = this._normalizePath(root); + const contextRootResult = this._lookupByNormalPath(normalRoot); + if (!contextRootResult) { + return []; + } + const {normalPath: rootRealPath, node: contextRoot} = contextRootResult; + if (!(contextRoot instanceof Map)) { + return []; + } + const contextRootAbsolutePath = + rootRealPath === '' + ? this.#rootDir + : path.join(this.#rootDir, rootRealPath); + + const files = []; + const prefix = './'; + + for (const relativePosixPath of this._pathIterator({ + pathSep: '/', + recursive: context.recursive, + rootNode: contextRoot, + subtreeOnly: true, + })) { + if ( + context.filter.test( + // NOTE(EvanBacon): Ensure files start with `./` for matching purposes + // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). + // `a/b.js` -> `./a/b.js` + prefix + relativePosixPath, + ) + ) { + const relativePath = + path.sep === '/' + ? relativePosixPath + : relativePosixPath.replace(/\//g, path.sep); + + files.push(contextRootAbsolutePath + path.sep + relativePath); + } + } + + return files; + } + + addOrModify(filePath: Path, metadata: FileMetaData): void { + const normalPath = this._normalizePath(filePath); + this.bulkAddOrModify(new Map([[normalPath, metadata]])); + } + + bulkAddOrModify(addedOrModifiedFiles: FileData): void { + for (const [normalPath, metadata] of addedOrModifiedFiles) { + this.#files.set(normalPath, metadata); + const directoryParts = normalPath.split(path.sep); + const basename = directoryParts.pop(); + const directoryNode = this._mkdirp(directoryParts); + if (metadata[H.SYMLINK] !== 0) { + const symlinkTarget = metadata[H.SYMLINK]; + invariant( + typeof symlinkTarget === 'string', + 'expected symlink targets to be populated', + ); + let rootRelativeSymlinkTarget; + if (path.isAbsolute(symlinkTarget)) { + rootRelativeSymlinkTarget = fastPath.relative( + this.#rootDir, + symlinkTarget, + ); + } else { + rootRelativeSymlinkTarget = path.normalize( + path.join(path.dirname(normalPath), symlinkTarget), + ); + } + directoryNode.set(basename, rootRelativeSymlinkTarget); + } else { + directoryNode.set(basename, metadata); + } + } + } + + remove(filePath: Path) { + const normalPath = this._normalizePath(filePath); + this.#files.delete(normalPath); + const directoryParts = normalPath.split(path.sep); + const basename = directoryParts.pop(); + const directoryNode = this._mkdirp(directoryParts); + directoryNode.delete(basename); + } + + _lookupByNormalPath( + relativePath: string, + opts: { + // Like lstat vs stat, whether to follow a symlink at the basename of + // the given path, or return the details of the symlink itself. + follow: boolean, + } = {follow: true}, + seen: Set = new Set(), + ): ?{normalPath: string, node: AnyNode} { + if (relativePath === '') { + return {normalPath: '', node: this.#rootNode}; + } + seen.add(relativePath); + const directoryParts = relativePath.split(path.sep); + const basename = directoryParts.pop(); + let node = this.#rootNode; + for (const [idx, directoryPart] of directoryParts.entries()) { + if (directoryPart === '.') { + continue; + } + const nextNode = node.get(directoryPart); + if (nextNode == null) { + return null; + } + if (Array.isArray(nextNode)) { + // Regular file in a directory path + return null; + } else if (typeof nextNode === 'string') { + if (seen.has(nextNode)) { + // TODO: Warn `Symlink cycle detected: ${[...seen, node].join(' -> ')}` + return null; + } + return this._lookupByNormalPath( + path.join(nextNode, ...directoryParts.slice(idx + 1), basename), + opts, + seen, + ); + } + node = nextNode; + } + const basenameNode = node.get(basename); + if (typeof basenameNode === 'string') { + // basenameNode is a symlink target + if (!opts.follow) { + return {normalPath: relativePath, node: basenameNode}; + } + if (seen.has(basenameNode)) { + // TODO: Warn `Symlink cycle detected: ${[...seen, target].join(' -> ')}` + return null; + } + return this._lookupByNormalPath(basenameNode, opts, seen); + } + return basenameNode ? {normalPath: relativePath, node: basenameNode} : null; + } + + _normalizePath(relativeOrAbsolutePath: Path): string { + return path.isAbsolute(relativeOrAbsolutePath) + ? fastPath.relative(this.#rootDir, relativeOrAbsolutePath) + : path.normalize(relativeOrAbsolutePath); + } + + _normalToAbsolutePath(normalPath: Path): Path { + if (normalPath[0] === '.') { + return path.normalize(this.#rootDir + path.sep + normalPath); + } else { + return this.#rootDir + path.sep + normalPath; + } + } + + *_regularFileIterator(): Iterator { + for (const [normalPath, metadata] of this.#files) { + if (metadata[H.SYMLINK] !== 0) { + continue; + } + yield normalPath; + } + } + + *_pathIterator({ + pathSep = path.sep, + recursive = true, + rootNode, + subtreeOnly = false, + }: { + pathSep?: string, + recursive?: boolean, + rootNode?: DirectoryNode, + subtreeOnly?: boolean, + } = {}): Iterable { + for (const [name, node] of rootNode ?? this.#rootNode) { + if (subtreeOnly && name === '..') { + continue; + } + if (Array.isArray(node)) { + yield name; + } else if (typeof node === 'string') { + const resolved = this._lookupByNormalPath(node); + if (resolved == null) { + continue; + } + const target = resolved.node; + if (target instanceof Map) { + if (!recursive) { + continue; + } + // symlink points to a directory - iterate over its contents + for (const file of this._pathIterator({ + pathSep, + recursive, + rootNode: target, + subtreeOnly, + })) { + yield name + pathSep + file; + } + } else { + // symlink points to a file - report it + yield name; + } + } else if (recursive) { + for (const file of this._pathIterator({ + pathSep, + recursive, + rootNode: node, + subtreeOnly, + })) { + yield name + pathSep + file; + } + } + } + } + + _getFileData( + filePath: Path, + opts: {follow: boolean} = {follow: true}, + ): ?FileMetaData { + const normalPath = this._normalizePath(filePath); + const metadata = this.#files.get(normalPath); + if (metadata && (!opts.follow || metadata[H.SYMLINK] === 0)) { + return metadata; + } + const result = this._lookupByNormalPath(normalPath, opts); + if (!result || result.node instanceof Map) { + return null; + } + return this.#files.get(result.normalPath); + } + + _mkdirp(directoryParts: $ReadOnlyArray): DirectoryNode { + let node = this.#rootNode; + for (const directoryPart of directoryParts) { + if (directoryPart === '.') { + continue; + } + let nextNode = node.get(directoryPart); + if (nextNode == null) { + nextNode = new Map(); + node.set(directoryPart, nextNode); + } + invariant( + nextNode instanceof Map, + '%s in %s is a file, directory expected', + directoryPart, + directoryParts, + ); + node = nextNode; + } + return node; + } +} diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js new file mode 100644 index 0000000000..7492c26df8 --- /dev/null +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -0,0 +1,154 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type TreeFS from '../TreeFS'; + +let mockPathModule; +jest.mock('path', () => mockPathModule); + +describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { + // Convenience function to write paths with posix separators but convert them + // to system separators + const p: string => string = filePath => + platform === 'win32' + ? filePath.replace(/\//g, '\\').replace(/^\\/, 'C:\\') + : filePath; + + let tfs: TreeFS; + beforeEach(() => { + jest.resetModules(); + mockPathModule = jest.requireActual<{}>('path')[platform]; + const TreeFS = require('../TreeFS').default; + tfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('foo/another.js'), ['', 123, 0, 0, '', '', 0]], + [p('foo/link-to-bar.js'), ['', 0, 0, 0, '', '', p('../bar.js')]], + [p('foo/link-to-another.js'), ['', 0, 0, 0, '', '', p('another.js')]], + [p('../outside/external.js'), ['', 0, 0, 0, '', '', 0]], + [p('bar.js'), ['', 234, 0, 0, '', '', 0]], + [p('link-to-foo'), ['', 456, 0, 0, '', '', p('./foo')]], + [p('root'), ['', 0, 0, 0, '', '', '..']], + [p('link-to-nowhere'), ['', 0, 0, 0, '', '', p('./nowhere')]], + [p('link-to-self'), ['', 0, 0, 0, '', '', p('./link-to-self')]], + [p('link-cycle-1'), ['', 0, 0, 0, '', '', p('./link-cycle-2')]], + [p('link-cycle-2'), ['', 0, 0, 0, '', '', p('./link-cycle-1')]], + ]), + }); + }); + + test('all files iterator returns all regular files by real path', () => { + expect(tfs.getAllFiles().sort()).toEqual([ + p('/outside/external.js'), + p('/project/bar.js'), + p('/project/foo/another.js'), + ]); + }); + + test.each([ + p('/outside/external.js'), + p('/project/bar.js'), + p('/project/foo/another.js'), + p('/project/foo/link-to-another.js'), + p('/project/link-to-foo/another.js'), + p('/project/link-to-foo/link-to-another.js'), + p('/project/root/outside/external.js'), + ])('existence check passes for regular files via symlinks: %s', filePath => { + expect(tfs.exists(filePath)).toBe(true); + }); + + test('existence check fails for directories, symlinks to directories, or symlinks to nowhere', () => { + expect(tfs.exists(p('/project/foo'))).toBe(false); + expect(tfs.exists(p('/project/link-to-foo'))).toBe(false); + expect(tfs.exists(p('/project/link-to-nowhere'))).toBe(false); + }); + + test('implements linkStats()', () => { + expect(tfs.linkStats(p('/project/link-to-foo/another.js'))).toEqual({ + fileType: 'f', + modifiedTime: 123, + }); + expect(tfs.linkStats(p('bar.js'))).toEqual({ + fileType: 'f', + modifiedTime: 234, + }); + expect(tfs.linkStats(p('./link-to-foo'))).toEqual({ + fileType: 'l', + modifiedTime: 456, + }); + }); + + describe('matchFilesWithContext', () => { + test('non-recursive, skipping deep paths', () => { + expect( + tfs.matchFilesWithContext(p('/project'), { + filter: new RegExp( + // Test starting with `./` since this is mandatory for parity with Webpack. + /^\.\/.*/, + ), + recursive: false, + }), + ).toEqual([p('/project/bar.js')]); + }); + + test('inner directory', () => { + expect( + tfs.matchFilesWithContext(p('/project/foo'), { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual([ + p('/project/foo/another.js'), + p('/project/foo/link-to-bar.js'), + p('/project/foo/link-to-another.js'), + ]); + }); + + test('outside rootDir', () => { + expect( + tfs.matchFilesWithContext(p('/outside'), { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual([p('/outside/external.js')]); + }); + + test('recursive', () => { + expect( + tfs.matchFilesWithContext(p('/project'), { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual([ + p('/project/foo/another.js'), + p('/project/foo/link-to-bar.js'), + p('/project/foo/link-to-another.js'), + p('/project/bar.js'), + p('/project/link-to-foo/another.js'), + p('/project/link-to-foo/link-to-bar.js'), + p('/project/link-to-foo/link-to-another.js'), + p('/project/root/outside/external.js'), + ]); + }); + + test('recursive with filter', () => { + expect( + tfs.matchFilesWithContext(p('/project'), { + filter: new RegExp(/\/another\.js/), + recursive: true, + }), + ).toEqual([ + p('/project/foo/another.js'), + p('/project/link-to-foo/another.js'), + ]); + }); + }); +});