From 5b20107206650412798013c976ba5b533267812f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 21 Sep 2023 12:12:36 +0200 Subject: [PATCH] backend-test-utils: document MockDirectory + more naming tweaks Signed-off-by: Patrik Oldsberg --- .../src/filesystem/MockDirectory.test.ts | 74 +++++-- .../src/filesystem/MockDirectory.ts | 191 +++++++++++++++--- 2 files changed, 219 insertions(+), 46 deletions(-) diff --git a/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts b/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts index 48b8dcd83d..22563b7a59 100644 --- a/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts +++ b/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts @@ -17,17 +17,17 @@ import { MockDirectory } from './MockDirectory'; describe('MockDirectory', () => { - const mocker = MockDirectory.create(); + const mockDir = MockDirectory.create(); it('should populate a directory with text files', async () => { - await mocker.setContent({ + await mockDir.setContent({ 'a.txt': 'a', 'a/b.txt': 'b', 'a/b/c.txt': 'c', 'a/b/d.txt': 'd', }); - await expect(mocker.content()).resolves.toEqual({ + await expect(mockDir.content()).resolves.toEqual({ 'a.txt': 'a', a: { 'b.txt': 'b', @@ -40,14 +40,14 @@ describe('MockDirectory', () => { }); it('should mix text and binary files', async () => { - await mocker.setContent({ + await mockDir.setContent({ 'a.txt': 'a', 'a/b.txt': 'b', 'a/b/c.bin': Buffer.from([0xc]), 'a/b/d.bin': Buffer.from([0xd]), }); - await expect(mocker.content()).resolves.toEqual({ + await expect(mockDir.content()).resolves.toEqual({ 'a.txt': 'a', a: { 'b.txt': 'b', @@ -60,24 +60,24 @@ describe('MockDirectory', () => { }); it('should be able to add content', async () => { - await mocker.setContent({ + await mockDir.setContent({ 'a.txt': 'a', b: {}, }); - await expect(mocker.content()).resolves.toEqual({ + await expect(mockDir.content()).resolves.toEqual({ 'a.txt': 'a', b: {}, }); - await mocker.addContent({ + await mockDir.addContent({ 'b.txt': 'b', b: { 'c.txt': 'c', }, }); - await expect(mocker.content()).resolves.toEqual({ + await expect(mockDir.content()).resolves.toEqual({ 'a.txt': 'a', 'b.txt': 'b', b: { @@ -87,57 +87,91 @@ describe('MockDirectory', () => { }); it('should replace existing files', async () => { - await mocker.setContent({ + await mockDir.setContent({ 'a.txt': 'a', }); - await mocker.addContent({ + await mockDir.addContent({ 'a.txt': 'a2', }); - await expect(mocker.content()).resolves.toEqual({ + await expect(mockDir.content()).resolves.toEqual({ 'a.txt': 'a2', }); }); it('should not override directories', async () => { - await mocker.setContent({ + await mockDir.setContent({ 'a.txt': 'a', b: {}, }); await expect( - mocker.addContent({ + mockDir.addContent({ 'a.txt': {}, }), ).rejects.toThrow('EEXIST'); await expect( - mocker.addContent({ + mockDir.addContent({ b: 'b', }), ).rejects.toThrow('EISDIR'); }); + it('examples should work', async () => { + await mockDir.setContent({ + 'test.txt': 'content', + 'sub-dir': { + 'file.txt': 'content', + 'nested-dir/file.txt': 'content', + }, + 'empty-dir': {}, + 'binary-file': Buffer.from([0, 1, 2]), + }); + + await mockDir.addContent({ + 'test.txt': 'content', + 'sub-dir': { + 'file.txt': 'content', + 'nested-dir/file.txt': 'content', + }, + 'empty-dir': {}, + 'binary-file': Buffer.from([0, 1, 2]), + }); + + await expect(mockDir.content()).resolves.toEqual({ + 'test.txt': 'content', + 'sub-dir': { + 'file.txt': 'content', + 'nested-dir': { + 'file.txt': 'content', + }, + }, + 'empty-dir': {}, + 'binary-file': Buffer.from([0, 1, 2]), + }); + }); + describe('cleanup', () => { - let cleanupMocker: MockDirectory; + let cleanupMockDir: MockDirectory; describe('inner', () => { - cleanupMocker = MockDirectory.create(); + cleanupMockDir = MockDirectory.create(); it('should populate a directory', async () => { - await cleanupMocker.setContent({ + await cleanupMockDir.setContent({ 'a.txt': 'a', }); - await expect(cleanupMocker.content()).resolves.toEqual({ + await expect(cleanupMockDir.content()).resolves.toEqual({ 'a.txt': 'a', }); }); }); it('should clean up after itself automatically', async () => { - await expect(cleanupMocker.content()).resolves.toBeUndefined(); + await expect(cleanupMockDir.content()).resolves.toBeUndefined(); }); }); }); diff --git a/packages/backend-test-utils/src/filesystem/MockDirectory.ts b/packages/backend-test-utils/src/filesystem/MockDirectory.ts index d7e7d2c2e1..2ef61b3d0c 100644 --- a/packages/backend-test-utils/src/filesystem/MockDirectory.ts +++ b/packages/backend-test-utils/src/filesystem/MockDirectory.ts @@ -27,6 +27,69 @@ import { relative as relativePath, } from 'path'; +/** + * The content of a mock directory represented by a nested object structure. + * + * @remarks + * + * When used as input, the keys may contain forward slashes to indicate nested directories. + * Then returned as output, each directory will always be represented as a separate object. + * + * @example + * ```ts + * { + * 'test.txt': 'content', + * 'sub-dir': { + * 'file.txt': 'content', + * 'nested-dir/file.txt': 'content', + * }, + * 'empty-dir': {}, + * 'binary-file': Buffer.from([0, 1, 2]), + * } + * ``` + * + * @public + */ +export type MockDirectoryContent = { + [name in string]: MockDirectoryContent | string | Buffer; +}; + +/** + * Options for {@link MockDirectory.create}. + * + * @public + */ +export interface MockDirectoryCreateOptions { + /** + * The root path to create the directory in. Defaults to a temporary directory. + * + * If an existing directory is provided, it will not be cleaned up after the test. + */ + root?: string; +} + +/** + * Options for {@link MockDirectory.content}. + * + * @public + */ +export interface MockDirectoryContentOptions { + /** + * The path to read content from. Defaults to the root of the mock directory. + * + * An absolute path can also be provided, as long as it is a child path of the mock directory. + */ + path?: string; + + /** + * Whether or not to return files as text rather than buffers. + * + * Defaults to checking the file extension against a list of known text extensions. + */ + shouldReadAsText?: boolean | ((path: string, buffer: Buffer) => boolean); +} + +/** @internal */ type MockEntry = | { type: 'file'; @@ -38,32 +101,35 @@ type MockEntry = path: string; }; -export type MockDirectoryContent = { - [name in string]: MockDirectoryContent | string | Buffer; -}; - -interface DirectoryMockerOptions { - /** - * The root path to create the directory in. Defaults to a temporary directory. - * - * If an existing directory is provided, it will not be cleaned up after the test. - */ - root?: string; -} - -interface DirectoryMockerContentOptions { - path?: string; - - /** - * Whether or not to return files as text rather than buffers. - * - * Defaults to checking the file extension against a list of known text extensions. - */ - shouldReadAsText?: boolean | ((path: string, buffer: Buffer) => boolean); -} - +/** + * A utility for creating a mock directory that is automatically cleaned up. + * + * @public + */ export class MockDirectory { - static create(options?: DirectoryMockerOptions) { + /** + * Creates a new temporary mock directory that will be removed after the tests have completed. + * + * @remarks + * + * This method is intended to be called outside of any test, either at top-level or + * within a `describe` block. It will call `afterAll` to make sure that the mock directory + * is removed after the tests have run. + * + * @example + * ```ts + * describe('MySubject', () => { + * const mockDir = MockDirectory.create(); + * + * beforeEach(() => mockDir.clear()); + * + * it('should work', async () => { + * // ... use mockDir + * }) + * }) + * ``` + */ + static create(options?: MockDirectoryCreateOptions) { const root = options?.root ?? fs.mkdtempSync(joinPath(getTmpDir(), 'backstage-tmp-test-dir-')); @@ -84,6 +150,12 @@ export class MockDirectory { return mocker; } + /** + * Like {@link MockDirectory.create}, but also mocks `os.tmpdir()` to return the + * mock directory path until the end of the test suite. + * + * @returns + */ static mockOsTmpDir() { const mocker = MockDirectory.create(); const origTmpdir = os.tmpdir; @@ -105,20 +177,58 @@ export class MockDirectory { this.#root = root; } + /** + * The path to the root of the mock directory + */ get path() { return this.#root; } + /** + * Resolves a path relative to the root of the mock directory. + */ resolve(...paths: string[]) { return resolvePath(this.#root, ...paths); } + /** + * Sets the content of the mock directory. This will remove any existing content. + * + * @example + * ```ts + * await mockDir.setContent({ + * 'test.txt': 'content', + * 'sub-dir': { + * 'file.txt': 'content', + * 'nested-dir/file.txt': 'content', + * }, + * 'empty-dir': {}, + * 'binary-file': Buffer.from([0, 1, 2]), + * }); + * ``` + */ async setContent(root: MockDirectoryContent) { await this.cleanup(); return this.addContent(root); } + /** + * Adds content of the mock directory. This will overwrite existing files. + * + * @example + * ```ts + * await mockDir.addContent({ + * 'test.txt': 'content', + * 'sub-dir': { + * 'file.txt': 'content', + * 'nested-dir/file.txt': 'content', + * }, + * 'empty-dir': {}, + * 'binary-file': Buffer.from([0, 1, 2]), + * }); + * ``` + */ async addContent(root: MockDirectoryContent) { const entries = this.#transformInput(root); @@ -139,8 +249,31 @@ export class MockDirectory { } } + /** + * Reads the content of the mock directory. + * + * @remarks + * + * Text files will be returned as strings, while binary files will be returned as buffers. + * By default the file extension is used to determine whether a file should be read as text. + * + * @example + * ```ts + * await expect(mockDir.content()).resolves.toEqual({ + * 'test.txt': 'content', + * 'sub-dir': { + * 'file.txt': 'content', + * 'nested-dir': { + * 'file.txt': 'content', + * }, + * }, + * 'empty-dir': {}, + * 'binary-file': Buffer.from([0, 1, 2]), + * }); + * ``` + */ async content( - options?: DirectoryMockerContentOptions, + options?: MockDirectoryContentOptions, ): Promise { const shouldReadAsText = (typeof options?.shouldReadAsText === 'boolean' @@ -188,10 +321,16 @@ export class MockDirectory { return read(root); } + /** + * Clears the content of the mock directory, ensuring that the directory itself exists. + */ clear = async () => { await this.setContent({}); }; + /** + * Removes the mock directory and all its contents. + */ cleanup = async () => { await fs.rm(this.#root, { recursive: true, force: true }); };