From f466368caf9e4762ba36e08e3ae1365b5d2314ac Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 26 Mar 2026 21:11:49 -0400 Subject: [PATCH] feat(test-utils): improve test config --- .changeset/ripe-foxes-repair.md | 5 + .changeset/silent-camels-train.md | 5 + package.json | 7 +- pnpm-lock.yaml | 12 ++ src/commands/build.ts | 28 +-- src/commands/test-utils/fixture.test.ts | 41 ++++ src/commands/test-utils/fixture.ts | 233 +++++++++++++++++++++++ src/commands/test-utils/index.ts | 2 + src/commands/test-utils/mock.test.ts | 43 +++++ src/commands/test-utils/mock.ts | 58 ++++++ src/commands/test-utils/stdio.test.ts | 79 ++++++++ src/commands/test-utils/stdio.ts | 68 +++++++ src/commands/test-utils/vitest.config.ts | 11 ++ src/commands/test.ts | 20 +- src/test-utils.test.ts | 41 ---- src/test-utils.ts | 233 ----------------------- 16 files changed, 591 insertions(+), 295 deletions(-) create mode 100644 .changeset/ripe-foxes-repair.md create mode 100644 .changeset/silent-camels-train.md create mode 100644 src/commands/test-utils/fixture.test.ts create mode 100644 src/commands/test-utils/fixture.ts create mode 100644 src/commands/test-utils/index.ts create mode 100644 src/commands/test-utils/mock.test.ts create mode 100644 src/commands/test-utils/mock.ts create mode 100644 src/commands/test-utils/stdio.test.ts create mode 100644 src/commands/test-utils/stdio.ts create mode 100644 src/commands/test-utils/vitest.config.ts delete mode 100644 src/test-utils.test.ts delete mode 100644 src/test-utils.ts diff --git a/.changeset/ripe-foxes-repair.md b/.changeset/ripe-foxes-repair.md new file mode 100644 index 0000000..ee6287d --- /dev/null +++ b/.changeset/ripe-foxes-repair.md @@ -0,0 +1,5 @@ +--- +"@bomb.sh/tools": patch +--- + +Ignores colocated `*.test.ts` files in build diff --git a/.changeset/silent-camels-train.md b/.changeset/silent-camels-train.md new file mode 100644 index 0000000..13eca6b --- /dev/null +++ b/.changeset/silent-camels-train.md @@ -0,0 +1,5 @@ +--- +"@bomb.sh/tools": patch +--- + +Adds automatic `vitest` config with `vitest-ansi-serializer` diff --git a/package.json b/package.json index 38b838a..8514a0a 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ }, "./skills/*": "./skills/*", "./test-utils": { - "types": "./dist/test-utils.d.mts", - "import": "./dist/test-utils.mjs" + "types": "./dist/test-utils/index.d.mts", + "import": "./dist/test-utils/index.mjs" }, "./*": "./dist/*", "./package.json": "./package.json", @@ -70,7 +70,8 @@ "publint": "^0.3.18", "tinyexec": "^1.0.1", "tsdown": "^0.21.0-beta.2", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "vitest-ansi-serializer": "^0.2.1" }, "devDependencies": { "@changesets/cli": "^2.28.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d738f6..46799b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3) + vitest-ansi-serializer: + specifier: ^0.2.1 + version: 0.2.1(vitest@4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3)) devDependencies: '@changesets/cli': specifier: ^2.28.1 @@ -1621,6 +1624,11 @@ packages: yaml: optional: true + vitest-ansi-serializer@0.2.1: + resolution: {integrity: sha512-IC60vT8raDlHwk2tZAy9wfetJMJkVOGC50jyjcC1HTYBAYfJEXVeKe72Jd5Jzcw1Xt73Nri2cdE98p+K2mnDrA==} + peerDependencies: + vitest: ^3.0.0 || ^4.0.0 + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3021,6 +3029,10 @@ snapshots: jiti: 2.6.1 yaml: 2.8.3 + vitest-ansi-serializer@0.2.1(vitest@4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3)): + dependencies: + vitest: 4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3) + vitest@4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.18 diff --git a/src/commands/build.ts b/src/commands/build.ts index 677cedb..d4abc37 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -3,20 +3,20 @@ import { build as tsdown } from "tsdown"; import type { CommandContext } from "../context.ts"; export async function build(ctx: CommandContext) { - const args = parse(ctx.args, { - boolean: ["bundle", "dts", "minify"], - }); + const args = parse(ctx.args, { + boolean: ["bundle", "dts", "minify"], + }); - const entry = args._.length > 0 ? args._.map(String) : ["src/**/*.ts"]; + const entry = args._.length > 0 ? args._.map(String) : ["src/**/*.ts", "!src/**/*.test.ts"]; - await tsdown({ - config: false, - entry, - format: "esm", - sourcemap: true, - clean: true, - unbundle: !args.bundle, - dts: args.dts, - minify: args.minify, - }); + await tsdown({ + config: false, + entry, + format: "esm", + sourcemap: true, + clean: true, + unbundle: !args.bundle, + dts: args.dts, + minify: args.minify, + }); } diff --git a/src/commands/test-utils/fixture.test.ts b/src/commands/test-utils/fixture.test.ts new file mode 100644 index 0000000..f1d5d24 --- /dev/null +++ b/src/commands/test-utils/fixture.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { existsSync } from "node:fs"; +import { createFixture } from "./fixture.ts"; + +describe("createFixture", () => { + it("creates files on disk from inline tree", async () => { + const fixture = await createFixture({ + "hello.txt": "hello world", + }); + expect(await fixture.text("hello.txt")).toBe("hello world"); + }); + + it("creates nested directories from slash-separated keys", async () => { + const fixture = await createFixture({ + "src/index.ts": "export const x = 1", + "src/utils/helpers.ts": "export function help() {}", + }); + expect(await fixture.isFile("src/index.ts")).toBe(true); + expect(await fixture.isFile("src/utils/helpers.ts")).toBe(true); + }); + + it("resolve returns absolute path within fixture root", async () => { + const fixture = await createFixture({ "a.txt": "" }); + expect(fixture.resolve("a.txt").toString()).toContain(fixture.root.toString()); + }); + + it("text reads the actual file", async () => { + const fixture = await createFixture({ "a.txt": "Empty" }); + expect(await fixture.text("a.txt")).toEqual("Empty"); + await fixture.write("a.txt", "Hello world!"); + expect(await fixture.text("a.txt")).toEqual("Hello world!"); + }); + + it("cleanup removes the temp directory", async () => { + const fixture = await createFixture({ "a.txt": "" }); + const path = fixture.root; + expect(await fixture.isDirectory(fixture.root)).toBe(true); + await fixture.cleanup(); + expect(existsSync(path)).toBe(false); + }); +}); diff --git a/src/commands/test-utils/fixture.ts b/src/commands/test-utils/fixture.ts new file mode 100644 index 0000000..ce15436 --- /dev/null +++ b/src/commands/test-utils/fixture.ts @@ -0,0 +1,233 @@ +import { mkdtemp, symlink as fsSymlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { NodeHfs } from "@humanfs/node"; +import type { HfsImpl } from "@humanfs/types"; +import { expect, onTestFinished } from "vitest"; + +interface ScopedHfsImpl extends Required { + text(file: string | URL): Promise; + json(file: string | URL): Promise; +} + +/** + * A temporary fixture directory with a scoped `hfs` filesystem. + * + * Includes all `hfs` methods — paths are resolved relative to the fixture root. + */ +export interface Fixture extends ScopedHfsImpl { + /** The fixture root as a `file://` URL. */ + root: URL; + /** Resolve a relative path within the fixture root. */ + resolve: (...segments: string[]) => URL; + /** Delete the fixture directory. Also runs automatically via `onTestFinished`. */ + cleanup: () => Promise; +} + +/** Context passed to dynamic file content functions. */ +export interface FileContext { + /** + * Metadata about the fixture root, analogous to `import.meta`. + * + * - `url` — the fixture root as a `file://` URL string + * - `filename` — absolute filesystem path to the fixture root + * - `dirname` — same as `filename` (root is a directory) + * - `resolve(path)` — resolve a relative path against the fixture root + */ + importMeta: { + url: string; + filename: string; + dirname: string; + resolve: (path: string) => string; + }; + /** + * Create a symbolic link to `target`. + * + * Returns a `SymlinkMarker` — the fixture will create the symlink on disk. + * + * @example + * ```ts + * { 'link.txt': ({ symlink }) => symlink('./target.txt') } + * ``` + */ + symlink: (target: string) => SymlinkMarker; +} + +const SYMLINK = Symbol("symlink"); + +/** Opaque marker returned by `ctx.symlink()`. */ +export interface SymlinkMarker { + [SYMLINK]: true; + target: string; +} + +/** + * A value in the file tree. + * + * | Type | Example | + * |------|---------| + * | `string` | `'file content'` | + * | `object` / `array` | `{ name: 'cool' }` — auto-serialized as JSON for `.json` keys | + * | `Buffer` | `Buffer.from([0x89, 0x50])` | + * | Nested directory | `{ dir: { 'file.txt': 'content' } }` | + * | Function | `({ importMeta, symlink }) => symlink('./target')` | + */ +export type FileTreeValue = + | string + | Buffer + | Record + | unknown[] + | FileTree + | ((ctx: FileContext) => string | Buffer | SymlinkMarker); + +/** A recursive tree of files and directories. */ +export interface FileTree { + [key: string]: FileTreeValue; +} + +function isSymlinkMarker(value: unknown): value is SymlinkMarker { + return typeof value === "object" && value !== null && SYMLINK in value; +} + +function isFileTree(value: unknown): value is FileTree { + return ( + typeof value === "object" && + value !== null && + !Buffer.isBuffer(value) && + !Array.isArray(value) && + !isSymlinkMarker(value) + ); +} + +function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl { + const r = (p: string | URL) => new URL(`./${p}`, base); + const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const; + + return { + text: (p: string | URL) => inner.text(r(p)), + json: (p: string | URL) => inner.json(r(p)), + bytes: (p) => inner.bytes(r(p)), + write: (p, c) => inner.write(r(p), c), + append: (p, c) => inner.append(r(p), c), + isFile: (p) => inner.isFile(r(p)), + isDirectory: (p) => inner.isDirectory(r(p)), + createDirectory: (p) => inner.createDirectory(r(p)), + delete: (p) => inner.delete(r(p)), + deleteAll: (p) => inner.deleteAll(r(p)), + list: (p) => inner.list(r(p)), + size: (p) => inner.size(r(p)), + lastModified: (p) => inner.lastModified(r(p)), + copy: (s, d) => inner.copy(...r2(s, d)), + copyAll: (s, d) => inner.copyAll(...r2(s, d)), + move: (s, d) => inner.move(...r2(s, d)), + moveAll: (s, d) => inner.moveAll(...r2(s, d)), + }; +} + +/** + * Create a temporary fixture directory from an inline file tree. + * + * Returns a {@link Fixture} with all `hfs` methods scoped to the fixture root. + * + * @example + * ```ts + * 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}`, + * }) + * + * const text = await fixture.text('hello.txt') + * const json = await fixture.json('package.json') + * ``` + */ +export async function createFixture(files: FileTree): Promise { + const raw = expect.getState().currentTestName ?? "bsh"; + const prefix = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const root = new URL(`${prefix}-`, `file://${tmpdir()}/`); + const path = await mkdtemp(fileURLToPath(root)); + const base = pathToFileURL(path + sep); + + const inner = new NodeHfs(); + const scoped = scopeHfs(inner, base); + const resolve = (...segments: string[]) => new URL(`./${segments.join("/")}`, base); + + const ctx: FileContext = { + importMeta: { + url: base.toString(), + filename: fileURLToPath(base), + dirname: fileURLToPath(base), + resolve: (p: string) => new URL(`./${p}`, base).toString(), + }, + symlink: (target: string): SymlinkMarker => ({ [SYMLINK]: true, target }), + }; + + async function writeTree(tree: FileTree, dir: URL): Promise { + for (const [name, raw] of Object.entries(tree)) { + const url = new URL(name, dir); + + // Nested directory object (not a plain value) + if ( + typeof raw !== "function" && + !Buffer.isBuffer(raw) && + !Array.isArray(raw) && + isFileTree(raw) && + !name.includes(".") + ) { + await inner.createDirectory(url); + // Trailing slash so nested entries resolve relative to the dir + await writeTree(raw, new URL(`${url}/`)); + continue; + } + + // Ensure parent directory exists + const parent = new URL("./", url); + await inner.createDirectory(parent); + + // Resolve functions + const content = typeof raw === "function" ? raw(ctx) : raw; + + // Symlink + if (isSymlinkMarker(content)) { + await fsSymlink(content.target, url); + continue; + } + + // Buffer + if (Buffer.isBuffer(content)) { + await inner.write(url, content); + continue; + } + + // JSON auto-serialization for .json files with non-string content + if (name.endsWith(".json") && typeof content !== "string") { + await inner.write(url, JSON.stringify(content, null, 2)); + continue; + } + + // String content + await inner.write(url, content as string); + } + } + + await writeTree(files, base); + + const cleanup = () => inner.deleteAll(path).then(() => undefined); + onTestFinished(cleanup); + + return { + root: base, + resolve, + cleanup, + ...scoped, + }; +} diff --git a/src/commands/test-utils/index.ts b/src/commands/test-utils/index.ts new file mode 100644 index 0000000..b8f5612 --- /dev/null +++ b/src/commands/test-utils/index.ts @@ -0,0 +1,2 @@ +export { createFixture } from "./fixture.ts"; +export { createMocks, type Mocks } from "./mock.ts"; diff --git a/src/commands/test-utils/mock.test.ts b/src/commands/test-utils/mock.test.ts new file mode 100644 index 0000000..bb28c4a --- /dev/null +++ b/src/commands/test-utils/mock.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { createMocks } from "./mock.ts"; +import { MockReadable, MockWritable } from "./stdio.ts"; + +describe("createMocks", () => { + it("returns undefined streams when not requested", () => { + const mocks = createMocks(); + expect(mocks.input).toBeUndefined(); + expect(mocks.output).toBeUndefined(); + }); + + it("creates input stream with `true`", () => { + const mocks = createMocks({ input: true }); + expect(mocks.input).toBeInstanceOf(MockReadable); + }); + + it("creates output stream with `true`", () => { + const mocks = createMocks({ output: true }); + expect(mocks.output).toBeInstanceOf(MockWritable); + }); + + it("passes config to output stream", () => { + const mocks = createMocks({ output: { columns: 120, rows: 40, isTTY: true } }); + expect(mocks.output.columns).toBe(120); + expect(mocks.output.rows).toBe(40); + expect(mocks.output.isTTY).toBe(true); + }); + + it("passes config to input stream", () => { + const mocks = createMocks({ input: { isTTY: true } }); + expect(mocks.input.isTTY).toBe(true); + }); + + it("stubs env vars for duration of test", () => { + createMocks({ env: { TEST_MOCK_VAR: "hello" } }); + expect(process.env.TEST_MOCK_VAR).toBe("hello"); + }); + + it("restores env vars after test finishes", async () => { + // Previous test's onTestFinished should have cleaned up + expect(process.env.TEST_MOCK_VAR).toBeUndefined(); + }); +}); diff --git a/src/commands/test-utils/mock.ts b/src/commands/test-utils/mock.ts new file mode 100644 index 0000000..b67718b --- /dev/null +++ b/src/commands/test-utils/mock.ts @@ -0,0 +1,58 @@ +import { onTestFinished, vi } from "vitest"; +import { MockReadable, MockWritable } from "./stdio.ts"; + +type InputConfig = true | ConstructorParameters[0]; +type OutputConfig = true | ConstructorParameters[0]; + +export interface CreateMockOptions { + /** Environment variables to set for the duration of the test. */ + env?: Record; + /** Pass `true` for defaults, or a config object. Omit to skip. */ + input?: InputConfig; + /** Pass `true` for defaults, or a config object for columns/rows/isTTY. */ + output?: OutputConfig; +} + +export type Mocks = { + input: O["input"] extends InputConfig ? MockReadable : undefined; + output: O["output"] extends OutputConfig ? MockWritable : undefined; +}; + +/** + * Create a mock test environment with streams, env vars. + * + * Cleanup is automatic via `onTestFinished` — no `beforeAll`/`afterAll` needed. + * + * @example + * ```ts + * let mocks: Mocks; + * beforeEach(() => { + * mocks = createMocks({ env: { CI: 'true' }}); + * }); + * + * it('works', () => { + * doThing(mocks.input, mocks.output); + * }); + * ``` + */ +export function createMocks(opts?: O): Mocks; +export function createMocks(opts: CreateMockOptions = {}): Mocks { + const input = opts.input + ? new MockReadable(typeof opts.input === "object" ? opts.input : undefined) + : undefined; + const output = opts.output + ? new MockWritable(typeof opts.output === "object" ? opts.output : undefined) + : undefined; + if (opts.env) { + for (const [key, value] of Object.entries(opts.env)) { + vi.stubEnv(key, value); + } + } + + onTestFinished(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + return { input, output } as Mocks; +} diff --git a/src/commands/test-utils/stdio.test.ts b/src/commands/test-utils/stdio.test.ts new file mode 100644 index 0000000..2714bd4 --- /dev/null +++ b/src/commands/test-utils/stdio.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { MockReadable, MockWritable } from "./stdio.ts"; + +describe("MockReadable", () => { + it("defaults to non-TTY", () => { + const r = new MockReadable(); + expect(r.isTTY).toBe(false); + expect(r.isRaw).toBe(false); + }); + + it("respects isTTY config", () => { + const r = new MockReadable({ isTTY: true }); + expect(r.isTTY).toBe(true); + }); + + it("setRawMode enables raw mode", () => { + const r = new MockReadable(); + r.setRawMode(); + expect(r.isRaw).toBe(true); + }); + + it("pushValue delivers data on read", () => + new Promise((resolve) => { + const r = new MockReadable(); + r.on("data", (chunk) => { + expect(chunk.toString()).toBe("hello"); + resolve(); + }); + r.pushValue("hello"); + })); + + it("close ends the stream", async () => { + const r = new MockReadable(); + r.close(); + + const chunks: string[] = []; + for await (const chunk of r) { + chunks.push(chunk.toString()); + } + expect(chunks).toEqual([]); + }); +}); + +describe("MockWritable", () => { + it("defaults to 80x20 non-TTY", () => { + const w = new MockWritable(); + expect(w.isTTY).toBe(false); + expect(w.columns).toBe(80); + expect(w.rows).toBe(20); + }); + + it("accepts custom config", () => { + const w = new MockWritable({ columns: 120, rows: 40, isTTY: true }); + expect(w.isTTY).toBe(true); + expect(w.columns).toBe(120); + expect(w.rows).toBe(40); + }); + + it("captures written chunks", () => { + const w = new MockWritable(); + w.write("hello"); + w.write(" world"); + expect(w.buffer).toEqual(["hello", " world"]); + }); + + it("resize updates dimensions and emits event", () => { + const w = new MockWritable({ columns: 80, rows: 20 }); + let resized = false; + w.on("resize", () => { + resized = true; + }); + + w.resize(120, 40); + + expect(w.columns).toBe(120); + expect(w.rows).toBe(40); + expect(resized).toBe(true); + }); +}); diff --git a/src/commands/test-utils/stdio.ts b/src/commands/test-utils/stdio.ts new file mode 100644 index 0000000..9e28103 --- /dev/null +++ b/src/commands/test-utils/stdio.ts @@ -0,0 +1,68 @@ +import { Readable, Writable, type ReadableOptions, type WritableOptions } from "node:stream"; + +export class MockReadable extends Readable { + protected _buffer: unknown[] | null = []; + public isTTY = false; + public isRaw = false; + public setRawMode() { + this.isRaw = true; + } + + constructor(config?: { isTTY?: boolean }, opts?: ReadableOptions) { + super(opts); + this.isTTY = config?.isTTY ?? false; + } + + override _read() { + if (this._buffer === null) { + this.push(null); + return; + } + + for (const val of this._buffer) { + this.push(val); + } + + this._buffer = []; + } + + pushValue(val: unknown): void { + this._buffer?.push(val); + } + + close(): void { + this._buffer = null; + } +} + +export class MockWritable extends Writable { + public buffer: string[] = []; + public isTTY = false; + public columns = 80; + public rows = 20; + + constructor( + config?: { columns?: number; rows?: number; isTTY?: boolean }, + opts?: WritableOptions, + ) { + super(opts); + this.isTTY = config?.isTTY ?? false; + this.columns = config?.columns ?? 80; + this.rows = config?.rows ?? 20; + } + + public resize(columns: number, rows: number): void { + this.columns = columns; + this.rows = rows; + this.emit("resize"); + } + + override _write( + chunk: any, + _encoding: BufferEncoding, + callback: (error?: Error | null | undefined) => void, + ): void { + this.buffer.push(chunk.toString()); + callback(); + } +} diff --git a/src/commands/test-utils/vitest.config.ts b/src/commands/test-utils/vitest.config.ts new file mode 100644 index 0000000..7774dc8 --- /dev/null +++ b/src/commands/test-utils/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: ['dist/**', 'node_modules/**'], + env: { + FORCE_COLOR: '1', + }, + snapshotSerializers: ['vitest-ansi-serializer'], + }, +}); diff --git a/src/commands/test.ts b/src/commands/test.ts index 50a2617..ed21571 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -1,11 +1,23 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; import type { CommandContext } from "../context.ts"; import { local } from "../utils.ts"; +function resolveConfig(): string { + // Built output (.mjs) or source (.ts) + for (const ext of [".mjs", ".ts"]) { + const url = new URL(`./test-utils/vitest.config${ext}`, import.meta.url); + const path = fileURLToPath(url); + if (existsSync(path)) return path; + } + throw new Error("Could not resolve vitest.config file"); +} + export async function test(ctx: CommandContext) { - const stdio = x(local("vitest"), ["run", ...ctx.args]); + const stdio = x(local("vitest"), ["run", "--config", resolveConfig(), ...ctx.args]); - for await (const line of stdio) { - console.log(line); - } + for await (const line of stdio) { + console.log(line); + } } diff --git a/src/test-utils.test.ts b/src/test-utils.test.ts deleted file mode 100644 index dfca9f2..0000000 --- a/src/test-utils.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { existsSync } from "node:fs"; -import { createFixture } from "./test-utils.ts"; - -describe("createFixture", () => { - it("creates files on disk from inline tree", async () => { - const fixture = await createFixture({ - "hello.txt": "hello world", - }); - expect(await fixture.text("hello.txt")).toBe("hello world"); - }); - - it("creates nested directories from slash-separated keys", async () => { - const fixture = await createFixture({ - "src/index.ts": "export const x = 1", - "src/utils/helpers.ts": "export function help() {}", - }); - expect(await fixture.isFile("src/index.ts")).toBe(true); - expect(await fixture.isFile("src/utils/helpers.ts")).toBe(true); - }); - - it("resolve returns absolute path within fixture root", async () => { - const fixture = await createFixture({ "a.txt": "" }); - expect(fixture.resolve("a.txt").toString()).toContain(fixture.root.toString()); - }); - - it("text reads the actual file", async () => { - const fixture = await createFixture({ "a.txt": "Empty" }); - expect(await fixture.text("a.txt")).toEqual("Empty"); - await fixture.write("a.txt", "Hello world!"); - expect(await fixture.text("a.txt")).toEqual("Hello world!"); - }); - - it("cleanup removes the temp directory", async () => { - const fixture = await createFixture({ "a.txt": "" }); - const path = fixture.root; - expect(fixture.isDirectory(fixture.root)).toBe(true); - await fixture.cleanup(); - expect(existsSync(path)).toBe(false); - }); -}); diff --git a/src/test-utils.ts b/src/test-utils.ts deleted file mode 100644 index c37b48c..0000000 --- a/src/test-utils.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { mkdtemp, symlink as fsSymlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { sep } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { NodeHfs } from "@humanfs/node"; -import type { HfsImpl } from "@humanfs/types"; -import { expect, onTestFinished } from "vitest"; - -interface ScopedHfsImpl extends HfsImpl { - text(file: string | URL): Promise; - json(file: string | URL): Promise; -} - -/** - * A temporary fixture directory with a scoped `hfs` filesystem. - * - * Includes all `hfs` methods — paths are resolved relative to the fixture root. - */ -export interface Fixture extends ScopedHfsImpl { - /** The fixture root as a `file://` URL. */ - root: URL; - /** Resolve a relative path within the fixture root. */ - resolve: (...segments: string[]) => URL; - /** Delete the fixture directory. Also runs automatically via `onTestFinished`. */ - cleanup: () => Promise; -} - -/** Context passed to dynamic file content functions. */ -export interface FileContext { - /** - * Metadata about the fixture root, analogous to `import.meta`. - * - * - `url` — the fixture root as a `file://` URL string - * - `filename` — absolute filesystem path to the fixture root - * - `dirname` — same as `filename` (root is a directory) - * - `resolve(path)` — resolve a relative path against the fixture root - */ - importMeta: { - url: string; - filename: string; - dirname: string; - resolve: (path: string) => string; - }; - /** - * Create a symbolic link to `target`. - * - * Returns a `SymlinkMarker` — the fixture will create the symlink on disk. - * - * @example - * ```ts - * { 'link.txt': ({ symlink }) => symlink('./target.txt') } - * ``` - */ - symlink: (target: string) => SymlinkMarker; -} - -const SYMLINK = Symbol("symlink"); - -/** Opaque marker returned by `ctx.symlink()`. */ -export interface SymlinkMarker { - [SYMLINK]: true; - target: string; -} - -/** - * A value in the file tree. - * - * | Type | Example | - * |------|---------| - * | `string` | `'file content'` | - * | `object` / `array` | `{ name: 'cool' }` — auto-serialized as JSON for `.json` keys | - * | `Buffer` | `Buffer.from([0x89, 0x50])` | - * | Nested directory | `{ dir: { 'file.txt': 'content' } }` | - * | Function | `({ importMeta, symlink }) => symlink('./target')` | - */ -export type FileTreeValue = - | string - | Buffer - | Record - | unknown[] - | FileTree - | ((ctx: FileContext) => string | Buffer | SymlinkMarker); - -/** A recursive tree of files and directories. */ -export interface FileTree { - [key: string]: FileTreeValue; -} - -function isSymlinkMarker(value: unknown): value is SymlinkMarker { - return typeof value === "object" && value !== null && SYMLINK in value; -} - -function isFileTree(value: unknown): value is FileTree { - return ( - typeof value === "object" && - value !== null && - !Buffer.isBuffer(value) && - !Array.isArray(value) && - !isSymlinkMarker(value) - ); -} - -function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl { - const r = (p: string | URL) => new URL(`./${p}`, base); - const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const; - - return { - text: (p: string | URL) => inner.text(r(p)), - json: (p: string | URL) => inner.json(r(p)), - bytes: (p) => inner.bytes(r(p)), - write: (p, c) => inner.write(r(p), c), - append: (p, c) => inner.append(r(p), c), - isFile: (p) => inner.isFile(r(p)), - isDirectory: (p) => inner.isDirectory(r(p)), - createDirectory: (p) => inner.createDirectory(r(p)), - delete: (p) => inner.delete(r(p)), - deleteAll: (p) => inner.deleteAll(r(p)), - list: (p) => inner.list(r(p)), - size: (p) => inner.size(r(p)), - lastModified: (p) => inner.lastModified(r(p)), - copy: (s, d) => inner.copy(...r2(s, d)), - copyAll: (s, d) => inner.copyAll(...r2(s, d)), - move: (s, d) => inner.move(...r2(s, d)), - moveAll: (s, d) => inner.moveAll(...r2(s, d)), - }; -} - -/** - * Create a temporary fixture directory from an inline file tree. - * - * Returns a {@link Fixture} with all `hfs` methods scoped to the fixture root. - * - * @example - * ```ts - * 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}`, - * }) - * - * const text = await fixture.text('hello.txt') - * const json = await fixture.json('package.json') - * ``` - */ -export async function createFixture(files: FileTree): Promise { - const raw = expect.getState().currentTestName ?? "bsh"; - const prefix = raw - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - const root = new URL(`${prefix}-`, `file://${tmpdir()}/`); - const path = await mkdtemp(fileURLToPath(root)); - const base = pathToFileURL(path + sep); - - const inner = new NodeHfs(); - const scoped = scopeHfs(inner, base); - const resolve = (...segments: string[]) => new URL(`./${segments.join("/")}`, base); - - const ctx: FileContext = { - importMeta: { - url: base.toString(), - filename: fileURLToPath(base), - dirname: fileURLToPath(base), - resolve: (p: string) => new URL(`./${p}`, base).toString(), - }, - symlink: (target: string): SymlinkMarker => ({ [SYMLINK]: true, target }), - }; - - async function writeTree(tree: FileTree, dir: URL): Promise { - for (const [name, raw] of Object.entries(tree)) { - const url = new URL(name, dir); - - // Nested directory object (not a plain value) - if ( - typeof raw !== "function" && - !Buffer.isBuffer(raw) && - !Array.isArray(raw) && - isFileTree(raw) && - !name.includes(".") - ) { - await inner.createDirectory(url); - // Trailing slash so nested entries resolve relative to the dir - await writeTree(raw, new URL(`${url}/`)); - continue; - } - - // Ensure parent directory exists - const parent = new URL("./", url); - await inner.createDirectory(parent); - - // Resolve functions - const content = typeof raw === "function" ? raw(ctx) : raw; - - // Symlink - if (isSymlinkMarker(content)) { - await fsSymlink(content.target, url); - continue; - } - - // Buffer - if (Buffer.isBuffer(content)) { - await inner.write(url, content); - continue; - } - - // JSON auto-serialization for .json files with non-string content - if (name.endsWith(".json") && typeof content !== "string") { - await inner.write(url, JSON.stringify(content, null, 2)); - continue; - } - - // String content - await inner.write(url, content as string); - } - } - - await writeTree(files, base); - - const cleanup = () => inner.deleteAll(path).then(() => undefined); - onTestFinished(cleanup); - - return { - root: base, - resolve, - cleanup, - ...scoped, - }; -}