Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 57 additions & 0 deletions packages/metro-file-map/src/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,19 @@ jest.mock('fs', () => ({
error.code = 'ENOENT';
throw error;
}),
readlinkSync: jest.fn(path => {
const entry = mockFs[path];
if (!entry) {
const error = new Error(`Cannot read path '${path}'.`);
// $FlowFixMe[prop-missing] code
error.code = 'ENOENT';
throw error;
}
if (typeof entry.link !== 'string') {
throw new Error(`Not a symlink: '${path}'.`);
}
return entry.link;
}),
writeFileSync: jest.fn((path, data, options) => {
expect(options).toBe(require('v8').serialize ? undefined : 'utf8');
mockFs[path] = data;
Expand Down Expand Up @@ -749,6 +762,50 @@ describe('FileMap', () => {
);
});

test('defers symlink resolution for entries with null mtime', async () => {
const node = require('../crawlers/node').default;
const fsModule = require('fs');

// $FlowFixMe[prop-missing]
// $FlowFixMe[missing-local-annot]
node.mockImplementation(options => {
const changedFiles = createMap<FileMetadata>({
[path.join('fruits', 'Strawberry.js')]: [32, 42, 0, null, 0, null],
[path.join('fruits', 'LinkToStrawberry.js')]: [
null,
0,
0,
null,
1,
null,
],
});
return Promise.resolve({changedFiles, removedFiles: new Set()});
});

const {fileSystem} = await buildNewFileMap({
enableSymlinks: true,
useWatchman: false,
});

expect(fsModule.promises.readlink).not.toHaveBeenCalledWith(
expect.stringContaining('LinkToStrawberry'),
);

expect(
fileSystem.lookup(
path.join('/', 'project', 'fruits', 'LinkToStrawberry.js'),
),
).toMatchObject({
exists: true,
realPath: path.join('/', 'project', 'fruits', 'Strawberry.js'),
});

expect(fsModule.readlinkSync).toHaveBeenCalledWith(
path.join('/', 'project', 'fruits', 'LinkToStrawberry.js'),
);
});

test('handles a Haste module moving between builds', async () => {
mockFs = object({
[path.join('/', 'project', 'vegetables', 'Melon.js')]: `
Expand Down
73 changes: 42 additions & 31 deletions packages/metro-file-map/src/crawlers/__tests__/integration-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,44 +80,55 @@ function oneOf(this: $FlowFixMe, actual: unknown, ...expectOneOf: unknown[]) {
* https://fburl.com/gdoc/y8dn025u */
expect.extend({oneOf});

const CASES = [
[
true,
new Map([
['foo.js', [expect.any(Number), 245, 0, null, 0, null]],
[
join('directory', 'bar.js'),
[expect.any(Number), 245, 0, null, 0, null],
],
[
'link-to-directory',
[expect.any(Number), 9, 0, null, expect.oneOf(1, 'directory'), null],
],
[
'link-to-foo.js',
[expect.any(Number), 6, 0, null, expect.oneOf(1, 'foo.js'), null],
],
]),
],
[
false,
new Map([
[
join('directory', 'bar.js'),
[expect.any(Number), 245, 0, null, 0, null],
],
['foo.js', [expect.any(Number), 245, 0, null, 0, null]],
]),
],
];
function getCases(skipsStat: boolean) {
const fileEntry = skipsStat
? [null, 0, 0, null, 0, null]
: [expect.any(Number), 245, 0, null, 0, null];
return [
[
true,
new Map([
['foo.js', fileEntry],
[join('directory', 'bar.js'), fileEntry],
[
'link-to-directory',
skipsStat
? [null, 0, 0, null, 1, null]
: [
expect.any(Number),
9,
0,
null,
expect.oneOf(1, 'directory'),
null,
],
],
[
'link-to-foo.js',
skipsStat
? [null, 0, 0, null, 1, null]
: [expect.any(Number), 6, 0, null, expect.oneOf(1, 'foo.js'), null],
],
]),
],
[
false,
new Map([
[join('directory', 'bar.js'), fileEntry],
['foo.js', fileEntry],
]),
],
];
}

describe.each(Object.keys(CRAWLERS))(
'Crawler integration tests (%s)',
crawlerName => {
const crawl = CRAWLERS[crawlerName];
const maybeTest = crawl ? test : test.skip;
const skipsStat = crawlerName === 'node-recursive';

maybeTest.each(CASES)(
maybeTest.each(getCases(skipsStat))(
'Finds the expected files (includeSymlinks: %s)',
async (includeSymlinks, expectedChangedFiles) => {
invariant(crawl, 'crawl should not be null within maybeTest');
Expand Down
118 changes: 108 additions & 10 deletions packages/metro-file-map/src/crawlers/__tests__/node-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,8 @@ describe('node crawler', () => {
);
expect(changedFiles).toEqual(
createMap({
'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null],
'fruits/tomato.js': [32, 42, 0, null, 0, null],
'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null],
'fruits/tomato.js': [null, 0, 0, null, 0, null],
}),
);
expect(removedFiles).toEqual(new Set());
Expand All @@ -297,8 +297,8 @@ describe('node crawler', () => {

expect(changedFiles).toEqual(
createMap({
'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null],
'fruits/tomato.js': [32, 42, 0, null, 0, null],
'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null],
'fruits/tomato.js': [null, 0, 0, null, 0, null],
}),
);
expect(removedFiles).toEqual(new Set());
Expand All @@ -321,8 +321,8 @@ describe('node crawler', () => {
expect(childProcess.spawn).toHaveBeenCalledTimes(0);
expect(changedFiles).toEqual(
createMap({
'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null],
'fruits/tomato.js': [32, 42, 0, null, 0, null],
'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null],
'fruits/tomato.js': [null, 0, 0, null, 0, null],
}),
);
expect(removedFiles).toEqual(new Set());
Expand Down Expand Up @@ -386,17 +386,115 @@ describe('node crawler', () => {

expect(changedFiles).toEqual(
createMap({
'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null],
'fruits/tomato.js': [32, 42, 0, null, 0, null],
'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null],
'fruits/tomato.js': [null, 0, 0, null, 0, null],
}),
);
expect(removedFiles).toEqual(new Set());
// once for /project/fruits, once for /project/fruits/directory
expect(fs.readdir).toHaveBeenCalledTimes(2);
// once for strawberry.js, once for tomato.js
expect(fs.lstat).toHaveBeenCalledTimes(0);
});

test('skips lstat for files with no prior mtime', async () => {
nodeCrawl = require('../node').default;
const fs = require('graceful-fs');

const files = createMap({
'fruits/tomato.js': [null, 0, 0, null, 0, null],
'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null],
});

const {changedFiles, removedFiles} = await nodeCrawl({
console: global.console,
previousState: {fileSystem: getFS(files)},
extensions: ['js'],
forceNodeFilesystemAPI: true,
ignore: pearMatcher,
rootDir,
roots: ['/project/fruits'],
});

expect(changedFiles).toEqual(new Map());
expect(removedFiles).toEqual(new Set());
expect(fs.lstat).toHaveBeenCalledTimes(0);
});

test('calls lstat only for files with existing mtime', async () => {
nodeCrawl = require('../node').default;
const fs = require('graceful-fs');

const files = createMap({
'fruits/tomato.js': [31, 42, 1, null, 0, null],
'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null],
});

const {changedFiles, removedFiles} = await nodeCrawl({
console: global.console,
previousState: {fileSystem: getFS(files)},
extensions: ['js'],
forceNodeFilesystemAPI: true,
ignore: pearMatcher,
rootDir,
roots: ['/project/fruits'],
});

expect(changedFiles).toEqual(
createMap({
'fruits/tomato.js': [32, 42, 0, null, 0, null],
}),
);
expect(removedFiles).toEqual(new Set());
expect(fs.lstat).toHaveBeenCalledTimes(1);
});

test('excludes unchanged files when lstat mtime matches cache', async () => {
nodeCrawl = require('../node').default;
const fs = require('graceful-fs');

const files = createMap({
'fruits/tomato.js': [32, 42, 1, null, 0, null],
'fruits/directory/strawberry.js': [33, 42, 1, null, 0, null],
});

const {changedFiles, removedFiles} = await nodeCrawl({
console: global.console,
previousState: {fileSystem: getFS(files)},
extensions: ['js'],
forceNodeFilesystemAPI: true,
ignore: pearMatcher,
rootDir,
roots: ['/project/fruits'],
});

expect(changedFiles).toEqual(new Map());
expect(removedFiles).toEqual(new Set());
expect(fs.lstat).toHaveBeenCalledTimes(2);
});

test('marks symlinks correctly when stat is skipped', async () => {
nodeCrawl = require('../node').default;

const {changedFiles} = await nodeCrawl({
console: global.console,
previousState: {fileSystem: emptyFS},
extensions: ['js'],
forceNodeFilesystemAPI: true,
includeSymlinks: true,
ignore: pearMatcher,
rootDir,
roots: ['/project/fruits'],
});

expect(changedFiles.get(normalize('fruits/symlink'))).toEqual([
null,
0,
0,
null,
1,
null,
]);
});

test('aborts the crawl on pre-aborted signal', async () => {
nodeCrawl = require('../node').default;
const err = new Error('aborted for test');
Expand Down
43 changes: 28 additions & 15 deletions packages/metro-file-map/src/crawlers/node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
CrawlerOptions,
CrawlResult,
FileData,
FileSystem,
IgnoreMatcher,
} from '../../flow-types';

Expand All @@ -36,6 +37,7 @@ function find(
includeSymlinks: boolean,
rootDir: string,
console: Console,
previousFileSystem: FileSystem | null,
callback: Callback,
): void {
const result: FileData = new Map();
Expand All @@ -58,7 +60,8 @@ function find(
return;
}

if (entry.isSymbolicLink() && !includeSymlinks) {
const isSymlink = entry.isSymbolicLink();
if (isSymlink && !includeSymlinks) {
return;
}

Expand All @@ -67,29 +70,38 @@ function find(
return;
}

activeCalls++;

fs.lstat(file, (err, stat) => {
activeCalls--;
const ext = path.extname(file).substr(1);
if (!isSymlink && !extensions.includes(ext)) {
return;
}

if (!err && stat) {
const ext = path.extname(file).substr(1);
if (stat.isSymbolicLink() || extensions.includes(ext)) {
result.set(pathUtils.absoluteToNormal(file), [
const fileNormal = pathUtils.absoluteToNormal(file);
const mtime = previousFileSystem?.getMtimeByNormalPath(fileNormal);
if (mtime == null || mtime === 0) {
// When we're in a cold start or a previous file doesn't exist, we can skip
// the mtime/size lstat now and treat the file as new
result.set(fileNormal, [null, 0, 0, null, isSymlink ? 1 : 0, null]);
} else {
activeCalls++;
fs.lstat(file, (err, stat) => {
activeCalls--;

if (!err && stat) {
result.set(fileNormal, [
stat.mtime.getTime(),
stat.size,
0,
null,
stat.isSymbolicLink() ? 1 : 0,
isSymlink ? 1 : 0,
null,
]);
}
}

if (activeCalls === 0) {
callback(result);
}
});
if (activeCalls === 0) {
callback(result);
}
});
}
});
}

Expand Down Expand Up @@ -232,6 +244,7 @@ export default async function nodeCrawl(
includeSymlinks,
rootDir,
console,
previousState.fileSystem,
callback,
);
}
Expand Down
Loading
Loading