diff --git a/.changeset/quiet-hooks-clean.md b/.changeset/quiet-hooks-clean.md new file mode 100644 index 0000000..57a90ef --- /dev/null +++ b/.changeset/quiet-hooks-clean.md @@ -0,0 +1,5 @@ +--- +"@fixture-kit/core": minor +--- + +Add optional `setup` and `teardown` hooks to `Fixture.create` and `Fixture.fromDirectory`. diff --git a/README.ko.md b/README.ko.md index a0cd54a..31f8f0a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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 +static create(tree: FixtureTree, options?: FixtureOptions): Promise interface FixtureTree { [name: string]: string | FixtureTree; } + +interface FixtureOptions { + setup?: (fixture: Fixture) => void | Promise; + teardown?: (fixture: Fixture) => void | Promise; +} ``` - `tree` — 파일 트리예요. 문자열 값은 파일 내용이 되고, 일반 객체 값은 중첩 디렉터리가 되고, 빈 객체는 빈 디렉터리를 만들어요. - 각 키는 항목 하나의 이름이어야 해요. `/`나 `\`가 포함된 키, 빈 키, `.`과 `..`은 `invalid fixture path` 에러로 거부해요. 그래서 트리가 픽스처 루트 밖에 파일을 쓸 수 없어요. - 문자열도 일반 객체도 아닌 값(`Date`, 배열, 클래스 인스턴스 등)은 `TypeError`로 거부해요. +- `setup`은 트리를 작성한 뒤 실행되고, 만들어진 픽스처를 인자로 받아요. +- `teardown`은 픽스처 디렉터리를 삭제하기 전에 실행돼요. - 도중에 실패하면 임시 디렉터리를 삭제한 뒤 에러를 다시 던져요. -### `Fixture.fromDirectory(directory)` +### `Fixture.fromDirectory(directory, options?)` 임시 디렉터리를 만들고 기존 디렉터리를 재귀적으로 복사해요. ```ts -static fromDirectory(directory: string): Promise +static fromDirectory(directory: string, options?: FixtureOptions): Promise ``` - `directory` — 소스 디렉터리 경로예요. 상대 경로는 현재 작업 디렉터리를 기준으로 해석해요. diff --git a/README.md b/README.md index 99e7e6b..88afc51 100644 --- a/README.md +++ b/README.md @@ -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 +static create(tree: FixtureTree, options?: FixtureOptions): Promise interface FixtureTree { [name: string]: string | FixtureTree; } + +interface FixtureOptions { + setup?: (fixture: Fixture) => void | Promise; + teardown?: (fixture: Fixture) => void | Promise; +} ``` - `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 +static fromDirectory(directory: string, options?: FixtureOptions): Promise ``` - `directory` — path of the source directory. Relative paths are resolved from the current working directory. diff --git a/src/fixture.spec.ts b/src/fixture.spec.ts index 2614606..007d58a 100644 --- a/src/fixture.spec.ts +++ b/src/fixture.spec.ts @@ -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 | 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", () => { @@ -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", () => { diff --git a/src/fixture.ts b/src/fixture.ts index 0f76ca5..0fa353b 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -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 { @@ -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 | 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 { - const fixture = new Fixture(await createTempDir()); + static async fromDirectory(directory: string, options?: FixtureOptions): Promise { + const fixture = new Fixture(await createTempDir(), options); try { const sourcePath = path.resolve(directory); @@ -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 { - const fixture = new Fixture(await createTempDir()); + static async create(inlineFixture: FixtureTree, options?: FixtureOptions): Promise { + 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 { - this.#cleanupPromise ??= fs.rm(this.root, { recursive: true, force: true }); + cleanup(): Promise { + this.#cleanupPromise ??= Promise.resolve().then(() => this.#cleanup()); return this.#cleanupPromise; } + async #cleanup(): Promise { + 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 { + try { + await this.cleanup(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Fixture setup failed and cleanup failed"); + } + + throw error; + } + async [Symbol.asyncDispose](): Promise { return this.cleanup(); } } + +function throwCleanupError(errors: unknown[]): never { + if (errors.length === 1) { + throw errors[0]; + } + + throw new AggregateError(errors, "Fixture cleanup failed"); +} diff --git a/src/types.ts b/src/types.ts index f1af5db..5ea7bb3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,13 @@ * @description A file tree where string values are file contents and * object values are nested directories. */ +import type { Fixture } from "./fixture.js"; + export interface FixtureTree { [name: string]: string | FixtureTree; } + +export interface FixtureOptions { + setup?: (fixture: Fixture) => void | Promise; + teardown?: (fixture: Fixture) => void | Promise; +}