From fe39f97ee0550065a65c8e221a5f51908922fd37 Mon Sep 17 00:00:00 2001 From: Jasper Vaneessen Date: Tue, 12 Apr 2022 11:02:30 +0200 Subject: [PATCH] refactor: Use fs-extra instead of fs to simplify file access * refactor: use fs-extra instead of fs * tests: manual mocks for fs-extra base + ensureDir * refactor: mockFileSystem + mockFs and mockFsExtra * add remove mock and some further test tweaks * test: FileDataAccessor tests passing * refactor: remove try-catch due to fs-extra handlin * refactor: fs-extra in atomicFileDataAccessor * refactor: AtomicFileDataAccessor fs-extra * test: fix coverage * refactor: use read/writeJson from fs-extra * refactor: less duplicate mocking code * refactor: re-use opendir mocking code --- .../accessors/AtomicFileDataAccessor.ts | 9 +-- src/storage/accessors/FileDataAccessor.ts | 62 ++++----------- src/storage/keyvalue/JsonFileStorage.ts | 8 +- .../TemplatedResourcesGenerator.test.ts | 4 +- test/unit/quota/PodQuotaStrategy.test.ts | 4 +- test/unit/quota/QuotaStrategy.test.ts | 4 +- .../accessors/AtomicFileDataAccessor.test.ts | 19 ++--- .../accessors/FileDataAccessor.test.ts | 23 ++++-- .../storage/keyvalue/JsonFileStorage.test.ts | 5 +- .../size-reporter/FileSizeReporter.test.ts | 4 +- .../util/templates/TemplateEngine.test.ts | 6 +- test/util/Util.ts | 77 ++++++++++++++++++- 12 files changed, 139 insertions(+), 86 deletions(-) diff --git a/src/storage/accessors/AtomicFileDataAccessor.ts b/src/storage/accessors/AtomicFileDataAccessor.ts index 6eb5f4ac5..ca4b4116c 100644 --- a/src/storage/accessors/AtomicFileDataAccessor.ts +++ b/src/storage/accessors/AtomicFileDataAccessor.ts @@ -1,5 +1,5 @@ -import { mkdirSync, promises as fsPromises } from 'fs'; import type { Readable } from 'stream'; +import { ensureDirSync, rename, unlink } from 'fs-extra'; import { v4 } from 'uuid'; import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; @@ -20,8 +20,7 @@ export class AtomicFileDataAccessor extends FileDataAccessor implements AtomicDa public constructor(resourceMapper: FileIdentifierMapper, rootFilePath: string, tempFilePath: string) { super(resourceMapper); this.tempFilePath = joinFilePath(rootFilePath, tempFilePath); - // Cannot use fsPromises in constructor - mkdirSync(this.tempFilePath, { recursive: true }); + ensureDirSync(this.tempFilePath); } /** @@ -45,12 +44,12 @@ export class AtomicFileDataAccessor extends FileDataAccessor implements AtomicDa await this.verifyExistingExtension(link); // When no quota errors occur move the file to its desired location - await fsPromises.rename(tempFilePath, link.filePath); + await rename(tempFilePath, link.filePath); } catch (error: unknown) { // Delete the data already written try { if ((await this.getStats(tempFilePath)).isFile()) { - await fsPromises.unlink(tempFilePath); + await unlink(tempFilePath); } } catch { throw error; diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index fd6cebc56..84175442e 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -1,6 +1,6 @@ -import type { Stats } from 'fs'; -import { createWriteStream, createReadStream, promises as fsPromises } from 'fs'; import type { Readable } from 'stream'; +import type { Stats } from 'fs-extra'; +import { ensureDir, remove, stat, lstat, createWriteStream, createReadStream, opendir } from 'fs-extra'; import type { Quad } from 'rdf-js'; import type { Representation } from '../../http/representation/Representation'; import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; @@ -92,7 +92,7 @@ export class FileDataAccessor implements DataAccessor { // Delete the metadata if there was an error writing the file if (wroteMetadata) { const metaLink = await this.resourceMapper.mapUrlToFilePath(identifier, true); - await fsPromises.unlink(metaLink.filePath); + await remove(metaLink.filePath); } throw error; } @@ -103,14 +103,7 @@ export class FileDataAccessor implements DataAccessor { */ public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise { const link = await this.resourceMapper.mapUrlToFilePath(identifier, false); - try { - await fsPromises.mkdir(link.filePath, { recursive: true }); - } catch (error: unknown) { - // Don't throw if directory already exists - if (!isSystemError(error) || error.code !== 'EEXIST') { - throw error; - } - } + await ensureDir(link.filePath); await this.writeMetadata(link, metadata); } @@ -119,23 +112,16 @@ export class FileDataAccessor implements DataAccessor { * Removes the corresponding file/folder (and metadata file). */ public async deleteResource(identifier: ResourceIdentifier): Promise { + const metaLink = await this.resourceMapper.mapUrlToFilePath(identifier, true); + await remove(metaLink.filePath); + const link = await this.resourceMapper.mapUrlToFilePath(identifier, false); const stats = await this.getStats(link.filePath); - try { - const metaLink = await this.resourceMapper.mapUrlToFilePath(identifier, true); - await fsPromises.unlink(metaLink.filePath); - } catch (error: unknown) { - // Ignore if it doesn't exist - if (!isSystemError(error) || error.code !== 'ENOENT') { - throw error; - } - } - if (!isContainerIdentifier(identifier) && stats.isFile()) { - await fsPromises.unlink(link.filePath); + await remove(link.filePath); } else if (isContainerIdentifier(identifier) && stats.isDirectory()) { - await fsPromises.rmdir(link.filePath); + await remove(link.filePath); } else { throw new NotFoundHttpError(); } @@ -151,7 +137,7 @@ export class FileDataAccessor implements DataAccessor { */ protected async getStats(path: string): Promise { try { - return await fsPromises.stat(path); + return await stat(path); } catch (error: unknown) { if (isSystemError(error) && error.code === 'ENOENT') { throw new NotFoundHttpError('', { cause: error }); @@ -212,14 +198,7 @@ export class FileDataAccessor implements DataAccessor { // Delete (potentially) existing metadata file if no metadata needs to be stored } else { - try { - await fsPromises.unlink(metadataLink.filePath); - } catch (error: unknown) { - // Metadata file doesn't exist so nothing needs to be removed - if (!isSystemError(error) || error.code !== 'ENOENT') { - throw error; - } - } + await remove(metadataLink.filePath); wroteMetadata = false; } return wroteMetadata; @@ -251,7 +230,7 @@ export class FileDataAccessor implements DataAccessor { const metadataLink = await this.resourceMapper.mapUrlToFilePath(identifier, true); // Check if the metadata file exists first - await fsPromises.lstat(metadataLink.filePath); + await lstat(metadataLink.filePath); const readMetadataStream = guardStream(createReadStream(metadataLink.filePath)); return await parseQuads(readMetadataStream, { format: metadataLink.contentType, baseIRI: identifier.path }); @@ -270,7 +249,7 @@ export class FileDataAccessor implements DataAccessor { * @param link - Path related metadata. */ private async* getChildMetadata(link: ResourceLink): AsyncIterableIterator { - const dir = await fsPromises.opendir(link.filePath); + const dir = await opendir(link.filePath); // For every child in the container we want to generate specific metadata for await (const entry of dir) { @@ -328,17 +307,10 @@ export class FileDataAccessor implements DataAccessor { * @param link - ResourceLink corresponding to the new resource data. */ protected async verifyExistingExtension(link: ResourceLink): Promise { - try { - // Delete the old file with the (now) wrong extension - const oldLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, false); - if (oldLink.filePath !== link.filePath) { - await fsPromises.unlink(oldLink.filePath); - } - } catch (error: unknown) { - // Ignore it if the file didn't exist yet and couldn't be unlinked - if (!isSystemError(error) || error.code !== 'ENOENT') { - throw error; - } + // Delete the old file with the (now) wrong extension + const oldLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, false); + if (oldLink.filePath !== link.filePath) { + await remove(oldLink.filePath); } } diff --git a/src/storage/keyvalue/JsonFileStorage.ts b/src/storage/keyvalue/JsonFileStorage.ts index fff543f83..48d574a2e 100644 --- a/src/storage/keyvalue/JsonFileStorage.ts +++ b/src/storage/keyvalue/JsonFileStorage.ts @@ -1,4 +1,4 @@ -import { promises as fsPromises } from 'fs'; +import { writeJson, readJson } from 'fs-extra'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import { isSystemError } from '../../util/errors/SystemError'; import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker'; @@ -70,8 +70,7 @@ export class JsonFileStorage implements KeyValueStorage { return this.locker.withWriteLock(this.lockIdentifier, async(): Promise => { const json = await this.getJson(); const result = updateFn(json); - const updatedText = JSON.stringify(json, null, 2); - await fsPromises.writeFile(this.filePath, updatedText, 'utf8'); + await writeJson(this.filePath, json, { encoding: 'utf8', spaces: 2 }); return result; }); } @@ -81,8 +80,7 @@ export class JsonFileStorage implements KeyValueStorage { */ private async getJson(): Promise> { try { - const text = await fsPromises.readFile(this.filePath, 'utf8'); - return JSON.parse(text); + return await readJson(this.filePath, 'utf8'); } catch (error: unknown) { if (isSystemError(error) && error.code === 'ENOENT') { return {}; diff --git a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts index 0e3723f71..85d496c93 100644 --- a/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts +++ b/test/unit/pods/generate/TemplatedResourcesGenerator.test.ts @@ -8,7 +8,7 @@ import type { import { ensureTrailingSlash, trimTrailingSlashes } from '../../../../src/util/PathUtil'; import { readableToString } from '../../../../src/util/StreamUtil'; import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine'; -import { mockFs } from '../../../util/Util'; +import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); @@ -52,7 +52,7 @@ describe('A TemplatedResourcesGenerator', (): void => { const webId = 'http://alice/#profile'; beforeEach(async(): Promise => { - cache = mockFs(rootFilePath); + cache = mockFileSystem(rootFilePath); }); it('fills in a template with the given options.', async(): Promise => { diff --git a/test/unit/quota/PodQuotaStrategy.test.ts b/test/unit/quota/PodQuotaStrategy.test.ts index 64ef455b2..a29532866 100644 --- a/test/unit/quota/PodQuotaStrategy.test.ts +++ b/test/unit/quota/PodQuotaStrategy.test.ts @@ -9,7 +9,7 @@ import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import type { IdentifierStrategy } from '../../../src/util/identifiers/IdentifierStrategy'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; import { PIM, RDF } from '../../../src/util/Vocabularies'; -import { mockFs } from '../../util/Util'; +import { mockFileSystem } from '../../util/Util'; jest.mock('fs'); @@ -24,7 +24,7 @@ describe('PodQuotaStrategy', (): void => { beforeEach((): void => { jest.restoreAllMocks(); - mockFs(rootFilePath, new Date()); + mockFileSystem(rootFilePath, new Date()); mockSize = { amount: 2000, unit: UNIT_BYTES }; identifierStrategy = new SingleRootIdentifierStrategy(base); mockReporter = { diff --git a/test/unit/quota/QuotaStrategy.test.ts b/test/unit/quota/QuotaStrategy.test.ts index c43c62367..f0820dc19 100644 --- a/test/unit/quota/QuotaStrategy.test.ts +++ b/test/unit/quota/QuotaStrategy.test.ts @@ -4,7 +4,7 @@ import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; import type { Size } from '../../../src/storage/size-reporter/Size'; import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; import { guardedStreamFrom, pipeSafely } from '../../../src/util/StreamUtil'; -import { mockFs } from '../../util/Util'; +import { mockFileSystem } from '../../util/Util'; jest.mock('fs'); @@ -26,7 +26,7 @@ describe('A QuotaStrategy', (): void => { beforeEach((): void => { jest.restoreAllMocks(); - mockFs(rootFilePath, new Date()); + mockFileSystem(rootFilePath, new Date()); mockSize = { amount: 2000, unit: UNIT_BYTES }; mockReporter = { getSize: jest.fn().mockResolvedValue({ unit: mockSize.unit, amount: 50 }), diff --git a/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts b/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts index 15be725a8..c592db11b 100644 --- a/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts +++ b/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts @@ -7,9 +7,10 @@ import { APPLICATION_OCTET_STREAM } from '../../../../src/util/ContentTypes'; import type { Guarded } from '../../../../src/util/GuardedStream'; import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; -import { mockFs } from '../../../util/Util'; +import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); +jest.mock('fs-extra'); describe('AtomicFileDataAccessor', (): void => { const rootFilePath = 'uploads'; @@ -20,7 +21,7 @@ describe('AtomicFileDataAccessor', (): void => { let data: Guarded; beforeEach(async(): Promise => { - cache = mockFs(rootFilePath, new Date()); + cache = mockFileSystem(rootFilePath, new Date()); accessor = new AtomicFileDataAccessor( new ExtensionBasedMapper(base, rootFilePath), rootFilePath, @@ -59,27 +60,27 @@ describe('AtomicFileDataAccessor', (): void => { data.emit('error', new Error('error')); return null; }); - jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + jest.requireMock('fs-extra').stat = jest.fn((): any => ({ isFile: (): boolean => false, })); await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); }); it('should throw when renaming / moving the file goes wrong.', async(): Promise => { - jest.requireMock('fs').promises.rename = jest.fn((): any => { + jest.requireMock('fs-extra').rename = jest.fn((): any => { throw new Error('error'); }); - jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + jest.requireMock('fs-extra').stat = jest.fn((): any => ({ isFile: (): boolean => true, })); await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); }); it('should (on error) not unlink the temp file if it does not exist.', async(): Promise => { - jest.requireMock('fs').promises.rename = jest.fn((): any => { + jest.requireMock('fs-extra').rename = jest.fn((): any => { throw new Error('error'); }); - jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + jest.requireMock('fs-extra').stat = jest.fn((): any => ({ isFile: (): boolean => false, })); await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); @@ -87,10 +88,10 @@ describe('AtomicFileDataAccessor', (): void => { it('should throw when renaming / moving the file goes wrong and the temp file does not exist.', async(): Promise => { - jest.requireMock('fs').promises.rename = jest.fn((): any => { + jest.requireMock('fs-extra').rename = jest.fn((): any => { throw new Error('error'); }); - jest.requireMock('fs').promises.stat = jest.fn(); + jest.requireMock('fs-extra').stat = jest.fn(); await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); }); }); diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts index 242038c8f..3ad6e6ae8 100644 --- a/test/unit/storage/accessors/FileDataAccessor.test.ts +++ b/test/unit/storage/accessors/FileDataAccessor.test.ts @@ -15,9 +15,10 @@ import { isContainerPath } from '../../../../src/util/PathUtil'; import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; import { toLiteral } from '../../../../src/util/TermUtil'; import { CONTENT_TYPE, DC, LDP, POSIX, RDF, SOLID_META, XSD } from '../../../../src/util/Vocabularies'; -import { mockFs } from '../../../util/Util'; +import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); +jest.mock('fs-extra'); const rootFilePath = 'uploads'; const now = new Date(); @@ -32,7 +33,7 @@ describe('A FileDataAccessor', (): void => { let data: Guarded; beforeEach(async(): Promise => { - cache = mockFs(rootFilePath, now); + cache = mockFileSystem(rootFilePath, now); accessor = new FileDataAccessor(new ExtensionBasedMapper(base, rootFilePath)); metadata = new RepresentationMetadata(APPLICATION_OCTET_STREAM); @@ -66,6 +67,13 @@ describe('A FileDataAccessor', (): void => { const stream = await accessor.getData({ path: `${base}resource` }); await expect(readableToString(stream)).resolves.toBe('data'); }); + + it('throws an error if something else went wrong.', async(): Promise => { + jest.requireMock('fs-extra').stat = (): any => { + throw new Error('error'); + }; + await expect(accessor.getData({ path: `${base}resource` })).rejects.toThrow('error'); + }); }); describe('getting metadata', (): void => { @@ -83,7 +91,7 @@ describe('A FileDataAccessor', (): void => { }); it('throws an error if something else went wrong.', async(): Promise => { - jest.requireMock('fs').promises.lstat = (): any => { + jest.requireMock('fs-extra').lstat = (): any => { throw new Error('error'); }; await expect(accessor.getMetadata({ path: base })).rejects.toThrow('error'); @@ -248,7 +256,7 @@ describe('A FileDataAccessor', (): void => { it('errors if there is a problem deleting the old metadata file.', async(): Promise => { cache.data = { resource: 'data', 'resource.meta': 'metadata!' }; - jest.requireMock('fs').promises.unlink = (): any => { + jest.requireMock('fs-extra').remove = (): any => { throw new Error('error'); }; await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)) @@ -302,7 +310,7 @@ describe('A FileDataAccessor', (): void => { it('throws an error if there is an issue deleting the original file.', async(): Promise => { cache.data = { 'resource$.ttl': ' .' }; - jest.requireMock('fs').promises.unlink = (): any => { + jest.requireMock('fs-extra').remove = (): any => { const error = new Error('error') as SystemError; error.code = 'EISDIR'; error.syscall = 'unlink'; @@ -332,7 +340,7 @@ describe('A FileDataAccessor', (): void => { }); it('throws other errors when making a directory.', async(): Promise => { - jest.requireMock('fs').promises.mkdir = (): any => { + jest.requireMock('fs-extra').ensureDir = (): any => { throw new Error('error'); }; await expect(accessor.writeContainer({ path: base }, metadata)).rejects.toThrow('error'); @@ -395,6 +403,9 @@ describe('A FileDataAccessor', (): void => { it('throws error if there is a problem with deleting existing metadata.', async(): Promise => { cache.data = { resource: 'apple', 'resource.meta': {}}; + jest.requireMock('fs-extra').remove = (): any => { + throw new Error('error'); + }; await expect(accessor.deleteResource({ path: `${base}resource` })).rejects.toThrow(); }); diff --git a/test/unit/storage/keyvalue/JsonFileStorage.test.ts b/test/unit/storage/keyvalue/JsonFileStorage.test.ts index 48e944700..6aa648647 100644 --- a/test/unit/storage/keyvalue/JsonFileStorage.test.ts +++ b/test/unit/storage/keyvalue/JsonFileStorage.test.ts @@ -1,9 +1,10 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import { JsonFileStorage } from '../../../../src/storage/keyvalue/JsonFileStorage'; import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker'; -import { mockFs } from '../../../util/Util'; +import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); +jest.mock('fs-extra'); describe('A JsonFileStorage', (): void => { const rootFilePath = 'files/'; @@ -13,7 +14,7 @@ describe('A JsonFileStorage', (): void => { let storage: JsonFileStorage; beforeEach(async(): Promise => { - cache = mockFs(rootFilePath); + cache = mockFileSystem(rootFilePath); locker = { withReadLock: jest.fn(async(identifier: ResourceIdentifier, whileLocked: () => any): Promise => await whileLocked()), diff --git a/test/unit/storage/size-reporter/FileSizeReporter.test.ts b/test/unit/storage/size-reporter/FileSizeReporter.test.ts index 8a3eff734..fd911a230 100644 --- a/test/unit/storage/size-reporter/FileSizeReporter.test.ts +++ b/test/unit/storage/size-reporter/FileSizeReporter.test.ts @@ -5,7 +5,7 @@ import type { FileIdentifierMapper, ResourceLink } from '../../../../src/storage import { FileSizeReporter } from '../../../../src/storage/size-reporter/FileSizeReporter'; import { UNIT_BYTES } from '../../../../src/storage/size-reporter/Size'; import { joinFilePath } from '../../../../src/util/PathUtil'; -import { mockFs } from '../../../util/Util'; +import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); @@ -28,7 +28,7 @@ describe('A FileSizeReporter', (): void => { ); beforeEach(async(): Promise => { - mockFs(fileRoot); + mockFileSystem(fileRoot); }); it('should work without the ignoreFolders constructor parameter.', async(): Promise => { diff --git a/test/unit/util/templates/TemplateEngine.test.ts b/test/unit/util/templates/TemplateEngine.test.ts index 897538f87..9be137581 100644 --- a/test/unit/util/templates/TemplateEngine.test.ts +++ b/test/unit/util/templates/TemplateEngine.test.ts @@ -1,6 +1,6 @@ import { resolveAssetPath } from '../../../../src/util/PathUtil'; import { getTemplateFilePath, readTemplate } from '../../../../src/util/templates/TemplateEngine'; -import { mockFs } from '../../../util/Util'; +import { mockFileSystem } from '../../../util/Util'; jest.mock('fs'); @@ -10,7 +10,7 @@ describe('TemplateEngine', (): void => { const templatePath = 'other'; beforeEach(async(): Promise => { - const { data } = mockFs(resolveAssetPath('')); + const { data } = mockFileSystem(resolveAssetPath('')); Object.assign(data, { 'template.xyz': '{{template}}', other: { @@ -45,7 +45,7 @@ describe('TemplateEngine', (): void => { const templatePath = 'other'; beforeEach(async(): Promise => { - const { data } = mockFs(resolveAssetPath('')); + const { data } = mockFileSystem(resolveAssetPath('')); Object.assign(data, { 'template.xyz': '{{template}}', other: { diff --git a/test/util/Util.ts b/test/util/Util.ts index 4003d741f..f7e33762c 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -60,7 +60,7 @@ export function describeIf(envFlag: string, name: string, fn: () => void): void * @param rootFilepath - The name of the root folder in which fs will start. * @param time - The date object to use for time functions (currently only mtime from lstats) */ -export function mockFs(rootFilepath?: string, time?: Date): { data: any } { +export function mockFileSystem(rootFilepath?: string, time?: Date): { data: any } { const cache: { data: any } = { data: {}}; rootFilepath = rootFilepath ?? 'folder'; @@ -97,7 +97,7 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { return { folder, name }; } - const mock = { + const mockFs = { createReadStream(path: string): any { const { folder, name } = getFolder(path); return Readable.from([ folder[name] ]); @@ -220,8 +220,79 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { }, }; + const mockFsExtra = { + async readJson(path: string): Promise> { + const { folder, name } = getFolder(path); + if (!folder[name]) { + throwSystemError('ENOENT'); + } + return JSON.parse(folder[name]); + }, + async writeJson(path: string, json: NodeJS.Dict): Promise { + const { folder, name } = getFolder(path); + const data = JSON.stringify(json, null, 2); + folder[name] = data; + }, + async ensureDir(path: string): Promise { + const { folder, name } = getFolder(path); + folder[name] = {}; + }, + async remove(path: string): Promise { + const { folder, name } = getFolder(path); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete folder[name]; + }, + createReadStream(path: string): any { + return mockFs.createReadStream(path); + }, + createWriteStream(path: string): any { + return mockFs.createWriteStream(path); + }, + async realpath(path: string): Promise { + return await mockFs.promises.realpath(path); + }, + async stat(path: string): Promise { + return mockFs.promises.lstat(await mockFs.promises.realpath(path)); + }, + async lstat(path: string): Promise { + return mockFs.promises.lstat(path); + }, + async unlink(path: string): Promise { + await mockFs.promises.unlink(path); + }, + async symlink(target: string, path: string): Promise { + await mockFs.promises.symlink(target, path); + }, + async rmdir(path: string): Promise { + await mockFs.promises.rmdir(path); + }, + async readdir(path: string): Promise { + return await mockFs.promises.readdir(path); + }, + async* opendir(path: string): AsyncIterableIterator { + for await (const entry of mockFs.promises.opendir(path)) { + yield entry; + } + }, + async mkdir(path: string): Promise { + await mockFs.promises.mkdir(path); + }, + async readFile(path: string): Promise { + return await mockFs.promises.readFile(path); + }, + async writeFile(path: string, data: string): Promise { + await mockFs.promises.writeFile(path, data); + }, + async rename(path: string, destination: string): Promise { + await mockFs.promises.rename(path, destination); + }, + }; + const fs = jest.requireMock('fs'); - Object.assign(fs, mock); + Object.assign(fs, mockFs); + + const fsExtra = jest.requireMock('fs-extra'); + Object.assign(fsExtra, mockFsExtra); return cache; }