From 852631202db01c2490f746d14485a2befdf87cb5 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Wed, 28 Oct 2026 20:19:49 +0900 Subject: [PATCH 1/2] feat: add composable fixture lifecycle plugins Compose fixture setup and teardown through plugins. Wait for pending filesystem operations before cleanup and document plugin instance scope. Based on toss/fixture-kit#15 by @GiHoon1123. Co-authored-by: GiHoon1123 --- .changeset/quiet-hooks-clean.md | 7 + README.ko.md | 90 +++++- README.md | 90 +++++- src/fixture.spec.ts | 470 ++++++++++++++++++++++++++++++++ src/fixture.ts | 68 ++++- src/fs.ts | 35 ++- src/types.ts | 13 + 7 files changed, 735 insertions(+), 38 deletions(-) create mode 100644 .changeset/quiet-hooks-clean.md diff --git a/.changeset/quiet-hooks-clean.md b/.changeset/quiet-hooks-clean.md new file mode 100644 index 0000000..35e2c8c --- /dev/null +++ b/.changeset/quiet-hooks-clean.md @@ -0,0 +1,7 @@ +--- +"@fixture-kit/core": minor +--- + +Add composable plugins to `Fixture.create` and `Fixture.fromDirectory`, with ordered setup, reverse teardown, and cleanup on failure. + +Wait for in-flight file operations before cleanup after initialization fails, and document plugin instance scope and partial setup cleanup. diff --git a/README.ko.md b/README.ko.md index a0cd54a..87fab9f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -147,34 +147,110 @@ await using fixture = await Fixture.fromDirectory( `cleanup()`은 멱등해요. -- 여러 번 호출하거나 동시에 호출해도 삭제는 한 번만 실행돼요. -- 디렉터리가 이미 삭제됐어도 에러를 던지지 않아요. +- 여러 번 호출하거나 동시에 호출해도 플러그인 정리와 디렉터리 삭제는 한 번만 실행돼요. +- 디렉터리가 이미 없어도 삭제 단계는 성공해요. + +### 플러그인 + +추가 준비나 정리 작업은 `plugins` 배열로 조합하세요. 각 플러그인은 동기 함수나 비동기 함수로 `setup`과 `teardown`을 제공할 수 있어요. + +```ts +await using fixture = await Fixture.create( + { "config.json": "{}" }, + { + plugins: [ + { + setup: ({ root }) => fs.mkdir(path.join(root, "generated")), + }, + { + setup: ({ root }) => fs.writeFile(path.join(root, "generated", "ready"), "yes"), + teardown: ({ root }) => fs.rm(path.join(root, "generated", "ready"), { force: true }), + }, + ], + }, +); +``` + +- 파일을 작성하거나 복사한 뒤, 배열 순서대로 `setup`을 실행해요. 모두 끝나야 픽스처를 반환해요. +- 정리할 때는 실행에 진입한 플러그인의 `teardown`을 역순으로 실행한 뒤 디렉터리를 삭제해요. +- `setup`이 실패하면 이후 플러그인은 실행하지 않아요. 실패한 플러그인을 포함해 이미 진입한 플러그인은 정리해요. +- 파일 작성이나 복사 중에 실패하면 플러그인 훅은 실행하지 않고 디렉터리를 삭제해요. +- `teardown`이 실패해도 나머지 플러그인 정리와 디렉터리 삭제는 계속 시도해요. 에러가 하나면 그대로 던지고, 여러 개면 `AggregateError`로 함께 전달해요. + +훅에는 `root`만 가진 컨텍스트를 전달해요. 각 훅은 자신이 만든 자원만 정리하고, 픽스처 전체 정리는 훅 밖의 `cleanup()`이나 `await using`에 맡기세요. 훅 안에서 해당 픽스처의 `cleanup()`을 다시 호출하는 것은 지원하지 않아요. + +`Fixture.fromDirectory(directory, { plugins })`에도 같은 옵션을 사용할 수 있어요. + +#### 플러그인 인스턴스 + +픽스처는 전달받은 플러그인 객체를 복제하지 않고 그대로 사용해요. 플러그인이 변경 가능한 상태를 저장한다면 픽스처마다 팩토리를 호출해 새 객체를 만드세요. 그러면 한 픽스처의 준비나 정리가 다른 픽스처의 자원에 영향을 주지 않아요. + +예를 들어 다음 팩토리는 픽스처 하나에서 사용할 파일 핸들을 보관해요. + +```ts +import fs, { type FileHandle } from "node:fs/promises"; +import path from "node:path"; +import { Fixture, type FixturePlugin } from "@fixture-kit/core"; + +function withLogFile(): FixturePlugin { + let file: FileHandle | undefined; + + return { + async setup({ root }) { + file = await fs.open(path.join(root, "test.log"), "w"); + await file.writeFile("ready\n"); + }, + async teardown() { + await file?.close(); + }, + }; +} + +await using first = await Fixture.create({}, { plugins: [withLogFile()] }); +await using second = await Fixture.create({}, { plugins: [withLogFile()] }); +``` + +같은 플러그인 객체를 여러 픽스처에서 공유하려면 플러그인에 상태가 없거나, 픽스처 컨텍스트마다 상태를 따로 관리해야 해요. `setup`이 일부 자원만 만든 뒤 실패해도 `teardown`을 실행하므로, `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 { + plugins?: readonly FixturePlugin[]; +} + +interface FixturePlugin { + setup?: (context: FixtureContext) => void | Promise; + teardown?: (context: FixtureContext) => void | Promise; +} + +interface FixtureContext { + readonly root: string; +} ``` - `tree` — 파일 트리예요. 문자열 값은 파일 내용이 되고, 일반 객체 값은 중첩 디렉터리가 되고, 빈 객체는 빈 디렉터리를 만들어요. - 각 키는 항목 하나의 이름이어야 해요. `/`나 `\`가 포함된 키, 빈 키, `.`과 `..`은 `invalid fixture path` 에러로 거부해요. 그래서 트리가 픽스처 루트 밖에 파일을 쓸 수 없어요. - 문자열도 일반 객체도 아닌 값(`Date`, 배열, 클래스 인스턴스 등)은 `TypeError`로 거부해요. +- `options.plugins` — 트리를 작성한 뒤 준비하고 삭제 전에 정리할 플러그인 배열이에요. 실행 순서와 실패 동작은 [플러그인](#플러그인)을 참고하세요. - 도중에 실패하면 임시 디렉터리를 삭제한 뒤 에러를 다시 던져요. -### `Fixture.fromDirectory(directory)` +### `Fixture.fromDirectory(directory, options?)` 임시 디렉터리를 만들고 기존 디렉터리를 재귀적으로 복사해요. ```ts -static fromDirectory(directory: string): Promise +static fromDirectory(directory: string, options?: FixtureOptions): Promise ``` - `directory` — 소스 디렉터리 경로예요. 상대 경로는 현재 작업 디렉터리를 기준으로 해석해요. @@ -194,7 +270,7 @@ readonly root: string cleanup(): Promise ``` -픽스처 디렉터리를 재귀적으로 삭제해요. 멱등해서 여러 번 호출하거나 동시에 호출해도 삭제는 한 번만 실행되고, 디렉터리가 이미 없어도 에러를 던지지 않아요. +플러그인의 `teardown`을 실행한 뒤 픽스처 디렉터리를 재귀적으로 삭제해요. 여러 번 호출하거나 동시에 호출해도 전체 정리는 한 번만 실행돼요. 디렉터리가 이미 없어도 삭제 단계는 성공하지만, 훅에서 발생한 에러는 전달돼요. ### `fixture[Symbol.asyncDispose]()` diff --git a/README.md b/README.md index 99e7e6b..a02f21e 100644 --- a/README.md +++ b/README.md @@ -147,34 +147,110 @@ With `await using`, the fixture directory is removed automatically at the end of `cleanup()` is idempotent: -- Repeated or concurrent calls remove the directory only once. -- It doesn't throw when the directory is already gone. +- Repeated or concurrent calls run plugin teardown and directory removal only once. +- Removing an already deleted directory does not cause an error. + +### Plugins + +Compose additional preparation and cleanup tasks with the `plugins` array. Each plugin can provide synchronous or asynchronous `setup` and `teardown` hooks. + +```ts +await using fixture = await Fixture.create( + { "config.json": "{}" }, + { + plugins: [ + { + setup: ({ root }) => fs.mkdir(path.join(root, "generated")), + }, + { + setup: ({ root }) => fs.writeFile(path.join(root, "generated", "ready"), "yes"), + teardown: ({ root }) => fs.rm(path.join(root, "generated", "ready"), { force: true }), + }, + ], + }, +); +``` + +- After files are written or copied, setup hooks run in declaration order. The fixture is returned only after they all complete. +- During cleanup, teardown hooks run in reverse order for plugins already entered, followed by directory removal. +- If setup fails, later plugins are skipped. Plugins already entered, including the failing plugin, are cleaned up. +- If writing or copying files fails, no plugin hooks run and the directory is removed. +- If teardown fails, the remaining teardowns and directory removal are still attempted. A single error is rethrown unchanged; multiple errors are reported together in an `AggregateError`. + +Hooks receive a context containing only `root`. Each hook should clean up its own resources; leave fixture cleanup to `cleanup()` or `await using` outside the hooks. Calling the owning fixture's `cleanup()` from a hook is not supported. + +The same options are available through `Fixture.fromDirectory(directory, { plugins })`. + +#### Plugin instances + +Fixture uses the plugin objects you pass in without cloning them. If a plugin stores mutable state, call its factory separately for each fixture so one fixture's setup or teardown cannot affect another fixture's resources. + +For example, this factory keeps an open file handle for one fixture: + +```ts +import fs, { type FileHandle } from "node:fs/promises"; +import path from "node:path"; +import { Fixture, type FixturePlugin } from "@fixture-kit/core"; + +function withLogFile(): FixturePlugin { + let file: FileHandle | undefined; + + return { + async setup({ root }) { + file = await fs.open(path.join(root, "test.log"), "w"); + await file.writeFile("ready\n"); + }, + async teardown() { + await file?.close(); + }, + }; +} + +await using first = await Fixture.create({}, { plugins: [withLogFile()] }); +await using second = await Fixture.create({}, { plugins: [withLogFile()] }); +``` + +Only share a plugin object between fixtures if it is stateless or keeps independent state for each fixture context. `teardown` must handle partial setup: it also runs when `setup` fails after acquiring only some resources. ## 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 { + plugins?: readonly FixturePlugin[]; +} + +interface FixturePlugin { + setup?: (context: FixtureContext) => void | Promise; + teardown?: (context: FixtureContext) => void | Promise; +} + +interface FixtureContext { + readonly root: string; +} ``` - `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`. +- `options.plugins` — plugins that prepare the fixture after writing the tree and clean up before deletion. See [Plugins](#plugins) for execution order and failure behavior. - 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. @@ -194,7 +270,7 @@ Absolute path of the fixture's temporary directory. The path is canonical — sy cleanup(): Promise ``` -Removes the fixture directory recursively. Idempotent — repeated or concurrent calls share a single removal, and it doesn't throw when the directory is already gone. +Runs plugin teardown, then removes the fixture directory recursively. Repeated or concurrent calls share the entire cleanup operation. Removing an already deleted directory succeeds, but errors from teardown hooks are still reported. ### `fixture[Symbol.asyncDispose]()` diff --git a/src/fixture.spec.ts b/src/fixture.spec.ts index 2614606..209f5e7 100644 --- a/src/fixture.spec.ts +++ b/src/fixture.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { Fixture } from "./fixture.js"; +import type { FixtureContext, FixtureOptions } from "./index.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; import fs from "node:fs/promises"; @@ -181,6 +182,404 @@ 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": "{}" }, + { + plugins: [ + { + setup: async (createdFixture) => { + setupRoot = createdFixture.root; + expect( + await fs.readFile(path.join(createdFixture.root, "config.json"), "utf-8"), + ).toBe("{}"); + await fs.writeFile(path.join(createdFixture.root, "ready"), "yes"); + }, + }, + ], + }, + ); + + expect(setupRoot).toBe(fixture.root); + expect(await fs.readFile(path.join(fixture.root, "ready"), "utf-8")).toBe("yes"); + await fixture.cleanup(); + }); + + it("should run setup after copying a directory", async () => { + let setupRoot: string | undefined; + + const fixture = await Fixture.fromDirectory(testFixture("basic"), { + plugins: [ + { + setup: async (createdFixture) => { + setupRoot = createdFixture.root; + expect( + await fs.readFile(path.join(createdFixture.root, "package.json"), "utf-8"), + ).toBe(await fs.readFile(path.join(testFixture("basic"), "package.json"), "utf-8")); + }, + }, + ], + }); + + 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( + {}, + { + plugins: [ + { + 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({}, { plugins: [{ teardown }] }); + + const cleanup = fixture.cleanup(); + expect(fixture.cleanup()).toBe(cleanup); + await Promise.all([cleanup, fixture.cleanup(), fixture.cleanup()]); + await fixture.cleanup(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it("should run setup in order and teardown in reverse order", async () => { + const events: string[] = []; + const fixture = await Fixture.create( + {}, + { + plugins: [ + { + setup: async ({ root }) => { + await fs.writeFile(path.join(root, "ready"), "first"); + events.push("first setup"); + }, + teardown: async ({ root }) => { + expect(await fs.readFile(path.join(root, "ready"), "utf-8")).toBe( + "second teardown", + ); + events.push("first teardown"); + }, + }, + { + setup: async ({ root }) => { + expect(await fs.readFile(path.join(root, "ready"), "utf-8")).toBe("first"); + events.push("second setup"); + }, + teardown: async ({ root }) => { + await fs.writeFile(path.join(root, "ready"), "second teardown"); + events.push("second teardown"); + }, + }, + ], + }, + ); + + expect(events).toEqual(["first setup", "second setup"]); + await fixture.cleanup(); + + expect(events).toEqual(["first setup", "second setup", "second teardown", "first teardown"]); + await expect(fs.access(fixture.root)).rejects.toThrow(); + }); + + it.each(["create", "fromDirectory"] as const)( + "should clean up entered plugins when setup fails in %s", + async (method) => { + const events: string[] = []; + const setupError = new Error("second setup failed"); + const skippedSetup = vi.fn(); + const skippedTeardown = vi.fn(); + let root: string | undefined; + const options = { + plugins: [ + { + setup: () => { + events.push("first setup"); + }, + teardown: () => { + events.push("first teardown"); + }, + }, + { + setup: (fixture: FixtureContext) => { + root = fixture.root; + events.push("second setup"); + throw setupError; + }, + teardown: () => { + events.push("second teardown"); + }, + }, + { setup: skippedSetup, teardown: skippedTeardown }, + ], + }; + + const result = + method === "create" + ? Fixture.create({}, options) + : Fixture.fromDirectory(testFixture("basic"), options); + await expect(result).rejects.toBe(setupError); + + expect(events).toEqual([ + "first setup", + "second setup", + "second teardown", + "first teardown", + ]); + expect(skippedSetup).not.toHaveBeenCalled(); + expect(skippedTeardown).not.toHaveBeenCalled(); + await expect(fs.access(root!)).rejects.toThrow(); + }, + ); + + it.each(["create", "fromDirectory"] as const)( + "should not enter plugins when preparing files fails in %s", + async (method) => { + const setup = vi.fn(); + const teardown = vi.fn(); + const options = { plugins: [{ setup, teardown }] }; + const result = + method === "create" + ? Fixture.create({ "../escape.txt": "x" }, options) + : Fixture.fromDirectory(testFixture("nonexistent"), options); + + await expect(result).rejects.toThrow(); + + expect(setup).not.toHaveBeenCalled(); + expect(teardown).not.toHaveBeenCalled(); + }, + ); + + it.each(["create", "fromDirectory"] as const)( + "should remove the root when reading plugins fails in %s", + async (method) => { + const optionsError = new Error("plugins unavailable"); + const options: FixtureOptions = { + get plugins(): FixtureOptions["plugins"] { + throw optionsError; + }, + }; + const createDirectory = vi.spyOn(fs, "mkdtemp"); + let created: string | undefined; + + try { + const result = + method === "create" + ? Fixture.create({}, options) + : Fixture.fromDirectory(testFixture("basic"), options); + await expect(result).rejects.toBe(optionsError); + + created = await createDirectory.mock.results[0]?.value; + expect(created).toBeTypeOf("string"); + await expect(fs.access(created!)).rejects.toThrow(); + } finally { + createDirectory.mockRestore(); + if (created !== undefined) { + await fs.rm(created, { recursive: true, force: true }); + } + } + }, + ); + + it.each(["create", "fromDirectory"] as const)( + "should accept an empty plugins array in %s", + async (method) => { + const options = { plugins: [] }; + await using fixture = + method === "create" + ? await Fixture.create({ "hello.txt": "world" }, options) + : await Fixture.fromDirectory(testFixture("basic"), options); + + expect((await fs.stat(fixture.root)).isDirectory()).toBe(true); + const filename = method === "create" ? "hello.txt" : "package.json"; + await expect(fs.readFile(path.join(fixture.root, filename), "utf-8")).resolves.toBeTruthy(); + }, + ); + + it("should finish every teardown and remove the root when multiple teardowns fail", async () => { + const firstError = new Error("first teardown failed"); + const secondError = new Error("second teardown failed"); + const events: string[] = []; + const fixture = await Fixture.create( + {}, + { + plugins: [ + { + teardown: () => { + events.push("first teardown"); + }, + }, + { + teardown: () => { + events.push("second teardown"); + throw firstError; + }, + }, + { + teardown: async () => { + events.push("third teardown"); + throw secondError; + }, + }, + ], + }, + ); + + const error = await fixture.cleanup().catch((caughtError: unknown) => caughtError); + + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toContain(firstError); + expect((error as AggregateError).errors).toContain(secondError); + expect(events).toEqual(["third teardown", "second teardown", "first teardown"]); + await expect(fs.access(fixture.root)).rejects.toThrow(); + }); + + it("should expose only the root directory to plugin hooks", async () => { + const contexts: FixtureContext[] = []; + const fixture = await Fixture.create( + {}, + { + plugins: [ + { + setup: (context) => { + contexts.push(context); + }, + teardown: (context) => { + contexts.push(context); + }, + }, + ], + }, + ); + + await fixture.cleanup(); + + expect(contexts).toStrictEqual([{ root: fixture.root }, { root: fixture.root }]); + for (const context of contexts) { + expect(context).not.toHaveProperty("cleanup"); + expect(Symbol.asyncDispose in context).toBe(false); + } + }); + + it.each(["return", "await"] as const)( + "should reject an invalid teardown cleanup call and remove the root (%s)", + async (mode) => { + const fixture = await Fixture.create( + {}, + { + plugins: [ + { + teardown: + mode === "return" + ? (context) => { + // @ts-expect-error Plugin hooks cannot clean up the owning fixture. + return context.cleanup(); + } + : async (context) => { + // @ts-expect-error Plugin hooks cannot clean up the owning fixture. + await context.cleanup(); + }, + }, + ], + }, + ); + + await expect(fixture.cleanup()).rejects.toBeInstanceOf(TypeError); + await expect(fs.access(fixture.root)).rejects.toThrow(); + }, + ); + + it("should remove the root when teardown fails", async () => { + const teardownError = new Error("teardown failed"); + const fixture = await Fixture.create( + {}, + { + plugins: [{ 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( + {}, + { + plugins: [{ 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( + {}, + { + plugins: [ + { + 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,9 +639,80 @@ 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({}, { plugins: [{ teardown }] }); + } + + expect(teardown).toHaveBeenCalledTimes(1); + }); }); describe("cleanup on error", () => { + it.each(["root", "nested"] as const)( + "should wait for pending directory writes at the %s before cleanup", + async (level) => { + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + let notifyWriteStarted!: () => void; + const writeStarted = new Promise((resolve) => { + notifyWriteStarted = resolve; + }); + const mkdir = fs.mkdir; + const writeError = new Error("directory write failed"); + let pendingWrite: Promise | undefined; + const directoryWrite = vi.spyOn(fs, "mkdir").mockImplementation((directory, options) => { + const name = path.basename(String(directory)); + if (name === "failing") { + return Promise.reject(writeError); + } + if (name !== "pending") { + return mkdir(directory, options); + } + + notifyWriteStarted(); + const operation = writeGate.then(() => mkdir(directory, options)); + pendingWrite = operation; + return operation; + }); + const createDirectory = vi.spyOn(fs, "mkdtemp"); + const remove = vi.spyOn(fs, "rm"); + const tree = { pending: {}, failing: {} }; + const result = Fixture.create(level === "root" ? tree : { nested: tree }).catch( + (error: unknown) => error, + ); + let created: string | undefined; + + try { + await writeStarted; + created = await createDirectory.mock.results[0]?.value; + // Let the rejected sibling reach cleanup if create returns too early. + await new Promise((resolve) => setImmediate(resolve)); + expect(remove).not.toHaveBeenCalled(); + + releaseWrite(); + await expect(result).resolves.toBe(writeError); + expect(remove).toHaveBeenCalledTimes(1); + await expect(fs.access(created!)).rejects.toThrow(); + } finally { + releaseWrite(); + await pendingWrite?.catch(() => {}); + await result; + directoryWrite.mockRestore(); + createDirectory.mockRestore(); + remove.mockRestore(); + if (created !== undefined) { + await fs.rm(created, { recursive: true, force: true }); + } + } + }, + ); + // The factories create a temp directory before doing any work; on failure // they must remove it so a rejected call leaves nothing behind. We spy on // mkdtemp to learn which directory was created, since the failing call diff --git a/src/fixture.ts b/src/fixture.ts index 0f76ca5..fae108c 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 { FixtureContext, FixtureOptions, FixturePlugin, FixtureTree } from "./types.js"; import { createTempDir, writeFixtureTree } from "./fs.js"; export class Fixture implements AsyncDisposable { @@ -12,6 +12,8 @@ export class Fixture implements AsyncDisposable { * where the temp directory lives under a symlinked `/var` → `/private/var`). */ readonly root: string; + readonly #context: FixtureContext; + readonly #activePlugins: FixturePlugin[] = []; #cleanupPromise: Promise | null = null; /** @@ -20,9 +22,10 @@ export class Fixture implements AsyncDisposable { */ private constructor(root: string) { this.root = root; + this.#context = { root }; } - static async fromDirectory(directory: string): Promise { + static async fromDirectory(directory: string, options?: FixtureOptions): Promise { const fixture = new Fixture(await createTempDir()); try { @@ -33,34 +36,81 @@ export class Fixture implements AsyncDisposable { } await fs.cp(sourcePath, fixture.root, { recursive: true, verbatimSymlinks: true }); + await fixture.#setup(options?.plugins); return fixture; } catch (error) { - await fixture.cleanup(); - throw error; + return fixture.#rejectAfterCleanup(error); } } - static async create(inlineFixture: FixtureTree): Promise { + static async create(inlineFixture: FixtureTree, options?: FixtureOptions): Promise { const fixture = new Fixture(await createTempDir()); try { await writeFixtureTree(fixture.root, inlineFixture); + await fixture.#setup(options?.plugins); 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 #setup(plugins: readonly FixturePlugin[] = []): Promise { + for (const plugin of plugins) { + this.#activePlugins.push(plugin); + await plugin.setup?.(this.#context); + } + } + + async #cleanup(): Promise { + const errors: unknown[] = []; + + for (const plugin of [...this.#activePlugins].reverse()) { + try { + await plugin.teardown?.(this.#context); + } 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/fs.ts b/src/fs.ts index 3839929..6357807 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -20,21 +20,26 @@ export async function createTempDir(): Promise { } export async function writeFixtureTree(directory: string, tree: FixtureTree): Promise { - await Promise.all( - Object.entries(tree).map(async ([name, content]) => { - const validName = parseValidName(name); - const fullPath = path.join(directory, validName); - - if (typeof content === "string") { - await fs.writeFile(fullPath, content); - } else if (isPlainObject(content)) { - await fs.mkdir(fullPath, { recursive: true }); - await writeFixtureTree(fullPath, content); - } else { - throw new TypeError(`invalid fixture content for ${name}: expected string or object`); - } - }), - ); + const writes = Object.entries(tree).map(async ([name, content]) => { + const validName = parseValidName(name); + const fullPath = path.join(directory, validName); + + if (typeof content === "string") { + await fs.writeFile(fullPath, content); + } else if (isPlainObject(content)) { + await fs.mkdir(fullPath, { recursive: true }); + await writeFixtureTree(fullPath, content); + } else { + throw new TypeError(`invalid fixture content for ${name}: expected string or object`); + } + }); + + try { + await Promise.all(writes); + } catch (error) { + await Promise.allSettled(writes); + throw error; + } } // A key names a single entry, not a path: no separators, no traversal. diff --git a/src/types.ts b/src/types.ts index f1af5db..44fc893 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,3 +5,16 @@ export interface FixtureTree { [name: string]: string | FixtureTree; } + +export interface FixtureOptions { + plugins?: readonly FixturePlugin[]; +} + +export interface FixtureContext { + readonly root: string; +} + +export interface FixturePlugin { + setup?: (context: FixtureContext) => void | Promise; + teardown?: (context: FixtureContext) => void | Promise; +} From 3650d257429dc1dbea624b7c99a19fddc4f98bd0 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Wed, 28 Oct 2026 20:45:35 +0900 Subject: [PATCH 2/2] test: add test cases --- src/fixture.spec.ts | 94 +++++++++++++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/src/fixture.spec.ts b/src/fixture.spec.ts index 209f5e7..f30ce72 100644 --- a/src/fixture.spec.ts +++ b/src/fixture.spec.ts @@ -270,6 +270,27 @@ describe("Fixture", () => { expect(teardown).toHaveBeenCalledTimes(1); }); + it("should not repeat failed cleanup for concurrent or later calls", async () => { + const teardownError = new Error("teardown failed"); + const teardown = vi.fn().mockRejectedValue(teardownError); + const fixture = await Fixture.create({}, { plugins: [{ teardown }] }); + const remove = vi.spyOn(fs, "rm"); + + try { + const cleanups = [fixture.cleanup(), fixture.cleanup(), fixture.cleanup()]; + await Promise.all(cleanups.map((cleanup) => expect(cleanup).rejects.toBe(teardownError))); + await expect(fixture.cleanup()).rejects.toBe(teardownError); + + expect(teardown).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(fixture.root, { recursive: true, force: true }); + await expect(fs.access(fixture.root)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + remove.mockRestore(); + await fs.rm(fixture.root, { recursive: true, force: true }); + } + }); + it("should run setup in order and teardown in reverse order", async () => { const events: string[] = []; const fixture = await Fixture.create( @@ -359,6 +380,51 @@ describe("Fixture", () => { }, ); + it.each(["create", "fromDirectory"] as const)( + "should clean up a partial resource when async setup fails in %s", + async (method) => { + const setupError = new Error("async setup failed"); + const skippedSetup = vi.fn(); + const skippedTeardown = vi.fn(); + let root: string | undefined; + const teardown = vi.fn(async (context: FixtureContext) => { + const resource = path.join(context.root, "partial.txt"); + expect(await fs.readFile(resource, "utf-8")).toBe("ready"); + await fs.rm(resource); + }); + const options: FixtureOptions = { + plugins: [ + { + setup: async (context) => { + root = context.root; + await fs.writeFile(path.join(root, "partial.txt"), "ready"); + throw setupError; + }, + teardown, + }, + { setup: skippedSetup, teardown: skippedTeardown }, + ], + }; + + try { + const result = + method === "create" + ? Fixture.create({}, options) + : Fixture.fromDirectory(testFixture("basic"), options); + await expect(result).rejects.toBe(setupError); + + expect(teardown).toHaveBeenCalledTimes(1); + expect(skippedSetup).not.toHaveBeenCalled(); + expect(skippedTeardown).not.toHaveBeenCalled(); + await expect(fs.access(root!)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (root !== undefined) { + await fs.rm(root, { recursive: true, force: true }); + } + } + }, + ); + it.each(["create", "fromDirectory"] as const)( "should not enter plugins when preparing files fails in %s", async (method) => { @@ -488,34 +554,6 @@ describe("Fixture", () => { } }); - it.each(["return", "await"] as const)( - "should reject an invalid teardown cleanup call and remove the root (%s)", - async (mode) => { - const fixture = await Fixture.create( - {}, - { - plugins: [ - { - teardown: - mode === "return" - ? (context) => { - // @ts-expect-error Plugin hooks cannot clean up the owning fixture. - return context.cleanup(); - } - : async (context) => { - // @ts-expect-error Plugin hooks cannot clean up the owning fixture. - await context.cleanup(); - }, - }, - ], - }, - ); - - await expect(fixture.cleanup()).rejects.toBeInstanceOf(TypeError); - await expect(fs.access(fixture.root)).rejects.toThrow(); - }, - ); - it("should remove the root when teardown fails", async () => { const teardownError = new Error("teardown failed"); const fixture = await Fixture.create(