Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Implement flexible fixture creation API #20

Merged
merged 4 commits into from
Jul 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ $ npm i -D @mizdra/inline-fixture-files
- Write fixture files inline
- Type-safe access to the fixture file path
- Share fixture files with test cases
- Flexible fixture creation API
- Cross-platform support
- Zero dependencies

Expand Down Expand Up @@ -148,3 +149,77 @@ describe('eslint', async () => {
});
});
```

### Example: Flexible fixture creation API

`@mizdra/inline-fixture-files` provides a flexible API for creating various variations of files. This allows you to create files with customized encoding, mode, atime, and mtime. It also allows for copying and symlinking.

```ts
import { defineIFFCreator } from '@mizdra/inline-fixture-files';
import { writeFile, utimes, cp, symlink, mkdir } from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname, join } from 'node:path';

const fixtureBaseDir = join(tmpdir(), 'your-app-name', process.env['VITEST_POOL_ID']!);
const createIFF = defineIFFCreator({ generateRootDir: () => join(fixtureBaseDir, randomUUID()) });

// Example: File
const iff1 = await createIFF({
'buffer.txt': async (path) => writeFile(path, Buffer.from([0x00, 0x01])),
'encoding.txt': async (path) => writeFile(path, 'text', { encoding: 'utf16le' }),
'mode.txt': async (path) => writeFile(path, 'text', { mode: 0o600 }),
'flag.txt': async (path) => writeFile(path, 'text', { flag: 'wx' }),
'utime.txt': async (path) => {
await writeFile(path, 'text');
await utimes(0, 0);
},
'cp.txt': async (path) => cp('./cp.txt', path, { mode: constants.COPYFILE_FICLONE }),
'symlink.txt': async (path) => symlink('./symlink.txt', path),
// NOTE: The flexible file creation API does not automatically create parent directories.
// Therefore, you must manually create the parent directories in order to create nested files.
'nested/file.txt': async (path) => {
await mkdir(dirname(path));
await writeFile(path, 'text', { mode: 0o600 });
},
});
expectType<{
'buffer.txt'': string;
'encoding.txt': string;
'mode.txt': string;
'flag.txt': string;
'utime.txt': string;
'cp.txt': string;
'symlink.txt': string;
'nested': string;
'nested/file.txt': string;
}>(iff1.paths);

// Example: Directory
const iff2 = await createIFF({
'mode': async (path) => mkdir(path, { mode: 0o600 }),
'cp': async (path) => cp('./cp', path, { mode: constants.COPYFILE_FICLONE }),
'symlink': async (path) => symlink('./symlink', path),
// NOTE: The flexible file creation API does not automatically create parent directories.
// Therefore, the recursive option is required to create nested directories.
'nested/directory': async (path) => mkdir(path, { mode: 0x600, recursive: true }),
}).addFixtures({
// Use the add function to add files to the directory created by the flexible file creation API.
'mode': {
'file1.txt': 'file1',
},
// If you want to include the paths to the files in the copied directory in `iff2.paths`,
// you can use the following hack:
'cp': {
'file1.txt': () => {}, // noop
},
});
expectType<{
'mode': string;
'mode/file1.txt': string;
'cp': string;
'cp/file1.txt': string;
'symlink': string;
'nested': string;
'nested/directory': string;
}>(iff2.paths);
```
2 changes: 1 addition & 1 deletion docs/api/inline-fixture-files.filetype.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
**Signature:**

```typescript
export type FileType = string;
export type FileType = string | ((path: string) => Promise<void>);
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@mizdra/inline-fixture-files](./inline-fixture-files.md) &gt; [IFFFixtureCreationError](./inline-fixture-files.ifffixturecreationerror.md) &gt; [(constructor)](./inline-fixture-files.ifffixturecreationerror._constructor_.md)

## IFFFixtureCreationError.(constructor)

Constructs a new instance of the `IFFFixtureCreationError` class

**Signature:**

```typescript
constructor(path: string, { cause, throwByFlexibleFileCreationAPI }: {
cause: Error;
throwByFlexibleFileCreationAPI: boolean;
});
```

## Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| path | string | |
| { cause, throwByFlexibleFileCreationAPI } | { cause: Error; throwByFlexibleFileCreationAPI: boolean; } | |

27 changes: 27 additions & 0 deletions docs/api/inline-fixture-files.ifffixturecreationerror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@mizdra/inline-fixture-files](./inline-fixture-files.md) &gt; [IFFFixtureCreationError](./inline-fixture-files.ifffixturecreationerror.md)

## IFFFixtureCreationError class

The error thrown when fixture creation fails.

**Signature:**

