Skip to content

Commit

Permalink
Track duplicate mocks, replace throwOnModuleCollision mutation with e…
Browse files Browse the repository at this point in the history
…xplicit validation
  • Loading branch information
robhogan committed Dec 13, 2024
1 parent 664c5d6 commit 82048b7
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 34 deletions.
12 changes: 9 additions & 3 deletions packages/metro-file-map/src/flow-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export type BuildResult = {

export type CacheData = $ReadOnly<{
clocks: WatchmanClocks,
mocks: RawMockMap,
mocks: ?RawMockMap,
fileSystemData: mixed,
}>;

Expand Down Expand Up @@ -312,9 +312,15 @@ export interface MutableFileSystem extends FileSystem {

export type Path = string;

export type RawMockMap = Map<string, Path>;
export type RawMockMap = $ReadOnly<{
duplicates: Map<string, Set<string>>,
mocks: Map<string, Path>,
}>;

export type ReadOnlyRawMockMap = $ReadOnlyMap<string, Path>;
export type ReadOnlyRawMockMap = $ReadOnly<{
duplicates: $ReadOnlyMap<string, $ReadOnlySet<string>>,
mocks: $ReadOnlyMap<string, Path>,
}>;

export type WatchmanClockSpec =
| string
Expand Down
10 changes: 6 additions & 4 deletions packages/metro-file-map/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export type {
// This should be bumped whenever a code change to `metro-file-map` itself
// would cause a change to the cache data structure and/or content (for a given
// filesystem state and build parameters).
const CACHE_BREAKER = '7';
const CACHE_BREAKER = '8';

const CHANGE_INTERVAL = 30;
// Periodically yield to the event loop to allow parallel I/O, etc.
Expand Down Expand Up @@ -380,7 +380,7 @@ export default class FileMap extends EventEmitter {
? new MockMapImpl({
console: this._console,
mocksPattern: this._options.mocksPattern,
rawMockMap: initialData?.mocks ?? new Map(),
rawMockMap: initialData?.mocks,
rootDir,
throwOnModuleCollision: this._options.throwOnModuleCollision,
})
Expand All @@ -389,6 +389,9 @@ export default class FileMap extends EventEmitter {
// Update `fileSystem`, `hasteMap` and `mocks` based on the file delta.
await this._applyFileDelta(fileSystem, hasteMap, mockMap, fileDelta);

// Validate the state of the mock map before persisting it.
mockMap?.assertValid();

await this._takeSnapshotAndPersist(
fileSystem,
fileDelta.clocks ?? new Map(),
Expand Down Expand Up @@ -740,7 +743,7 @@ export default class FileMap extends EventEmitter {
{
fileSystemData: fileSystem.getSerializableSnapshot(),
clocks: new Map(clocks),
mocks: mockMap ? mockMap.getSerializableSnapshot() : new Map(),
mocks: mockMap ? mockMap.getSerializableSnapshot() : null,
},
{changed, removed},
);
Expand Down Expand Up @@ -820,7 +823,6 @@ export default class FileMap extends EventEmitter {
// In watch mode, we'll only warn about module collisions and we'll retain
// all files, even changes to node_modules.
hasteMap.setThrowOnModuleCollision(false);
mockMap?.setThrowOnModuleCollision(false);
this._options.retainAllFiles = true;

const hasWatchedExtension = (filePath: string) =>
Expand Down
100 changes: 75 additions & 25 deletions packages/metro-file-map/src/lib/MockMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {MockMap as IMockMap, Path, RawMockMap} from '../flow-types';
import getMockName from '../getMockName';
import {DuplicateError} from './DuplicateError';
import {RootPathUtils} from './RootPathUtils';
import nullthrows from 'nullthrows';
import path from 'path';

export default class MockMap implements IMockMap {
Expand All @@ -33,21 +34,25 @@ export default class MockMap implements IMockMap {
}: {
console: typeof console,
mocksPattern: RegExp,
rawMockMap: RawMockMap,
rawMockMap?: ?RawMockMap,
rootDir: Path,
throwOnModuleCollision: boolean,
}) {
this.#mocksPattern = mocksPattern;
this.#raw = rawMockMap;
this.#raw = rawMockMap ?? {mocks: new Map(), duplicates: new Map()};
this.#rootDir = rootDir;
this.#console = console;
this.#pathUtils = new RootPathUtils(rootDir);
this.#throwOnModuleCollision = throwOnModuleCollision;
}

getMockModule(name: string): ?Path {
const mockPath = this.#raw.get(name) || this.#raw.get(name + '/index');
return mockPath != null ? this.#pathUtils.normalToAbsolute(mockPath) : null;
const mockPath =
this.#raw.mocks.get(name) || this.#raw.mocks.get(name + '/index');
if (typeof mockPath !== 'string') {
return null;
}
return this.#pathUtils.normalToAbsolute(mockPath);
}

onNewOrModifiedFile(absoluteFilePath: Path): void {
Expand All @@ -56,45 +61,90 @@ export default class MockMap implements IMockMap {
}

const mockName = getMockName(absoluteFilePath);
const existingMockPath = this.#raw.get(mockName);
const existingMockPath = this.#raw.mocks.get(mockName);
const newMockPath = this.#pathUtils.absoluteToNormal(absoluteFilePath);

if (existingMockPath != null) {
if (existingMockPath !== newMockPath) {
const method = this.#throwOnModuleCollision ? 'error' : 'warn';

this.#console[method](
[
'metro-file-map: duplicate manual mock found: ' + mockName,
' The following files share their name; please delete one of them:',
' * <rootDir>' + path.sep + existingMockPath,
' * <rootDir>' + path.sep + newMockPath,
'',
].join('\n'),
);

if (this.#throwOnModuleCollision) {
throw new DuplicateError(existingMockPath, newMockPath);
let duplicates = this.#raw.duplicates.get(mockName);
if (duplicates == null) {
duplicates = new Set([existingMockPath, newMockPath]);
this.#raw.duplicates.set(mockName, duplicates);
} else {
duplicates.add(newMockPath);
}

this.#console.warn(this.#getMessageForDuplicates(mockName, duplicates));
}
}

this.#raw.set(mockName, newMockPath);
// If there are duplicates and we don't throw, the latest mock wins.
// This is to preserve backwards compatibility, but it's unpredictable.
this.#raw.mocks.set(mockName, newMockPath);
}

onRemovedFile(absoluteFilePath: Path): void {
if (!this.#mocksPattern.test(absoluteFilePath)) {
return;
}
const mockName = getMockName(absoluteFilePath);
this.#raw.delete(mockName);
const duplicates = this.#raw.duplicates.get(mockName);
if (duplicates != null) {
const relativePath = this.#pathUtils.absoluteToNormal(absoluteFilePath);
duplicates.delete(relativePath);
if (duplicates.size === 1) {
this.#raw.duplicates.delete(mockName);
}
// Set the mock to a remaining duplicate. Should never be empty.
const remaining = nullthrows(duplicates.values().next().value);
this.#raw.mocks.set(mockName, remaining);
} else {
this.#raw.mocks.delete(mockName);
}
}

setThrowOnModuleCollision(throwOnModuleCollision: boolean): void {
this.#throwOnModuleCollision = throwOnModuleCollision;
getSerializableSnapshot(): RawMockMap {
return {
mocks: new Map(this.#raw.mocks),
duplicates: new Map(
[...this.#raw.duplicates].map(([k, v]) => [k, new Set(v)]),
),
};
}

getSerializableSnapshot(): RawMockMap {
return new Map(this.#raw);
assertValid(): void {
if (!this.#throwOnModuleCollision) {
return;
}
// Throw an aggregate error for each duplicate.
const errors = [];
for (const [mockName, relativePaths] of this.#raw.duplicates) {
errors.push(this.#getMessageForDuplicates(mockName, relativePaths));
}
if (errors.length > 0) {
throw new Error(
`Mock map has ${errors.length} error${errors.length > 1 ? 's' : ''}:\n${errors.join('\n')}`,
);
}
}

#getMessageForDuplicates(
mockName: string,
duplicates: $ReadOnlySet<string>,
): string {
return (
'Duplicate manual mock found for `' +
mockName +
'`:\n' +
[...duplicates]
.map(
relativePath =>
' * <rootDir>' +
path.sep +
this.#pathUtils.absoluteToNormal(relativePath) +
'\n',
)
.join('')
);
}
}
88 changes: 88 additions & 0 deletions packages/metro-file-map/src/lib/__tests__/MockMap-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* 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 MockMapType from '../MockMap';

let mockPathModule;
jest.mock('path', () => mockPathModule);

describe.each([['win32'], ['posix']])('MockMap on %s', platform => {
const p: string => string = filePath =>
platform === 'win32'
? filePath.replace(/\//g, '\\').replace(/^\\/, 'C:\\')
: filePath;

let MockMap: Class<MockMapType>;

const opts = {
console,
mocksPattern: /__mocks__[\/\\].+\.(js|json)$/,
rootDir: p('/root'),
throwOnModuleCollision: true,
};

beforeEach(() => {
jest.resetModules();
mockPathModule = jest.requireActual<{}>('path')[platform];
MockMap = require('../MockMap').default;
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.clearAllMocks();
});

test('set and get a mock module', () => {
const mockMap = new MockMap(opts);
mockMap.onNewOrModifiedFile(p('/root/__mocks__/foo.js'));
expect(mockMap.getMockModule('foo')).toBe(p('/root/__mocks__/foo.js'));
});

test('assertValid throws on duplicates', () => {
const mockMap = new MockMap(opts);
mockMap.onNewOrModifiedFile(p('/root/__mocks__/foo.js'));
mockMap.onNewOrModifiedFile(p('/root/other/__mocks__/foo.js'));

expect(console.warn).toHaveBeenCalledTimes(1);
expect(() => mockMap.assertValid()).toThrowError(
`Mock map has 1 error:
Duplicate manual mock found for \`foo\`:
* <rootDir>/../../__mocks__/foo.js
* <rootDir>/../../other/__mocks__/foo.js
`.replaceAll('/', mockPathModule.sep),
);
});

test('recovers from duplicates', () => {
const mockMap = new MockMap(opts);
mockMap.onNewOrModifiedFile(p('/root/__mocks__/foo.js'));
mockMap.onNewOrModifiedFile(p('/root/other/__mocks__/foo.js'));

expect(() => mockMap.assertValid()).toThrow();

expect(mockMap.getMockModule('foo')).toBe(p('/root/__mocks__/foo.js'));

expect(mockMap.getSerializableSnapshot()).toEqual({
mocks: new Map([['foo', p('other/__mocks__/foo.js')]]),
duplicates: new Map([
['foo', new Set([p('other/__mocks__/foo.js'), p('__mocks__/foo.js')])],
]),
});

mockMap.onRemovedFile(p('/root/other/__mocks__/foo.js'));

expect(() => mockMap.assertValid()).not.toThrow();

expect(mockMap.getMockModule('foo')).toBe(p('/root/__mocks__/foo.js'));

expect(mockMap.getSerializableSnapshot()).toEqual({
mocks: new Map([['foo', p('__mocks__/foo.js')]]),
duplicates: new Map(),
});
});
});
8 changes: 6 additions & 2 deletions packages/metro-file-map/types/flow-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface BuildResult {

export interface CacheData {
readonly clocks: WatchmanClocks;
readonly mocks: MockData;
readonly mocks: RawMockMap;
readonly files: FileData;
}

Expand Down Expand Up @@ -278,7 +278,11 @@ export interface HasteMap {
computeConflicts(): Array<HasteConflict>;
}

export type MockData = Map<string, Path>;
export type RawMockMap = {
readonly mocks: Map<string, string>;
readonly duplicates: Map<string, Set<string>>;
};

export type HasteMapData = Map<string, HasteMapItem>;

export interface HasteMapItem {
Expand Down

0 comments on commit 82048b7

Please sign in to comment.