Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/quiet-hooks-clean.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 83 additions & 7 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fixture>
static create(tree: FixtureTree, options?: FixtureOptions): Promise<Fixture>

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

interface FixtureOptions {
plugins?: readonly FixturePlugin[];
}

interface FixturePlugin {
setup?: (context: FixtureContext) => void | Promise<void>;
teardown?: (context: FixtureContext) => void | Promise<void>;
}

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<Fixture>
static fromDirectory(directory: string, options?: FixtureOptions): Promise<Fixture>
```

- `directory` — 소스 디렉터리 경로예요. 상대 경로는 현재 작업 디렉터리를 기준으로 해석해요.
Expand All @@ -194,7 +270,7 @@ readonly root: string
cleanup(): Promise<void>
```

픽스처 디렉터리를 재귀적으로 삭제해요. 멱등해서 여러 번 호출하거나 동시에 호출해도 삭제는 한 번만 실행되고, 디렉터리가 이미 없어도 에러를 던지지 않아요.
플러그인의 `teardown`을 실행한 뒤 픽스처 디렉터리를 재귀적으로 삭제해요. 여러 번 호출하거나 동시에 호출해도 전체 정리는 한 번만 실행돼요. 디렉터리가 이미 없어도 삭제 단계는 성공하지만, 훅에서 발생한 에러는 전달돼요.

### `fixture[Symbol.asyncDispose]()`

Expand Down
90 changes: 83 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fixture>
static create(tree: FixtureTree, options?: FixtureOptions): Promise<Fixture>

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

interface FixtureOptions {
plugins?: readonly FixturePlugin[];
}

interface FixturePlugin {
setup?: (context: FixtureContext) => void | Promise<void>;
teardown?: (context: FixtureContext) => void | Promise<void>;
}

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<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 All @@ -194,7 +270,7 @@ Absolute path of the fixture's temporary directory. The path is canonical — sy
cleanup(): Promise<void>
```

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]()`

Expand Down
Loading
Loading