Simplify Module.js

Reviewed By: rafeca

Differential Revision: D7628732

fbshipit-source-id: 50f5612d94727190c372148eba1a01f245f21d73
This commit is contained in:
Miguel Jimenez Esun 2018-04-19 09:06:49 -07:00 committed by Facebook Github Bot
parent 16e843ef98
commit 01827a0fab
6 changed files with 244 additions and 826 deletions

View File

@ -291,12 +291,6 @@ class Bundler {
}
}
if (!cache && code == null) {
throw new Error(
'When not using experimental caches, code should always be provided',
);
}
// Second, if there was no result, compute it ourselves.
if (!data) {
data = await this._transformer.transform(

View File

@ -7,7 +7,7 @@ Array [
"a",
"b",
],
"id": "index",
"id": "index.js",
"isAsset": false,
"isPolyfill": false,
"path": "/root/index.js",
@ -15,7 +15,7 @@ Array [
},
Object {
"dependencies": Array [],
"id": "a",
"id": "a.js",
"isAsset": false,
"isPolyfill": false,
"path": "/root/a.js",
@ -23,7 +23,7 @@ Array [
},
Object {
"dependencies": Array [],
"id": "b",
"id": "b.js",
"isAsset": false,
"isPolyfill": false,
"path": "/root/b.js",

View File

@ -12,9 +12,6 @@
const Module = require('./Module');
import type {TransformedCode} from '../JSTransformer/worker';
import type {ReadResult} from './Module';
class AssetModule extends Module {
getPackage() {
return null;
@ -28,12 +25,12 @@ class AssetModule extends Module {
return true;
}
_finalizeReadResult(source: string, result: TransformedCode): ReadResult {
_readSourceCode() {
// We do not want to return the "source code" of assets, since it's going to
// be binary data and can potentially be very large. This source property
// is only used to generate the sourcemaps (since we include all the
// modules original sources in the sourcemaps).
return {...result, source: ''};
return '';
}
}

View File

@ -10,26 +10,13 @@
'use strict';
const crypto = require('crypto');
const docblock = require('jest-docblock');
const fs = require('fs');
const invariant = require('fbjs/lib/invariant');
const isAbsolutePath = require('absolute-path');
const jsonStableStringify = require('json-stable-stringify');
const path = require('path');
import type {
TransformedCode,
Options as WorkerOptions,
} from '../JSTransformer/worker';
import type {GlobalTransformCache} from '../lib/GlobalTransformCache';
import type {
TransformCache,
GetTransformCacheKey,
ReadTransformProps,
} from '../lib/TransformCaching';
import type {Reporter} from '../lib/reporting';
import type DependencyGraphHelpers from './DependencyGraph/DependencyGraphHelpers';
import type ModuleCache from './ModuleCache';
import type {LocalPath} from './lib/toLocalPath';
import type {MetroSourceMapSegmentTuple} from 'metro-source-map';
@ -49,67 +36,23 @@ export type TransformCode = (
transformOptions: WorkerOptions,
) => Promise<TransformedCode>;
export type HasteImpl = {
getHasteName(filePath: string): string | void,
// This exists temporarily to enforce consistency while we deprecate
// @providesModule.
enforceHasteNameMatches?: (
filePath: string,
expectedName: string | void,
) => void,
};
export type Options = {
globalTransformCache: ?GlobalTransformCache,
hasteImplModulePath?: string,
reporter: Reporter,
resetCache: boolean,
transformCache: TransformCache,
};
export type ConstructorArgs = {
depGraphHelpers: DependencyGraphHelpers,
experimentalCaches: boolean,
file: string,
getTransformCacheKey: GetTransformCacheKey,
localPath: LocalPath,
moduleCache: ModuleCache,
options: Options,
transformCode: TransformCode,
};
type DocBlock = {+[key: string]: string};
class Module {
localPath: LocalPath;
path: string;
type: string;
_experimentalCaches: boolean;
_moduleCache: ModuleCache;
_transformCode: TransformCode;
_getTransformCacheKey: GetTransformCacheKey;
_depGraphHelpers: DependencyGraphHelpers;
_options: Options;
_docBlock: ?DocBlock;
_hasteNameCache: ?{+hasteName: ?string};
_sourceCode: ?string;
_readPromises: Map<string, Promise<ReadResult>>;
_readResultsByOptionsKey: Map<string, CachedReadResult>;
constructor({
depGraphHelpers,
experimentalCaches,
file,
getTransformCacheKey,
localPath,
moduleCache,
options,
transformCode,
}: ConstructorArgs) {
constructor({file, localPath, moduleCache, transformCode}: ConstructorArgs) {
if (!isAbsolutePath(file)) {
throw new Error('Expected file to be absolute path but got ' + file);
}
@ -118,295 +61,59 @@ class Module {
this.path = file;
this.type = 'Module';
this._experimentalCaches = experimentalCaches;
this._moduleCache = moduleCache;
this._transformCode = transformCode;
this._getTransformCacheKey = getTransformCacheKey;
this._depGraphHelpers = depGraphHelpers;
this._options = options || {};
this._readPromises = new Map();
this._readResultsByOptionsKey = new Map();
}
isHaste(): boolean {
return this._getHasteName() != null;
return false;
}
getName(): string {
// TODO: T26134860 Used for debugging purposes only; disabled with the new
// caches.
if (this._experimentalCaches) {
return path.basename(this.path);
}
if (this.isHaste()) {
const name = this._getHasteName();
if (name != null) {
return name;
}
}
const p = this.getPackage();
if (!p) {
// Name is local path
return this.localPath;
}
const packageName = p.getName();
if (!packageName) {
return this.path;
}
return path
.join(packageName, path.relative(p.root, this.path))
.replace(/\\/g, '/');
return this.localPath;
}
getPackage() {
return this._moduleCache.getPackageForModule(this);
}
/**
* We don't need to invalidate the TranformCache itself because it guarantees
* itself that if a source code changed we won't return the cached transformed
* code.
*/
invalidate() {
this._sourceCode = null;
// TODO: T26134860 Caches present in Module are not used with experimental
// caches, except for the one related to source code.
if (this._experimentalCaches) {
return;
}
this._readPromises.clear();
this._readResultsByOptionsKey.clear();
this._docBlock = null;
this._hasteNameCache = null;
}
_readSourceCode(): string {
if (this._sourceCode == null) {
this._sourceCode = fs.readFileSync(this.path, 'utf8');
}
return this._sourceCode;
}
_readDocBlock(): DocBlock {
if (this._docBlock == null) {
this._docBlock = docblock.parse(docblock.extract(this._readSourceCode()));
}
return this._docBlock;
async read(transformOptions: WorkerOptions): Promise<ReadResult> {
const result: TransformedCode = await this._transformCode(
this,
null, // Source code is read on the worker
transformOptions,
);
const module = this;
return {
code: result.code,
dependencies: result.dependencies,
map: result.map,
get source() {
return module._readSourceCode();
},
};
}
_getHasteName(): ?string {
if (this._hasteNameCache == null) {
this._hasteNameCache = {hasteName: this._readHasteName()};
}
return this._hasteNameCache.hasteName;
}
/**
* If a custom Haste implementation is provided, then we use it to determine
* the actual Haste name instead of "@providesModule".
* `enforceHasteNameMatches` has been added to that it is easier to
* transition from a system using "@providesModule" to a system using another
* custom system, by throwing if inconsistencies are detected. For example,
* we could verify that the file's basename (ex. "bar/foo.js") is the same as
* the "@providesModule" name (ex. "foo").
*/
_readHasteName(): ?string {
const hasteImplModulePath = this._options.hasteImplModulePath;
if (hasteImplModulePath == null) {
return this._readHasteNameFromDocBlock();
}
// eslint-disable-next-line no-useless-call
const HasteImpl = (require.call(null, hasteImplModulePath): HasteImpl);
if (HasteImpl.enforceHasteNameMatches != null) {
const name = this._readHasteNameFromDocBlock();
HasteImpl.enforceHasteNameMatches(this.path, name || undefined);
}
return HasteImpl.getHasteName(this.path);
}
/**
* We extract the Haste name from the `@providesModule` docbloc field. This is
* not allowed for modules living in `node_modules`, except if they are
* whitelisted.
*/
_readHasteNameFromDocBlock(): ?string {
const moduleDocBlock = this._readDocBlock();
const {providesModule} = moduleDocBlock;
if (providesModule && !this._depGraphHelpers.isNodeModulesDir(this.path)) {
return /^\S+/.exec(providesModule)[0];
}
readCached(transformOptions: WorkerOptions): null {
return null;
}
/**
* To what we read from the cache or worker, we need to add id and source.
*/
_finalizeReadResult(source: string, result: TransformedCode): ReadResult {
return {...result, source};
}
async _transformCodeFor(
cacheProps: ReadTransformProps,
): Promise<TransformedCode> {
const {_transformCode} = this;
invariant(_transformCode != null, 'missing code transform funtion');
const {sourceCode, transformOptions} = cacheProps;
return await _transformCode(this, sourceCode, transformOptions);
}
async _transformAndStoreCodeGlobally(
cacheProps: ReadTransformProps,
globalCache: GlobalTransformCache,
): Promise<TransformedCode> {
const result = await this._transformCodeFor(cacheProps);
globalCache.store(globalCache.keyOf(cacheProps), result);
return result;
}
async _getTransformedCode(
cacheProps: ReadTransformProps,
): Promise<TransformedCode> {
const globalCache = this._options.globalTransformCache;
if (globalCache == null || !globalCache.shouldFetch(cacheProps)) {
return await this._transformCodeFor(cacheProps);
}
const globalCachedResult = await globalCache.fetch(
globalCache.keyOf(cacheProps),
);
if (globalCachedResult != null) {
return globalCachedResult;
}
return await this._transformAndStoreCodeGlobally(cacheProps, globalCache);
}
async _getAndCacheTransformedCode(
cacheProps: ReadTransformProps,
): Promise<TransformedCode> {
const result = await this._getTransformedCode(cacheProps);
this._options.transformCache.writeSync({...cacheProps, result});
return result;
}
/**
* Shorthand for reading both from cache or from fresh for all call sites that
* are asynchronous by default.
*/
async read(transformOptions: WorkerOptions): Promise<ReadResult> {
// TODO: T26134860 Cache layer lives inside the transformer now; just call
// the transform method.
if (this._experimentalCaches) {
const result: TransformedCode = await this._transformCode(
this,
null, // Source code is read on the worker
transformOptions,
);
const module = this;
return {
code: result.code,
dependencies: result.dependencies,
map: result.map,
get source() {
return module._readSourceCode();
},
};
}
const cached = this.readCached(transformOptions);
if (cached != null) {
return cached;
}
return this.readFresh(transformOptions);
}
/**
* Same as `readFresh`, but reads from the cache instead of transforming
* the file from source. This has the benefit of being synchronous. As a
* result it is possible to read many cached Module in a row, synchronously.
*/
readCached(transformOptions: WorkerOptions): CachedReadResult {
const key = stableObjectHash(transformOptions || {});
let result = this._readResultsByOptionsKey.get(key);
if (result != null) {
return result;
}
result = this._readFromTransformCache(transformOptions, key);
this._readResultsByOptionsKey.set(key, result);
return result;
}
/**
* Read again from the TransformCache, on disk. `readCached` should be favored
* so it's faster in case the results are already in memory.
*/
_readFromTransformCache(
transformOptions: WorkerOptions,
transformOptionsKey: string,
): CachedReadResult {
const cacheProps = this._getCacheProps(
transformOptions,
transformOptionsKey,
);
const cachedResult = this._options.transformCache.readSync(cacheProps);
if (cachedResult == null) {
return null;
}
return this._finalizeReadResult(cacheProps.sourceCode, cachedResult);
}
/**
* Gathers relevant data about a module: source code, transformed code,
* dependencies, etc. This function reads and transforms the source from
* scratch. We don't repeat the same work as `readCached` because we assume
* call sites have called it already.
*/
readFresh(transformOptions: WorkerOptions): Promise<ReadResult> {
const key = stableObjectHash(transformOptions || {});
const promise = this._readPromises.get(key);
if (promise != null) {
return promise;
}
const freshPromise = (async () => {
const cacheProps = this._getCacheProps(transformOptions, key);
const freshResult = await this._getAndCacheTransformedCode(cacheProps);
const finalResult = this._finalizeReadResult(
cacheProps.sourceCode,
freshResult,
);
this._readResultsByOptionsKey.set(key, finalResult);
return finalResult;
})();
this._readPromises.set(key, freshPromise);
return freshPromise;
}
_getCacheProps(transformOptions: WorkerOptions, transformOptionsKey: string) {
const sourceCode = this._readSourceCode();
const getTransformCacheKey = this._getTransformCacheKey;
return {
filePath: this.path,
localPath: this.localPath,
sourceCode,
getTransformCacheKey,
transformOptions,
transformOptionsKey,
cacheOptions: {
resetCache: this._options.resetCache,
reporter: this._options.reporter,
},
};
return this.read(transformOptions);
}
hash() {
@ -422,19 +129,4 @@ class Module {
}
}
// use weak map to speed up hash creation of known objects
const knownHashes = new WeakMap();
function stableObjectHash(object) {
let digest = knownHashes.get(object);
if (!digest) {
digest = crypto
.createHash('md5')
.update(jsonStableStringify(object))
.digest('base64');
knownHashes.set(object, digest);
}
return digest;
}
module.exports = Module;

View File

@ -10,363 +10,94 @@
'use strict';
jest
.mock('fs', () => new (require('metro-memory-fs'))())
.mock('graceful-fs')
.mock('../ModuleCache')
.mock('../DependencyGraph/DependencyGraphHelpers')
.mock('../../lib/TransformCaching');
jest.mock('fs').mock('../ModuleCache');
const Module = require('../Module');
const ModuleCache = require('../ModuleCache');
const DependencyGraphHelpers = require('../DependencyGraph/DependencyGraphHelpers');
const TransformCaching = require('../../lib/TransformCaching');
const fs = require('fs');
const packageJson = JSON.stringify({
name: 'arbitrary',
version: '1.0.0',
description: "A require('foo') story",
});
function mockPackageFile() {
fs.reset();
fs.mkdirSync('/root');
fs.writeFileSync('/root/package.json', packageJson);
}
function mockIndexFile(indexJs) {
fs.reset();
fs.mkdirSync('/root');
fs.writeFileSync('/root/index.js', indexJs);
}
const ModuleCache = require('../ModuleCache');
const Module = require('../Module');
describe('Module', () => {
const fileName = '/root/index.js';
let transformCode;
let moduleCache;
let module;
let cache;
const transformCache = TransformCaching.mocked();
const createCache = () => ({
get: jest
.genMockFn()
.mockImplementation((filepath, field, cb) => cb(filepath)),
invalidate: jest.genMockFn(),
end: jest.genMockFn(),
});
let transformCacheKey;
const createModule = options =>
new Module({
options: {transformCache},
transformCode: (module, sourceCode, transformOptions) => {
return Promise.resolve({code: sourceCode});
},
...options,
cache,
file: (options && options.file) || fileName,
depGraphHelpers: new DependencyGraphHelpers(),
localPath: (options && options.localPath) || fileName,
moduleCache: new ModuleCache({cache}),
getTransformCacheKey: () => transformCacheKey,
beforeEach(() => {
transformCode = jest.fn().mockReturnValue({
code: 'int main(void) { return -1; }',
dependencies: ['stdlib.h', 'conio.h'],
map: [],
});
const createJSONModule = options =>
createModule({...options, file: '/root/package.json'});
moduleCache = new ModuleCache();
beforeEach(function() {
Object.defineProperty(process, 'platform', {
configurable: true,
enumerable: true,
value: 'linux',
});
cache = createCache();
transformCacheKey = 'abcdef';
transformCache.mock.reset();
});
describe('Experimental caches', () => {
it('Calls into the transformer directly when having experimental caches on', async () => {
const transformCode = jest.fn().mockReturnValue({
code: 'code',
dependencies: ['dep1', 'dep2'],
map: [],
});
const module = new Module({
cache,
experimentalCaches: true,
depGraphHelpers: new DependencyGraphHelpers(),
file: fileName,
getTransformCacheKey: () => transformCacheKey,
localPath: fileName,
moduleCache: new ModuleCache({cache}),
options: {transformCache},
transformCode,
});
mockIndexFile('originalCode');
jest.spyOn(fs, 'readFileSync');
// Read the first time, transform code is called.
const res1 = await module.read({foo: 3});
expect(res1.code).toBe('code');
expect(res1.dependencies).toEqual(['dep1', 'dep2']);
expect(transformCode).toHaveBeenCalledTimes(1);
// Read a second time, transformCode is called again!
const res2 = await module.read({foo: 3});
expect(res2.code).toBe('code');
expect(res2.dependencies).toEqual(['dep1', 'dep2']);
expect(transformCode).toHaveBeenCalledTimes(2);
// Code was never read, though, because experimental caches read on the
// worker, to speed up local cache!
expect(fs.readFileSync).not.toHaveBeenCalled();
module = new Module({
file: '/root/to/file.js',
localPath: 'file.js',
moduleCache,
transformCode,
});
});
describe('Module ID', () => {
const moduleId = 'arbitraryModule';
const source = `/**
* @providesModule ${moduleId}
*/
`;
afterEach(() => {
fs.readFileSync.mockReset();
});
let module;
beforeEach(() => {
module = createModule();
});
it('Returns the correct values for many properties and methods', () => {
expect(module.localPath).toBe('file.js');
expect(module.path).toBe('/root/to/file.js');
expect(module.type).toBe('Module');
describe('@providesModule annotations', () => {
beforeEach(() => {
mockIndexFile(source);
});
expect(module.hash()).toBeDefined();
expect(module.getName()).toBe('file.js');
expect(module.isHaste()).toBe(false);
expect(module.isAsset()).toBe(false);
expect(module.isPolyfill()).toBe(false);
});
it('extracts the module name from the header', () => {
expect(module.getName()).toEqual(moduleId);
});
it('reads the modules correctly', () => {
const opts = {};
it('identifies the module as haste module', () => {
expect(module.isHaste()).toBe(true);
});
// Caches are not in Module.js anymore.
expect(module.readCached()).toBe(null);
it('does not transform the file in order to access the name', () => {
const transformCode = jest
.genMockFn()
.mockReturnValue(Promise.resolve());
// When reading fresh, we call directly into read.
module.readFresh(opts);
expect(transformCode.mock.calls[0][0]).toBe(module);
expect(transformCode.mock.calls[0][1]).toBe(null);
expect(transformCode.mock.calls[0][2]).toBe(opts);
});
createModule({transformCode}).getName();
expect(transformCode).not.toBeCalled();
});
it('returns the result from the transform code straight away', async () => {
fs.readFileSync.mockReturnValue('original code');
it('does not transform the file in order to access the haste status', () => {
const transformCode = jest
.genMockFn()
.mockReturnValue(Promise.resolve());
createModule({transformCode}).isHaste();
expect(transformCode).not.toBeCalled();
});
});
describe('no annotation', () => {
beforeEach(() => {
mockIndexFile('arbitrary(code);');
});
it('uses the file name as module name', () => {
expect(module.getName()).toEqual(fileName);
});
it('does not identify the module as haste module', () =>
expect(module.isHaste()).toBe(false));
it('does not transform the file in order to access the name', () => {
const transformCode = jest
.genMockFn()
.mockReturnValue(Promise.resolve());
createModule({transformCode}).getName();
expect(transformCode).not.toBeCalled();
});
it('does not transform the file in order to access the haste status', () => {
const transformCode = jest
.genMockFn()
.mockReturnValue(Promise.resolve());
createModule({transformCode}).isHaste();
expect(transformCode).not.toBeCalled();
});
expect(await module.read({})).toEqual({
code: 'int main(void) { return -1; }',
dependencies: ['stdlib.h', 'conio.h'],
map: [],
source: 'original code',
});
});
describe('Code', () => {
const fileContents = 'arbitrary(code)';
beforeEach(function() {
mockIndexFile(fileContents);
});
it('checks that code is only read once until invalidated', async () => {
fs.readFileSync.mockReturnValue('original code');
it('exposes file contents as `code` property on the data exposed by `read()`', () =>
createModule()
.read()
.then(({code}) => expect(code).toBe(fileContents)));
});
// Read once. No access to "source", so no reads.
await module.read({});
expect(fs.readFileSync).toHaveBeenCalledTimes(0);
describe('Custom Code Transform', () => {
let transformCode;
let transformResult;
const fileContents = 'arbitrary(code);';
const exampleCode = `
${'require'}('a');
${'System.import'}('b');
${'require'}('c');`;
// Read again, accessing "source".
expect((await module.read({})).source).toEqual('original code');
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
beforeEach(function() {
transformResult = {code: ''};
transformCode = jest
.genMockFn()
.mockImplementation((module, sourceCode, options) => {
transformCache.writeSync({
filePath: module.path,
sourceCode,
transformOptions: options,
getTransformCacheKey: () => transformCacheKey,
result: transformResult,
});
return Promise.resolve(transformResult);
});
mockIndexFile(fileContents);
});
// Read again, accessing "source" again. Still 1 because code was cached.
expect((await module.read({})).source).toEqual('original code');
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
it('passes the module and file contents to the transform function when reading', () => {
const module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toBeCalledWith(module, fileContents, undefined);
});
});
// Invalidate.
module.invalidate();
it('passes any additional options to the transform function when reading', () => {
const module = createModule({transformCode});
const transformOptions = {arbitrary: Object()};
return module
.read(transformOptions)
.then(() =>
expect(transformCode.mock.calls[0][2]).toBe(transformOptions),
);
});
it('passes the module and file contents to the transform for JSON files', () => {
mockPackageFile();
const module = createJSONModule({transformCode});
return module.read().then(() => {
expect(transformCode).toBeCalledWith(module, packageJson, undefined);
});
});
it('does not extend the passed options object for JSON files', () => {
mockPackageFile();
const module = createJSONModule({transformCode});
const options = {arbitrary: 'foo'};
return module.read(options).then(() => {
expect(transformCode).toBeCalledWith(module, packageJson, options);
});
});
it('uses dependencies that `transformCode` resolves to, instead of extracting them', async () => {
const mockedDependencies = ['foo', 'bar'];
transformResult = {
code: exampleCode,
dependencies: mockedDependencies,
};
const module = createModule({transformCode});
const data = await module.read();
expect(data.dependencies).toEqual(mockedDependencies);
});
it('forwards all additional properties of the result provided by `transformCode`', () => {
transformResult = {
code: exampleCode,
arbitrary: 'arbitrary',
dependencyOffsets: [12, 764],
map: {version: 3},
subObject: {foo: 'bar'},
};
const module = createModule({transformCode});
return module.read().then(result => {
expect(result).toEqual(jasmine.objectContaining(transformResult));
});
});
it('exposes the transformed code rather than the raw file contents', async () => {
transformResult = {code: exampleCode};
const module = createModule({transformCode});
const data = await module.read();
expect(data.code).toBe(exampleCode);
});
it('exposes the raw file contents as `source` property', () => {
const module = createModule({transformCode});
return module.read().then(data => expect(data.source).toBe(fileContents));
});
it('exposes a source map returned by the transform', async () => {
const map = {version: 3};
transformResult = {map, code: exampleCode};
const module = createModule({transformCode});
const data = await module.read();
expect(data.map).toBe(map);
});
it('caches the transform result for the same transform options', () => {
let module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toHaveBeenCalledTimes(1);
// We want to check transform caching rather than shallow caching of
// Promises returned by read().
module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toHaveBeenCalledTimes(1);
});
});
});
it('triggers a new transform for different transform options', () => {
const module = createModule({transformCode});
return module.read({foo: 1}).then(() => {
expect(transformCode).toHaveBeenCalledTimes(1);
return module.read({foo: 2}).then(() => {
expect(transformCode).toHaveBeenCalledTimes(2);
});
});
});
it('triggers a new transform for different source code', () => {
let module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toHaveBeenCalledTimes(1);
cache = createCache();
mockIndexFile('test');
module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toHaveBeenCalledTimes(2);
});
});
});
it('triggers a new transform for different transform cache key', () => {
let module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toHaveBeenCalledTimes(1);
transformCacheKey = 'other';
module = createModule({transformCode});
return module.read().then(() => {
expect(transformCode).toHaveBeenCalledTimes(2);
});
});
});
// Read again, this time it will read it.
expect((await module.read({})).source).toEqual('original code');
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
});
});