Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quiet-hooks-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fixture-kit/core": minor
---

Add optional `setup` and `teardown` hooks to `Fixture.create` and `Fixture.fromDirectory`.
32 changes: 28 additions & 4 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,31 +150,55 @@ await using fixture = await Fixture.fromDirectory(
- 여러 번 호출하거나 동시에 호출해도 삭제는 한 번만 실행돼요.
- 디렉터리가 이미 삭제됐어도 에러를 던지지 않아요.

픽스처를 만든 직후나 삭제하기 직전에 실행할 코드가 있다면 라이프사이클 훅을 사용할 수 있어요.

```ts
await using fixture = await Fixture.create(
{ "config.json": "{}" },
{
setup: (fixture) => fs.writeFile(path.join(fixture.root, "ready"), "yes"),
teardown: (fixture) => closeTestServer(fixture.root),
},
);
```

두 훅 모두 동기 함수와 비동기 함수를 지원해요. `setup`은 파일을 작성하거나 복사한 뒤에 실행되고,
`teardown`은 `await using`으로 정리할 때를 포함해 루트 디렉터리를 삭제하기 전에 실행돼요. `setup`이
실패해도 정리는 계속 시도하고, `teardown`이 실패해도 디렉터리 삭제는 시도해요. 여러 작업이 실패하면
각 에러를 함께 확인할 수 있어요.

## API 레퍼런스

### `Fixture.create(tree)`
### `Fixture.create(tree, options?)`

임시 디렉터리를 만들고 주어진 파일 트리를 작성해요.

```ts
static create(tree: FixtureTree): Promise<Fixture>
static create(tree: FixtureTree, options?: FixtureOptions): Promise<Fixture>

interface FixtureTree {
[name: string]: string | FixtureTree;
}

interface FixtureOptions {
setup?: (fixture: Fixture) => void | Promise<void>;
teardown?: (fixture: Fixture) => void | Promise<void>;
}
```

- `tree` — 파일 트리예요. 문자열 값은 파일 내용이 되고, 일반 객체 값은 중첩 디렉터리가 되고, 빈 객체는 빈 디렉터리를 만들어요.
- 각 키는 항목 하나의 이름이어야 해요. `/`나 `\`가 포함된 키, 빈 키, `.`과 `..`은 `invalid fixture path` 에러로 거부해요. 그래서 트리가 픽스처 루트 밖에 파일을 쓸 수 없어요.
- 문자열도 일반 객체도 아닌 값(`Date`, 배열, 클래스 인스턴스 등)은 `TypeError`로 거부해요.
- `setup`은 트리를 작성한 뒤 실행되고, 만들어진 픽스처를 인자로 받아요.
- `teardown`은 픽스처 디렉터리를 삭제하기 전에 실행돼요.
- 도중에 실패하면 임시 디렉터리를 삭제한 뒤 에러를 다시 던져요.

### `Fixture.fromDirectory(directory)`
### `Fixture.fromDirectory(directory, options?)`

임시 디렉터리를 만들고 기존 디렉터리를 재귀적으로 복사해요.

```ts
static fromDirectory(directory: string): Promise<Fixture>
static fromDirectory(directory: string, options?: FixtureOptions): Promise<Fixture>
```

- `directory` — 소스 디렉터리 경로예요. 상대 경로는 현재 작업 디렉터리를 기준으로 해석해요.
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,31 +150,55 @@ With `await using`, the fixture directory is removed automatically at the end of
- Repeated or concurrent calls remove the directory only once.
- It doesn't throw when the directory is already gone.

You can run code after the fixture has been created and before it is removed with lifecycle hooks:

```ts
await using fixture = await Fixture.create(
{ "config.json": "{}" },
{
setup: (fixture) => fs.writeFile(path.join(fixture.root, "ready"), "yes"),
teardown: (fixture) => closeTestServer(fixture.root),
},
);
```

Both hooks may be synchronous or asynchronous. `setup` runs after the files have been written or
copied. `teardown` runs before the root directory is removed, including when cleanup is triggered
by `await using`. If setup fails, cleanup still runs. If teardown fails, the directory removal is
still attempted; when more than one operation fails, the errors are reported together.

## API Reference

### `Fixture.create(tree)`
### `Fixture.create(tree, options?)`

Creates a temporary directory and writes the given file tree into it.

```ts
static create(tree: FixtureTree): Promise<Fixture>
static create(tree: FixtureTree, options?: FixtureOptions): Promise<Fixture>

interface FixtureTree {
[name: string]: string | FixtureTree;
}

interface FixtureOptions {
setup?: (fixture: Fixture) => void | Promise<void>;
teardown?: (fixture: Fixture) => void | Promise<void>;
}
```

- `tree` — a file tree. String values become file contents, plain-object values become nested directories, and an empty object creates an empty directory.
- Each key must name a single entry: keys containing `/` or `\`, empty keys, and `.` or `..` are rejected with an `invalid fixture path` error, so a tree can never write outside the fixture root.
- Values that are neither strings nor plain objects (such as `Date`, arrays, or class instances) are rejected with a `TypeError`.
- `setup` runs after the tree is written and receives the created fixture.
- `teardown` runs before the fixture directory is removed.
- If anything fails, the temporary directory is removed before the error is rethrown.

### `Fixture.fromDirectory(directory)`
### `Fixture.fromDirectory(directory, options?)`

Creates a temporary directory and recursively copies an existing directory into it.

```ts
static fromDirectory(directory: string): Promise<Fixture>
static fromDirectory(directory: string, options?: FixtureOptions): Promise<Fixture>
```

- `directory` — path of the source directory. Relative paths are resolved from the current working directory.
Expand Down
145 changes: 145 additions & 0 deletions src/fixture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,141 @@ describe("Fixture", () => {
"invalid fixture path",
);
});

it("should run setup after writing the fixture", async () => {
let setupRoot: string | undefined;

const fixture = await Fixture.create(
{ "config.json": "{}" },
{
setup: async (createdFixture) => {
setupRoot = createdFixture.root;
expect(await fs.readFile(path.join(createdFixture.root, "config.json"), "utf-8")).toBe(
"{}",
);
},
},
);

expect(setupRoot).toBe(fixture.root);
await fixture.cleanup();
});

it("should run setup after copying a directory", async () => {
let setupRoot: string | undefined;

const fixture = await Fixture.fromDirectory(testFixture("basic"), {
setup: (createdFixture) => {
setupRoot = createdFixture.root;
},
});

expect(setupRoot).toBe(fixture.root);
await fixture.cleanup();
});
});

describe("lifecycle hooks", () => {
it("should run teardown before removing the root", async () => {
let teardownRoot: string | undefined;
let rootExistsDuringTeardown = false;

const fixture = await Fixture.create(
{},
{
teardown: async (createdFixture) => {
teardownRoot = createdFixture.root;
rootExistsDuringTeardown = await fs
.access(createdFixture.root)
.then(() => true)
.catch(() => false);
},
},
);

await fixture.cleanup();

expect(teardownRoot).toBe(fixture.root);
expect(rootExistsDuringTeardown).toBe(true);
await expect(fs.access(fixture.root)).rejects.toThrow();
});

it("should run teardown once for concurrent cleanup calls", async () => {
const teardown = vi.fn();
const fixture = await Fixture.create({}, { teardown });

await Promise.all([fixture.cleanup(), fixture.cleanup(), fixture.cleanup()]);

expect(teardown).toHaveBeenCalledTimes(1);
});

it("should share the cleanup promise when teardown re-enters cleanup", async () => {
let nestedCleanup: Promise<void> | undefined;
const fixture = await Fixture.create(
{},
{
teardown: (createdFixture) => {
nestedCleanup = createdFixture.cleanup();
},
},
);

const cleanup = fixture.cleanup();
await cleanup;

expect(nestedCleanup).toBe(cleanup);
});

it("should remove the root when teardown fails", async () => {
const teardownError = new Error("teardown failed");
const fixture = await Fixture.create({}, { teardown: () => Promise.reject(teardownError) });

await expect(fixture.cleanup()).rejects.toBe(teardownError);
await expect(fs.access(fixture.root)).rejects.toThrow();
});

it("should report teardown and removal errors together", async () => {
const teardownError = new Error("teardown failed");
const removalError = new Error("removal failed");
const remove = vi.spyOn(fs, "rm").mockRejectedValueOnce(removalError);
const fixture = await Fixture.create({}, { teardown: () => Promise.reject(teardownError) });

try {
const error = await fixture.cleanup().catch((caughtError: unknown) => caughtError);
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toContain(teardownError);
expect((error as AggregateError).errors).toContain(removalError);
} finally {
remove.mockRestore();
await fs.rm(fixture.root, { recursive: true, force: true });
}
});

it("should preserve setup and teardown errors when setup fails", async () => {
const setupError = new Error("setup failed");
const teardownError = new Error("teardown failed");
let root: string | undefined;

const result = Fixture.create(
{},
{
setup: (fixture) => {
root = fixture.root;
throw setupError;
},
teardown: () => {
throw teardownError;
},
},
);

const error = await result.catch((caughtError: unknown) => caughtError);
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toContain(setupError);
expect((error as AggregateError).errors).toContain(teardownError);

await expect(fs.access(root!)).rejects.toThrow();
});
});

describe("root", () => {
Expand Down Expand Up @@ -240,6 +375,16 @@ describe("Fixture", () => {

await expect(fs.access(root)).rejects.toThrow();
});

it("should run teardown when the fixture is disposed", async () => {
const teardown = vi.fn();

{
await using _fixture = await Fixture.create({}, { teardown });
}

expect(teardown).toHaveBeenCalledTimes(1);
});
});

describe("cleanup on error", () => {
Expand Down
64 changes: 52 additions & 12 deletions src/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { FixtureTree } from "./types.js";
import type { FixtureOptions, FixtureTree } from "./types.js";
import { createTempDir, writeFixtureTree } from "./fs.js";

export class Fixture implements AsyncDisposable {
Expand All @@ -12,18 +12,20 @@ export class Fixture implements AsyncDisposable {
* where the temp directory lives under a symlinked `/var` → `/private/var`).
*/
readonly root: string;
readonly #options: FixtureOptions;
#cleanupPromise: Promise<void> | null = null;

/**
* @note Use `Fixture.create` to create a new fixture.
* @internal
*/
private constructor(root: string) {
private constructor(root: string, options: FixtureOptions = {}) {
this.root = root;
this.#options = options;
}

static async fromDirectory(directory: string): Promise<Fixture> {
const fixture = new Fixture(await createTempDir());
static async fromDirectory(directory: string, options?: FixtureOptions): Promise<Fixture> {
const fixture = new Fixture(await createTempDir(), options);

try {
const sourcePath = path.resolve(directory);
Expand All @@ -33,34 +35,72 @@ export class Fixture implements AsyncDisposable {
}

await fs.cp(sourcePath, fixture.root, { recursive: true, verbatimSymlinks: true });
await fixture.#options.setup?.(fixture);

return fixture;
} catch (error) {
await fixture.cleanup();
throw error;
return fixture.#rejectAfterCleanup(error);
}
}

static async create(inlineFixture: FixtureTree): Promise<Fixture> {
const fixture = new Fixture(await createTempDir());
static async create(inlineFixture: FixtureTree, options?: FixtureOptions): Promise<Fixture> {
const fixture = new Fixture(await createTempDir(), options);

try {
await writeFixtureTree(fixture.root, inlineFixture);
await fixture.#options.setup?.(fixture);

return fixture;
} catch (error) {
await fixture.cleanup();
throw error;
return fixture.#rejectAfterCleanup(error);
}
}

async cleanup(): Promise<void> {
this.#cleanupPromise ??= fs.rm(this.root, { recursive: true, force: true });
cleanup(): Promise<void> {
this.#cleanupPromise ??= Promise.resolve().then(() => this.#cleanup());

return this.#cleanupPromise;
}

async #cleanup(): Promise<void> {
const errors: unknown[] = [];

try {
await this.#options.teardown?.(this);
} catch (error) {
errors.push(error);
}

try {
await fs.rm(this.root, { recursive: true, force: true });
} catch (error) {
errors.push(error);
}

if (errors.length > 0) {
throwCleanupError(errors);
}
}

async #rejectAfterCleanup(error: unknown): Promise<never> {
try {
await this.cleanup();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Fixture setup failed and cleanup failed");
}

throw error;
}

async [Symbol.asyncDispose](): Promise<void> {
return this.cleanup();
}
}

function throwCleanupError(errors: unknown[]): never {
if (errors.length === 1) {
throw errors[0];
}

throw new AggregateError(errors, "Fixture cleanup failed");
}
Loading