```typescript
export declare class IFFFixtureCreationError extends Error
```
**Extends:** Error

## Constructors

| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(path, { cause, throwByFlexibleFileCreationAPI })](./inline-fixture-files.ifffixturecreationerror._constructor_.md) | | Constructs a new instance of the <code>IFFFixtureCreationError</code> class |

## Properties

| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [path](./inline-fixture-files.ifffixturecreationerror.path.md) | | string | The path of the fixture that failed to create. |

13 changes: 13 additions & 0 deletions docs/api/inline-fixture-files.ifffixturecreationerror.path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@mizdra/inline-fixture-files](./inline-fixture-files.md) &gt; [IFFFixtureCreationError](./inline-fixture-files.ifffixturecreationerror.md) &gt; [path](./inline-fixture-files.ifffixturecreationerror.path.md)

## IFFFixtureCreationError.path property

The path of the fixture that failed to create.

**Signature:**

```typescript
path: string;
```
6 changes: 6 additions & 0 deletions docs/api/inline-fixture-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

The utility for writing fixture files inline.

## Classes

| Class | Description |
| --- | --- |
| [IFFFixtureCreationError](./inline-fixture-files.ifffixturecreationerror.md) | The error thrown when fixture creation fails. |

## Functions

| Function | Description |
Expand Down
39 changes: 35 additions & 4 deletions src/create-iff-impl.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, readdir, rm } from 'node:fs/promises';
import { readFile, readdir, rm, stat, utimes, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { beforeEach, expect, test } from 'vitest';
import { beforeEach, describe, expect, test } from 'vitest';
import { createIFFImpl } from './create-iff-impl.js';
import { fixtureDir } from './test/util.js';

Expand Down Expand Up @@ -37,9 +37,11 @@ test('create directory with property name containing separator', async () => {
});

test('create empty directory with empty object', async () => {
await createIFFImpl({}, fixtureDir);
expect(await readdir(fixtureDir)).toEqual([]);

await createIFFImpl({ a: {} }, fixtureDir);
const files = await readdir(join(fixtureDir, 'a'));
expect(files).toEqual([]);
expect(await readdir(join(fixtureDir, 'a'))).toEqual([]);
});

test('throw error when item name starts with separator', async () => {
Expand Down Expand Up @@ -97,3 +99,32 @@ test('prefer the later fixture when creating fixtures for same path', async () =
);
expect(await readFile(join(fixtureDir, 'a/a.txt'), 'utf-8')).toMatchInlineSnapshot('"a-a#2"');
});

describe('support flexible fixture creation API', () => {
test('write file with callback', async () => {
await createIFFImpl(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'utime.txt': async (path) => {
await writeFile(path, 'utime');
await utimes(path, new Date(0), new Date(1));
},
},
fixtureDir,
);
const { atime, mtime } = await stat(join(fixtureDir, 'utime.txt'));
expect(await readFile(join(fixtureDir, 'utime.txt'), 'utf-8')).toMatchInlineSnapshot('"utime"');
expect(atime.getTime()).toBe(0);
expect(mtime.getTime()).toBe(1);
});
test('do not create parent directory when writing file with callback', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/naming-convention
createIFFImpl({ 'nested/file.txt': async (path) => writeFile(path, 'text') }, fixtureDir),
).rejects.toThrowError(
`Failed to create fixture ('${join(fixtureDir, 'nested/file.txt')}').` +
` Did you forget to create the parent directory ('${join(fixtureDir, 'nested')}')?` +
` The flexible fixture creation API does not automatically create the parent directory, you have to create it manually.`,
);
});
});
26 changes: 17 additions & 9 deletions src/create-iff-impl.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { sep as sepForPosix } from 'node:path/posix';
import { IFFFixtureCreationError } from './error.js';

/** @public */
export type FileType = string; // TODO: support `File` class
export type FileType = string | ((path: string) => Promise<void>);

/** @public */
// eslint-disable-next-line no-use-before-define
Expand All @@ -26,27 +27,34 @@ export interface Directory {
[name: string]: DirectoryItem;
}

