backend-test-utils: refactor to createMockDirectory + remove root option

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-02 16:54:44 +02:00
parent 0f58df31d6
commit 81726cd2d4
16 changed files with 186 additions and 193 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/backend-test-utils': patch
---
Added `MockDirectory` to help out with file system mocking in tests.
Added `createMockDirectory()` to help out with file system mocking in tests.
@@ -24,7 +24,7 @@ import {
AzureIntegrationConfig,
} from '@backstage/integration';
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -44,7 +44,7 @@ type AzureIntegrationConfigLike = Partial<
const logger = getVoidLogger();
const mockDir = MockDirectory.mockOsTmpDir();
const mockDir = createMockDirectory({ mockOsTmpDir: true });
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -20,7 +20,7 @@ import {
readBitbucketCloudIntegrationConfig,
} from '@backstage/integration';
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -32,7 +32,7 @@ import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
import getRawBody from 'raw-body';
const mockDir = MockDirectory.mockOsTmpDir();
const mockDir = createMockDirectory({ mockOsTmpDir: true });
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -20,7 +20,7 @@ import {
readBitbucketServerIntegrationConfig,
} from '@backstage/integration';
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -31,7 +31,7 @@ import { NotModifiedError } from '@backstage/errors';
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
MockDirectory.mockOsTmpDir();
createMockDirectory({ mockOsTmpDir: true });
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -20,7 +20,7 @@ import {
readBitbucketIntegrationConfig,
} from '@backstage/integration';
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -69,7 +69,7 @@ describe('BitbucketUrlReader.factory', () => {
});
describe('BitbucketUrlReader', () => {
const mockDir = MockDirectory.mockOsTmpDir();
const mockDir = createMockDirectory({ mockOsTmpDir: true });
beforeEach(mockDir.clear);
@@ -15,7 +15,7 @@
*/
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
@@ -35,7 +35,7 @@ import { DefaultReadTreeResponseFactory } from './tree';
import { GerritUrlReader } from './GerritUrlReader';
import getRawBody from 'raw-body';
const mockDir = MockDirectory.mockOsTmpDir();
const mockDir = createMockDirectory({ mockOsTmpDir: true });
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -21,7 +21,7 @@ import {
readGithubIntegrationConfig,
} from '@backstage/integration';
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -38,7 +38,7 @@ import {
} from './GithubUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
const mockDir = MockDirectory.mockOsTmpDir();
const mockDir = createMockDirectory({ mockOsTmpDir: true });
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -16,7 +16,7 @@
import { ConfigReader } from '@backstage/config';
import {
MockDirectory,
createMockDirectory,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -34,7 +34,7 @@ import {
const logger = getVoidLogger();
const mockDir = MockDirectory.mockOsTmpDir();
const mockDir = createMockDirectory({ mockOsTmpDir: true });
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
@@ -18,7 +18,7 @@ import fs from 'fs-extra';
import path from 'path';
import { FromReadableArrayOptions } from '../types';
import { ReadableArrayResponse } from './ReadableArrayResponse';
import { MockDirectory } from '@backstage/backend-test-utils';
import { createMockDirectory } from '@backstage/backend-test-utils';
const name1 = 'file1.yaml';
const file1 = fs.readFileSync(
@@ -31,8 +31,8 @@ const file2 = fs.readFileSync(
);
describe('ReadableArrayResponse', () => {
const sourceDir = MockDirectory.create();
const targetDir = MockDirectory.create();
const sourceDir = createMockDirectory();
const targetDir = createMockDirectory();
beforeEach(() => {
sourceDir.setContent({
@@ -17,15 +17,15 @@
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { TarArchiveResponse } from './TarArchiveResponse';
import { MockDirectory } from '@backstage/backend-test-utils';
import { createMockDirectory } from '@backstage/backend-test-utils';
const archiveData = fs.readFileSync(
resolvePath(__filename, '../../__fixtures__/mock-main.tar.gz'),
);
describe('TarArchiveResponse', () => {
const sourceDir = MockDirectory.create();
const targetDir = MockDirectory.create();
const sourceDir = createMockDirectory();
const targetDir = createMockDirectory();
beforeAll(() => {
sourceDir.setContent({ 'test-archive.tar.gz': archiveData });
@@ -19,7 +19,7 @@ import { Readable } from 'stream';
import { create as createArchive } from 'archiver';
import { resolve as resolvePath } from 'path';
import { ZipArchiveResponse } from './ZipArchiveResponse';
import { MockDirectory } from '@backstage/backend-test-utils';
import { createMockDirectory } from '@backstage/backend-test-utils';
const archiveData = fs.readFileSync(
resolvePath(__filename, '../../__fixtures__/mock-main.zip'),
@@ -35,8 +35,8 @@ const archiveWithMaliciousEntry = fs.readFileSync(
);
describe('ZipArchiveResponse', () => {
const sourceDir = MockDirectory.create();
const targetDir = MockDirectory.create();
const sourceDir = createMockDirectory();
const targetDir = createMockDirectory();
beforeAll(() => {
sourceDir.setContent({
@@ -19,15 +19,15 @@ import Docker from 'dockerode';
import Stream, { PassThrough } from 'stream';
import { ContainerRunner } from './ContainerRunner';
import { DockerContainerRunner, UserOptions } from './DockerContainerRunner';
import { MockDirectory } from '@backstage/backend-test-utils';
import { createMockDirectory } from '@backstage/backend-test-utils';
const mockDocker = new Docker() as jest.Mocked<Docker>;
describe('DockerContainerRunner', () => {
let containerTaskApi: ContainerRunner;
const inputDir = MockDirectory.create();
const outputDir = MockDirectory.create();
const inputDir = createMockDirectory();
const outputDir = createMockDirectory();
beforeEach(() => {
inputDir.clear();
+11 -8
View File
@@ -31,20 +31,23 @@ import { ServiceRef } from '@backstage/backend-plugin-api';
import { TokenManagerService } from '@backstage/backend-plugin-api';
import { UrlReaderService } from '@backstage/backend-plugin-api';
// @public
export function createMockDirectory(
options?: MockDirectoryOptions,
): MockDirectory;
// @public (undocumented)
export function isDockerDisabledForTests(): boolean;
// @public
export class MockDirectory {
export interface MockDirectory {
addContent(root: MockDirectoryContent): void;
clear: () => void;
clear(): void;
content(
options?: MockDirectoryContentOptions,
): MockDirectoryContent | undefined;
static create(options?: MockDirectoryCreateOptions): MockDirectory;
static mockOsTmpDir(): MockDirectory;
get path(): string;
remove: () => void;
readonly path: string;
remove(): void;
resolve(...paths: string[]): string;
setContent(root: MockDirectoryContent): void;
}
@@ -61,8 +64,8 @@ export interface MockDirectoryContentOptions {
}
// @public
export interface MockDirectoryCreateOptions {
root?: string;
export interface MockDirectoryOptions {
mockOsTmpDir?: boolean;
}
// @public (undocumented)
@@ -21,10 +21,10 @@ import {
resolve as resolvePath,
relative as relativePath,
} from 'path';
import { MockDirectory } from './MockDirectory';
import { createMockDirectory, MockDirectory } from './MockDirectory';
describe('MockDirectory', () => {
const mockDir = MockDirectory.create();
describe('createMockDirectory', () => {
const mockDir = createMockDirectory();
beforeEach(mockDir.clear);
@@ -277,7 +277,7 @@ describe('MockDirectory', () => {
let cleanupMockDir: MockDirectory;
describe('inner', () => {
cleanupMockDir = MockDirectory.create();
cleanupMockDir = createMockDirectory();
it('should populate a directory', () => {
cleanupMockDir.setContent({
@@ -299,7 +299,7 @@ describe('MockDirectory', () => {
let tmpDirMock: MockDirectory;
describe('inner', () => {
tmpDirMock = MockDirectory.mockOsTmpDir();
tmpDirMock = createMockDirectory({ mockOsTmpDir: true });
it('should mock os.tmpdir()', () => {
expect(os.tmpdir()).toBe(tmpDirMock.path);
@@ -310,22 +310,4 @@ describe('MockDirectory', () => {
expect(os.tmpdir()).not.toBe(tmpDirMock.path);
});
});
describe('existing directory', () => {
let existingMockDir: MockDirectory;
describe('inner', () => {
existingMockDir = MockDirectory.create({ root: __dirname }); // hardcore mode
it('should read existing directory', () => {
expect(existingMockDir.content()).toMatchObject({
'index.ts': expect.any(String),
});
});
});
it('should remove existing directory', () => {
expect(fs.pathExistsSync(__dirname)).toBe(true);
});
});
});
@@ -56,20 +56,6 @@ 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}.
*
@@ -91,107 +77,21 @@ export interface MockDirectoryContentOptions {
shouldReadAsText?: boolean | ((path: string, buffer: Buffer) => boolean);
}
/** @internal */
type MockEntry =
| {
type: 'file';
path: string;
content: Buffer;
}
| {
type: 'dir';
path: string;
};
/**
* A utility for creating a mock directory that is automatically cleaned up.
*
* @public
*/
export class MockDirectory {
/**
* 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', () => {
* // ... use mockDir
* })
* })
* ```
*/
static create(options?: MockDirectoryCreateOptions): MockDirectory {
const root =
options?.root ??
fs.mkdtempSync(joinPath(getTmpDir(), 'backstage-tmp-test-dir-'));
const mocker = new MockDirectory(root);
const shouldCleanup = !options?.root || !fs.pathExistsSync(options.root);
if (shouldCleanup) {
process.on('beforeExit', mocker.remove);
try {
afterAll(mocker.remove);
} catch {
/* ignore */
}
}
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(): MockDirectory {
const mocker = MockDirectory.create();
const origTmpdir = os.tmpdir;
os.tmpdir = () => mocker.path;
try {
afterAll(() => {
os.tmpdir = origTmpdir;
});
} catch {
/* ignore */
}
return mocker;
}
readonly #root: string;
private constructor(root: string) {
this.#root = root;
}
export interface MockDirectory {
/**
* The path to the root of the mock directory
*/
get path(): string {
return this.#root;
}
readonly path: string;
/**
* Resolves a path relative to the root of the mock directory.
*/
resolve(...paths: string[]): string {
return resolvePath(this.#root, ...paths);
}
resolve(...paths: string[]): string;
/**
* Sets the content of the mock directory. This will remove any existing content.
@@ -209,11 +109,7 @@ export class MockDirectory {
* });
* ```
*/
setContent(root: MockDirectoryContent): void {
this.remove();
return this.addContent(root);
}
setContent(root: MockDirectoryContent): void;
/**
* Adds content of the mock directory. This will overwrite existing files.
@@ -231,25 +127,7 @@ export class MockDirectory {
* });
* ```
*/
addContent(root: MockDirectoryContent): void {
const entries = this.#transformInput(root);
for (const entry of entries) {
const fullPath = resolvePath(this.#root, entry.path);
if (!isChildPath(this.#root, fullPath)) {
throw new Error(
`Provided path must resolve to a child path of the mock directory, got '${fullPath}'`,
);
}
if (entry.type === 'dir') {
fs.ensureDirSync(fullPath, { mode: 0o777 });
} else if (entry.type === 'file') {
fs.ensureDirSync(dirname(fullPath), { mode: 0o777 });
fs.writeFileSync(fullPath, entry.content, { mode: 0o666 });
}
}
}
addContent(root: MockDirectoryContent): void;
/**
* Reads the content of the mock directory.
@@ -274,6 +152,75 @@ export class MockDirectory {
* });
* ```
*/
content(
options?: MockDirectoryContentOptions,
): MockDirectoryContent | undefined;
/**
* Clears the content of the mock directory, ensuring that the directory itself exists.
*/
clear(): void;
/**
* Removes the mock directory and all its contents.
*/
remove(): void;
}
/** @internal */
type MockEntry =
| {
type: 'file';
path: string;
content: Buffer;
}
| {
type: 'dir';
path: string;
};
/** @internal */
class MockDirectoryImpl {
readonly #root: string;
constructor(root: string) {
this.#root = root;
}
get path(): string {
return this.#root;
}
resolve(...paths: string[]): string {
return resolvePath(this.#root, ...paths);
}
setContent(root: MockDirectoryContent): void {
this.remove();
return this.addContent(root);
}
addContent(root: MockDirectoryContent): void {
const entries = this.#transformInput(root);
for (const entry of entries) {
const fullPath = resolvePath(this.#root, entry.path);
if (!isChildPath(this.#root, fullPath)) {
throw new Error(
`Provided path must resolve to a child path of the mock directory, got '${fullPath}'`,
);
}
if (entry.type === 'dir') {
fs.ensureDirSync(fullPath, { mode: 0o777 });
} else if (entry.type === 'file') {
fs.ensureDirSync(dirname(fullPath), { mode: 0o777 });
fs.writeFileSync(fullPath, entry.content, { mode: 0o666 });
}
}
}
content(
options?: MockDirectoryContentOptions,
): MockDirectoryContent | undefined {
@@ -319,16 +266,10 @@ export class MockDirectory {
return read(root);
}
/**
* Clears the content of the mock directory, ensuring that the directory itself exists.
*/
clear = (): void => {
this.setContent({});
};
/**
* Removes the mock directory and all its contents.
*/
remove = (): void => {
fs.removeSync(this.#root);
};
@@ -359,3 +300,69 @@ export class MockDirectory {
return entries;
}
}
/**
* Options for {@link createMockDirectory}.
*
* @public
*/
export interface MockDirectoryOptions {
/**
* In addition to creating a temporary directory, also mock `os.tmpdir()` to return the
* mock directory path until the end of the test suite.
*
* @returns
*/
mockOsTmpDir?: boolean;
}
/**
* Creates a new temporary mock directory that will be removed after the tests have completed.
*
* @public
* @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 = createMockDirectory();
*
* beforeEach(mockDir.clear);
*
* it('should work', () => {
* // ... use mockDir
* })
* })
* ```
*/
export function createMockDirectory(
options?: MockDirectoryOptions,
): MockDirectory {
const root = fs.mkdtempSync(joinPath(getTmpDir(), 'backstage-tmp-test-dir-'));
const mocker = new MockDirectoryImpl(root);
const origTmpdir = options?.mockOsTmpDir ? os.tmpdir : undefined;
if (origTmpdir) {
os.tmpdir = () => mocker.path;
}
process.on('beforeExit', mocker.remove);
try {
afterAll(() => {
if (origTmpdir) {
os.tmpdir = origTmpdir;
}
mocker.remove();
});
} catch {
/* ignore */
}
return mocker;
}
@@ -15,8 +15,9 @@
*/
export {
MockDirectory,
createMockDirectory,
type MockDirectory,
type MockDirectoryOptions,
type MockDirectoryContent,
type MockDirectoryContentOptions,
type MockDirectoryCreateOptions,
} from './MockDirectory';