@bomb.sh/tools
Version:
The internal dev, build, and lint CLI for Bombshell projects
351 lines (273 loc) • 10.1 kB
Markdown
---
name: test
description: >
Vitest test runner with colocated .test.ts files and test utilities from
@bomb.sh/tools/test-utils. Covers createFixture (temp directories, inline file trees,
hfs API), createMocks (env stubbing, MockReadable/MockWritable streams, auto-cleanup),
.test-d.ts type-level tests, and auto-loaded vitest config. Use when writing or
running tests in Bombshell projects.
metadata:
type: core
library: '@bomb.sh/tools'
library_version: '0.3.1'
requires:
- lifecycle
sources:
- 'bombshell-dev/tools:src/commands/test.ts'
- 'bombshell-dev/tools:src/commands/test-utils/index.ts'
---
# Test
Vitest test runner with colocated test files, filesystem fixtures, and mock utilities.
## Setup
`bsh test` auto-loads its own vitest config — no `vitest.config.ts` needed in your project. The bundled config excludes `dist/`, sets `FORCE_COLOR=1`, and registers `vitest-ansi-serializer` for snapshot tests with ANSI output.
Run the full test suite:
```sh
pnpm run test
```
Filter to a specific file:
```sh
pnpm run test -- src/commands/build.test.ts
```
Pass any vitest flags after `--`:
```sh
pnpm run test -- --reporter=verbose
```
Tests are colocated next to the source they test. Never use `__tests__/`, `test/`, or any other separate directory.
- **`.test.ts`** — runtime tests
- **`.test-d.ts`** — type-level tests (compile-time assertions only)
```
src/
commands/
build.ts
build.test.ts
framework/
schema.ts
schema.test.ts
params.ts
params.test-d.ts
test-utils.ts
test-utils.test.ts
```
## Core Patterns
### createFixture
Creates a temporary directory from an inline file tree. Returns a `Fixture` with filesystem methods scoped to that directory. Cleanup runs automatically via `onTestFinished`.
```ts
import { describe, it, expect } from "vitest";
import { createFixture } from "@bomb.sh/tools/test-utils";
describe("my-feature", () => {
it("reads files from the fixture", async () => {
const fixture = await createFixture({
"hello.txt": "hello world",
"package.json": { name: "test", version: "1.0.0" },
"icon.png": Buffer.from([0x89, 0x50]),
src: {
"index.ts": "export default 1",
},
"link.txt": ({ symlink }) => symlink("./hello.txt"),
"info.txt": ({ importMeta }) => `Root: ${importMeta.url}`,
});
expect(await fixture.text("hello.txt")).toBe("hello world");
expect(await fixture.json("package.json")).toEqual({
name: "test",
version: "1.0.0",
});
expect(await fixture.isFile("src/index.ts")).toBe(true);
});
});
```
| Method | Returns | Description |
|--------|---------|-------------|
| `fixture.root` | `URL` | Fixture root as a `file://` URL |
| `fixture.resolve(...segments)` | `URL` | Resolve a relative path within the fixture root |
| `fixture.text(file)` | `Promise<string \| undefined>` | Read file contents as a string |
| `fixture.json(file)` | `Promise<unknown \| undefined>` | Read and parse a JSON file |
| `fixture.write(file, content)` | `Promise<void>` | Write a file to the fixture |
| `fixture.isFile(file)` | `Promise<boolean>` | Check if path is a file |
| `fixture.isDirectory(dir)` | `Promise<boolean>` | Check if path is a directory |
| `fixture.list(dir)` | `Promise<Iterable>` | List directory contents |
| `fixture.cleanup()` | `Promise<void>` | Delete the fixture directory (auto-runs via `onTestFinished`) |
All [`hfs`](https://github.com/humanwhocodes/humanfs) methods are available on the fixture, scoped to the fixture root. The table above covers the most common ones.
### File Tree Values
| Type | Behavior |
|------|----------|
| `string` | Written as-is |
| `object` / `array` | Auto-serialized as JSON for `.json` keys |
| `Buffer` | Written as binary |
| Nested object (key has no `.`) | Creates a subdirectory |
| `(ctx) => ...` | Dynamic — receives `importMeta` and `symlink` helpers |
The `importMeta` context provides:
- `importMeta.url` — fixture root as a `file://` URL string
- `importMeta.filename` — absolute filesystem path to the fixture root
- `importMeta.dirname` — same as `filename` (root is a directory)
- `importMeta.resolve(path)` — resolve a relative path against the fixture root
The `symlink` helper creates a symbolic link:
```ts
{
"target.txt": "real content",
"link.txt": ({ symlink }) => symlink("./target.txt"),
}
```
Creates a mock test environment with streams and env vars. Cleanup is automatic via `onTestFinished` — no `beforeAll`/`afterAll` needed.
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { createMocks, type Mocks } from "@bomb.sh/tools/test-utils";
describe("my-cli", () => {
let mocks: Mocks;
beforeEach(() => {
mocks = createMocks({
input: true, // MockReadable with defaults
output: { columns: 120, isTTY: true }, // MockWritable
env: { CI: "true", NO_COLOR: "1" },
});
});
it("writes output", () => {
render(mocks.input, mocks.output);
expect(mocks.output.buffer.join("")).toContain("hello");
});
});
```
| Option | Type | Description |
|--------|------|-------------|
| `env` | `Record<string, string \| undefined>` | Environment variables to stub for the test duration |
| `input` | `true \| { isTTY?: boolean }` | Create a `MockReadable`. Pass `true` for defaults |
| `output` | `true \| { columns?: number; rows?: number; isTTY?: boolean }` | Create a `MockWritable`. Defaults: 80×20, non-TTY |
| Member | Type | Description |
|--------|------|-------------|
| `isTTY` | `boolean` | Whether the stream is a TTY |
| `isRaw` | `boolean` | Whether raw mode is enabled |
| `setRawMode()` | `() => void` | Enable raw mode |
| `pushValue(val)` | `(val: unknown) => void` | Push a value to the readable buffer |
| `close()` | `() => void` | Signal end of stream |
| Member | Type | Description |
|--------|------|-------------|
| `buffer` | `string[]` | All written chunks as strings |
| `isTTY` | `boolean` | Whether the stream is a TTY |
| `columns` | `number` | Terminal width (default 80) |
| `rows` | `number` | Terminal height (default 20) |
| `resize(columns, rows)` | `(columns: number, rows: number) => void` | Resize and emit `"resize"` event |
Files ending in `.test-d.ts` run compile-time type assertions using `expectTypeOf` from vitest. No runtime code executes — these tests verify that types resolve correctly.
```ts
import { describe, expectTypeOf, test } from "vitest";
import type { PathParams } from "./params.ts";
describe("PathParams", () => {
test("extracts single param", () => {
expectTypeOf({} as PathParams<"/users/[id]">).toMatchTypeOf<{
id: string;
}>();
});
test("extracts spread params", () => {
expectTypeOf(
{} as PathParams<"/files/[...path]">
).toMatchTypeOf<{ path: string[] }>();
});
test("no params returns empty object", () => {
expectTypeOf({} as PathParams<"/static/page">).toMatchTypeOf<
Record<string, never>
>();
});
});
```
Tests must be colocated next to the source file they test.
```
__tests__/
schema.test.ts
src/
schema.ts
test/
schema.test.ts
src/
schema.ts
src/
schema.ts
schema.test.ts
```
Fixtures must be declared inline in each test. Shared fixtures make tests coupled and hard to read.
```ts
// Wrong — shared fixture across tests
const sharedTree = {
"package.json": { name: "test", version: "1.0.0" },
"src/index.ts": "export default 1",
};
it("test a", async () => {
const fixture = await createFixture(sharedTree);
// ...
});
it("test b", async () => {
const fixture = await createFixture(sharedTree);
// ...
});
// Correct — each test declares its own fixture
it("test a", async () => {
const fixture = await createFixture({
"package.json": { name: "test", version: "1.0.0" },
});
// ...
});
it("test b", async () => {
const fixture = await createFixture({
"src/index.ts": "export default 1",
});
// ...
});
```
Write tests that verify observable outputs and side effects, not internal function calls, mock counts, or private state.
```ts
// Wrong — testing internal implementation
it("calls internal parser three times", async () => {
const spy = vi.spyOn(internals, "parse");
await processConfig(input);
expect(spy).toHaveBeenCalledTimes(3);
});
// Correct — testing observable behavior
it("produces valid output from config", async () => {
const fixture = await createFixture({
"config.json": { entry: "src/index.ts" },
});
const result = await processConfig(fixture.root);
expect(result.entry).toBe("src/index.ts");
});
```
`bsh test` provides its own config. A project-level `vitest.config.ts` shadows the bsh defaults (ANSI serialization, dist exclusion, FORCE_COLOR).
```
# Wrong
vitest.config.ts exists in project root
# Correct
No vitest config — bsh handles it
```
### HIGH: Manual env/mock cleanup instead of createMocks
Use `createMocks` instead of manual `vi.stubEnv`/`vi.unstubAllEnvs` patterns. It auto-cleans via `onTestFinished`.
```ts
// Wrong — manual lifecycle
beforeEach(() => { vi.stubEnv("CI", "true"); });
afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); });
// Correct
beforeEach(() => { mocks = createMocks({ env: { CI: "true" } }); });
```
Always use `pnpm run test`. The `bsh test` wrapper configures vitest correctly.
```sh
pnpm exec vitest run
npx vitest
vitest run
pnpm run test
pnpm run test -- src/commands/build.test.ts
```
See also: **lint/SKILL.md** — Tests must follow the same coding conventions (no `node:path`, consistent type imports, named exports, etc.).