export function isFile(item: DirectoryItem): item is FileType {
return typeof item === 'string';
export function isDirectory(item: DirectoryItem): item is Directory {
return typeof item === 'object';
}

function throwFixtureCreationError(path: string, cause: Error, throwByFlexibleFileCreationAPI = false): never {
throw new IFFFixtureCreationError(path, { cause, throwByFlexibleFileCreationAPI });
}

export async function createIFFImpl(directory: Directory, baseDir: string): Promise<void> {
await mkdir(baseDir, { recursive: true }).catch((cause) => throwFixtureCreationError(baseDir, cause));

for (const [name, item] of Object.entries(directory)) {
// TODO: Extract to `validateDirectory` function
if (name.startsWith(sepForPosix)) throw new Error(`Item name must not start with separator: ${name}`);
if (name.endsWith(sepForPosix)) throw new Error(`Item name must not end with separator: ${name}`);
if (name.includes(sepForPosix.repeat(2)))
throw new Error(`Item name must not contain consecutive separators: ${name}`);

const path = join(baseDir, name);
if (isFile(item)) {
if (typeof item === 'string') {
// `item` is file.
await mkdir(dirname(path), { recursive: true }).catch((cause) => throwFixtureCreationError(dirname(path), cause));
await writeFile(path, item).catch((cause) => throwFixtureCreationError(path, cause));
} else if (typeof item === 'function') {
// `item` is file.
await mkdir(dirname(path), { recursive: true });
if (typeof item === 'string') {
await writeFile(path, item);
}
await item(path).catch((cause) => throwFixtureCreationError(path, cause, true));
} else {
// `item` is directory.
await mkdir(path, { recursive: true });
await createIFFImpl(item, path);
}
}
Expand Down
29 changes: 29 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { dirname } from 'node:path';

/**
* The error thrown when fixture creation fails.
* @public
*/
export class IFFFixtureCreationError extends Error {
/** The path of the fixture that failed to create. */
path: string;
static {
this.prototype.name = 'IFFFixtureCreationError';
}
constructor(
path: string,
{ cause, throwByFlexibleFileCreationAPI }: { cause: Error; throwByFlexibleFileCreationAPI: boolean },
) {
let message = `Failed to create fixture ('${path}').`;

if (throwByFlexibleFileCreationAPI) {
const parentDirectory = dirname(path);
message +=
` Did you forget to create the parent directory ('${parentDirectory}')?` +
` The flexible fixture creation API does not automatically create the parent directory, you have to create it manually.`;
}

super(message, { cause });
this.path = path;
}
}
22 changes: 22 additions & 0 deletions src/get-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { utimes, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expectType } from 'ts-expect';
import { describe, expect, test } from 'vitest';
Expand Down Expand Up @@ -176,4 +177,25 @@ describe('getPaths', () => {
// eslint-disable-next-line no-unused-expressions
paths['d/a.txt'];
});
test('support flexible fixture creation API', () => {
const paths = getPaths(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'utime.txt': async (path) => {
await writeFile(path, 'utime');
await utimes(path, new Date(0), new Date(1));
},
},
rootDir,
);
expect(paths).toStrictEqual({
'utime.txt': join(fixtureDir, 'utime.txt'),
});
expectType<{
'utime.txt': string;
}>(paths);
// @ts-expect-error
// eslint-disable-next-line no-unused-expressions
paths['a.txt'];
});
});
6 changes: 4 additions & 2 deletions src/get-paths.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from 'node:path';
import { join as joinForPosix, dirname as dirnameForPosix, sep as sepForPosix } from 'node:path/posix';
import { Directory, isFile } from './create-iff-impl.js';
import { Directory, isDirectory } from './create-iff-impl.js';

/** Utility type that converts `{ a: string, [key: string]: any; }` to `{ a: string }`. */
// ref: https://github.com/type-challenges/type-challenges/issues/3542
Expand Down Expand Up @@ -44,16 +44,18 @@ export function getSelfAndUpperPaths(path: string): string[] {
export function getPaths<T extends Directory>(directory: T, rootDir: string, prefix = ''): FlattenDirectory<T> {
let paths: Record<string, string> = {};
for (const [name, item] of Object.entries(directory)) {
// TODO: Extract to `validateDirectory` function
if (name.startsWith(sepForPosix)) throw new Error(`Item name must not start with separator: ${name}`);
if (name.endsWith(sepForPosix)) throw new Error(`Item name must not end with separator: ${name}`);
if (name.includes(sepForPosix.repeat(2)))
throw new Error(`Item name must not contain consecutive separators: ${name}`);

for (const n of getSelfAndUpperPaths(name)) {
// TODO: Is this safe?
paths[joinForPosix(prefix, n)] = join(rootDir, prefix, n);
}

if (!isFile(item)) {
if (isDirectory(item)) {
const newPaths = getPaths(item, rootDir, joinForPosix(prefix, name));
paths = { ...paths, ...newPaths };
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Directory, createIFFImpl } from './create-iff-impl.js';
import { FlattenDirectory, getPaths } from './get-paths.js';

export type { Directory, DirectoryItem, FileType } from './create-iff-impl.js';
export { IFFFixtureCreationError } from './error.js';

/**
* The options for {@link defineIFFCreator}.
Expand Down