[READ-ONLY] Mirror of https://github.com/bombshell-dev/tools. Internal CLI to standardize tooling across all Bombshell projects
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(test-utils): improve test config (#19)

authored by

Nate Moore and committed by
GitHub
(Mar 26, 2026, 9:24 PM EDT) ffba54eb 22ea4b92

+591 -295
+5
.changeset/ripe-foxes-repair.md
··· 1 + --- 2 + "@bomb.sh/tools": patch 3 + --- 4 + 5 + Ignores colocated `*.test.ts` files in build
+5
.changeset/silent-camels-train.md
··· 1 + --- 2 + "@bomb.sh/tools": patch 3 + --- 4 + 5 + Adds automatic `vitest` config with `vitest-ansi-serializer`
+4 -3
package.json
··· 40 40 }, 41 41 "./skills/*": "./skills/*", 42 42 "./test-utils": { 43 - "types": "./dist/test-utils.d.mts", 44 - "import": "./dist/test-utils.mjs" 43 + "types": "./dist/test-utils/index.d.mts", 44 + "import": "./dist/test-utils/index.mjs" 45 45 }, 46 46 "./*": "./dist/*", 47 47 "./package.json": "./package.json", ··· 70 70 "publint": "^0.3.18", 71 71 "tinyexec": "^1.0.1", 72 72 "tsdown": "^0.21.0-beta.2", 73 - "vitest": "^4.0.18" 73 + "vitest": "^4.0.18", 74 + "vitest-ansi-serializer": "^0.2.1" 74 75 }, 75 76 "devDependencies": { 76 77 "@changesets/cli": "^2.28.1",
+12
pnpm-lock.yaml
··· 41 41 vitest: 42 42 specifier: ^4.0.18 43 43 version: 4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3) 44 + vitest-ansi-serializer: 45 + specifier: ^0.2.1 46 + version: 0.2.1(vitest@4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3)) 44 47 devDependencies: 45 48 '@changesets/cli': 46 49 specifier: ^2.28.1 ··· 1621 1624 yaml: 1622 1625 optional: true 1623 1626 1627 + vitest-ansi-serializer@0.2.1: 1628 + resolution: {integrity: sha512-IC60vT8raDlHwk2tZAy9wfetJMJkVOGC50jyjcC1HTYBAYfJEXVeKe72Jd5Jzcw1Xt73Nri2cdE98p+K2mnDrA==} 1629 + peerDependencies: 1630 + vitest: ^3.0.0 || ^4.0.0 1631 + 1624 1632 vitest@4.0.18: 1625 1633 resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} 1626 1634 engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} ··· 3020 3028 fsevents: 2.3.3 3021 3029 jiti: 2.6.1 3022 3030 yaml: 2.8.3 3031 + 3032 + vitest-ansi-serializer@0.2.1(vitest@4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3)): 3033 + dependencies: 3034 + vitest: 4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3) 3023 3035 3024 3036 vitest@4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3): 3025 3037 dependencies:
+14 -14
src/commands/build.ts
··· 3 3 import type { CommandContext } from "../context.ts"; 4 4 5 5 export async function build(ctx: CommandContext) { 6 - const args = parse(ctx.args, { 7 - boolean: ["bundle", "dts", "minify"], 8 - }); 6 + const args = parse(ctx.args, { 7 + boolean: ["bundle", "dts", "minify"], 8 + }); 9 9 10 - const entry = args._.length > 0 ? args._.map(String) : ["src/**/*.ts"]; 10 + const entry = args._.length > 0 ? args._.map(String) : ["src/**/*.ts", "!src/**/*.test.ts"]; 11 11 12 - await tsdown({ 13 - config: false, 14 - entry, 15 - format: "esm", 16 - sourcemap: true, 17 - clean: true, 18 - unbundle: !args.bundle, 19 - dts: args.dts, 20 - minify: args.minify, 21 - }); 12 + await tsdown({ 13 + config: false, 14 + entry, 15 + format: "esm", 16 + sourcemap: true, 17 + clean: true, 18 + unbundle: !args.bundle, 19 + dts: args.dts, 20 + minify: args.minify, 21 + }); 22 22 }
+41
src/commands/test-utils/fixture.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { existsSync } from "node:fs"; 3 + import { createFixture } from "./fixture.ts"; 4 + 5 + describe("createFixture", () => { 6 + it("creates files on disk from inline tree", async () => { 7 + const fixture = await createFixture({ 8 + "hello.txt": "hello world", 9 + }); 10 + expect(await fixture.text("hello.txt")).toBe("hello world"); 11 + }); 12 + 13 + it("creates nested directories from slash-separated keys", async () => { 14 + const fixture = await createFixture({ 15 + "src/index.ts": "export const x = 1", 16 + "src/utils/helpers.ts": "export function help() {}", 17 + }); 18 + expect(await fixture.isFile("src/index.ts")).toBe(true); 19 + expect(await fixture.isFile("src/utils/helpers.ts")).toBe(true); 20 + }); 21 + 22 + it("resolve returns absolute path within fixture root", async () => { 23 + const fixture = await createFixture({ "a.txt": "" }); 24 + expect(fixture.resolve("a.txt").toString()).toContain(fixture.root.toString()); 25 + }); 26 + 27 + it("text reads the actual file", async () => { 28 + const fixture = await createFixture({ "a.txt": "Empty" }); 29 + expect(await fixture.text("a.txt")).toEqual("Empty"); 30 + await fixture.write("a.txt", "Hello world!"); 31 + expect(await fixture.text("a.txt")).toEqual("Hello world!"); 32 + }); 33 + 34 + it("cleanup removes the temp directory", async () => { 35 + const fixture = await createFixture({ "a.txt": "" }); 36 + const path = fixture.root; 37 + expect(await fixture.isDirectory(fixture.root)).toBe(true); 38 + await fixture.cleanup(); 39 + expect(existsSync(path)).toBe(false); 40 + }); 41 + });
+233
src/commands/test-utils/fixture.ts
··· 1 + import { mkdtemp, symlink as fsSymlink } from "node:fs/promises"; 2 + import { tmpdir } from "node:os"; 3 + import { sep } from "node:path"; 4 + import { fileURLToPath, pathToFileURL } from "node:url"; 5 + import { NodeHfs } from "@humanfs/node"; 6 + import type { HfsImpl } from "@humanfs/types"; 7 + import { expect, onTestFinished } from "vitest"; 8 + 9 + interface ScopedHfsImpl extends Required<HfsImpl> { 10 + text(file: string | URL): Promise<string | undefined>; 11 + json(file: string | URL): Promise<unknown | undefined>; 12 + } 13 + 14 + /** 15 + * A temporary fixture directory with a scoped `hfs` filesystem. 16 + * 17 + * Includes all `hfs` methods — paths are resolved relative to the fixture root. 18 + */ 19 + export interface Fixture extends ScopedHfsImpl { 20 + /** The fixture root as a `file://` URL. */ 21 + root: URL; 22 + /** Resolve a relative path within the fixture root. */ 23 + resolve: (...segments: string[]) => URL; 24 + /** Delete the fixture directory. Also runs automatically via `onTestFinished`. */ 25 + cleanup: () => Promise<void>; 26 + } 27 + 28 + /** Context passed to dynamic file content functions. */ 29 + export interface FileContext { 30 + /** 31 + * Metadata about the fixture root, analogous to `import.meta`. 32 + * 33 + * - `url` — the fixture root as a `file://` URL string 34 + * - `filename` — absolute filesystem path to the fixture root 35 + * - `dirname` — same as `filename` (root is a directory) 36 + * - `resolve(path)` — resolve a relative path against the fixture root 37 + */ 38 + importMeta: { 39 + url: string; 40 + filename: string; 41 + dirname: string; 42 + resolve: (path: string) => string; 43 + }; 44 + /** 45 + * Create a symbolic link to `target`. 46 + * 47 + * Returns a `SymlinkMarker` — the fixture will create the symlink on disk. 48 + * 49 + * @example 50 + * ```ts 51 + * { 'link.txt': ({ symlink }) => symlink('./target.txt') } 52 + * ``` 53 + */ 54 + symlink: (target: string) => SymlinkMarker; 55 + } 56 + 57 + const SYMLINK = Symbol("symlink"); 58 + 59 + /** Opaque marker returned by `ctx.symlink()`. */ 60 + export interface SymlinkMarker { 61 + [SYMLINK]: true; 62 + target: string; 63 + } 64 + 65 + /** 66 + * A value in the file tree. 67 + * 68 + * | Type | Example | 69 + * |------|---------| 70 + * | `string` | `'file content'` | 71 + * | `object` / `array` | `{ name: 'cool' }` — auto-serialized as JSON for `.json` keys | 72 + * | `Buffer` | `Buffer.from([0x89, 0x50])` | 73 + * | Nested directory | `{ dir: { 'file.txt': 'content' } }` | 74 + * | Function | `({ importMeta, symlink }) => symlink('./target')` | 75 + */ 76 + export type FileTreeValue = 77 + | string 78 + | Buffer 79 + | Record<string, unknown> 80 + | unknown[] 81 + | FileTree 82 + | ((ctx: FileContext) => string | Buffer | SymlinkMarker); 83 + 84 + /** A recursive tree of files and directories. */ 85 + export interface FileTree { 86 + [key: string]: FileTreeValue; 87 + } 88 + 89 + function isSymlinkMarker(value: unknown): value is SymlinkMarker { 90 + return typeof value === "object" && value !== null && SYMLINK in value; 91 + } 92 + 93 + function isFileTree(value: unknown): value is FileTree { 94 + return ( 95 + typeof value === "object" && 96 + value !== null && 97 + !Buffer.isBuffer(value) && 98 + !Array.isArray(value) && 99 + !isSymlinkMarker(value) 100 + ); 101 + } 102 + 103 + function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl { 104 + const r = (p: string | URL) => new URL(`./${p}`, base); 105 + const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const; 106 + 107 + return { 108 + text: (p: string | URL) => inner.text(r(p)), 109 + json: (p: string | URL) => inner.json(r(p)), 110 + bytes: (p) => inner.bytes(r(p)), 111 + write: (p, c) => inner.write(r(p), c), 112 + append: (p, c) => inner.append(r(p), c), 113 + isFile: (p) => inner.isFile(r(p)), 114 + isDirectory: (p) => inner.isDirectory(r(p)), 115 + createDirectory: (p) => inner.createDirectory(r(p)), 116 + delete: (p) => inner.delete(r(p)), 117 + deleteAll: (p) => inner.deleteAll(r(p)), 118 + list: (p) => inner.list(r(p)), 119 + size: (p) => inner.size(r(p)), 120 + lastModified: (p) => inner.lastModified(r(p)), 121 + copy: (s, d) => inner.copy(...r2(s, d)), 122 + copyAll: (s, d) => inner.copyAll(...r2(s, d)), 123 + move: (s, d) => inner.move(...r2(s, d)), 124 + moveAll: (s, d) => inner.moveAll(...r2(s, d)), 125 + }; 126 + } 127 + 128 + /** 129 + * Create a temporary fixture directory from an inline file tree. 130 + * 131 + * Returns a {@link Fixture} with all `hfs` methods scoped to the fixture root. 132 + * 133 + * @example 134 + * ```ts 135 + * const fixture = await createFixture({ 136 + * 'hello.txt': 'hello world', 137 + * 'package.json': { name: 'test', version: '1.0.0' }, 138 + * 'icon.png': Buffer.from([0x89, 0x50]), 139 + * src: { 140 + * 'index.ts': 'export default 1', 141 + * }, 142 + * 'link.txt': ({ symlink }) => symlink('./hello.txt'), 143 + * 'info.txt': ({ importMeta }) => `Root: ${importMeta.url}`, 144 + * }) 145 + * 146 + * const text = await fixture.text('hello.txt') 147 + * const json = await fixture.json('package.json') 148 + * ``` 149 + */ 150 + export async function createFixture(files: FileTree): Promise<Fixture> { 151 + const raw = expect.getState().currentTestName ?? "bsh"; 152 + const prefix = raw 153 + .toLowerCase() 154 + .replace(/[^a-z0-9]+/g, "-") 155 + .replace(/^-|-$/g, ""); 156 + const root = new URL(`${prefix}-`, `file://${tmpdir()}/`); 157 + const path = await mkdtemp(fileURLToPath(root)); 158 + const base = pathToFileURL(path + sep); 159 + 160 + const inner = new NodeHfs(); 161 + const scoped = scopeHfs(inner, base); 162 + const resolve = (...segments: string[]) => new URL(`./${segments.join("/")}`, base); 163 + 164 + const ctx: FileContext = { 165 + importMeta: { 166 + url: base.toString(), 167 + filename: fileURLToPath(base), 168 + dirname: fileURLToPath(base), 169 + resolve: (p: string) => new URL(`./${p}`, base).toString(), 170 + }, 171 + symlink: (target: string): SymlinkMarker => ({ [SYMLINK]: true, target }), 172 + }; 173 + 174 + async function writeTree(tree: FileTree, dir: URL): Promise<void> { 175 + for (const [name, raw] of Object.entries(tree)) { 176 + const url = new URL(name, dir); 177 + 178 + // Nested directory object (not a plain value) 179 + if ( 180 + typeof raw !== "function" && 181 + !Buffer.isBuffer(raw) && 182 + !Array.isArray(raw) && 183 + isFileTree(raw) && 184 + !name.includes(".") 185 + ) { 186 + await inner.createDirectory(url); 187 + // Trailing slash so nested entries resolve relative to the dir 188 + await writeTree(raw, new URL(`${url}/`)); 189 + continue; 190 + } 191 + 192 + // Ensure parent directory exists 193 + const parent = new URL("./", url); 194 + await inner.createDirectory(parent); 195 + 196 + // Resolve functions 197 + const content = typeof raw === "function" ? raw(ctx) : raw; 198 + 199 + // Symlink 200 + if (isSymlinkMarker(content)) { 201 + await fsSymlink(content.target, url); 202 + continue; 203 + } 204 + 205 + // Buffer 206 + if (Buffer.isBuffer(content)) { 207 + await inner.write(url, content); 208 + continue; 209 + } 210 + 211 + // JSON auto-serialization for .json files with non-string content 212 + if (name.endsWith(".json") && typeof content !== "string") { 213 + await inner.write(url, JSON.stringify(content, null, 2)); 214 + continue; 215 + } 216 + 217 + // String content 218 + await inner.write(url, content as string); 219 + } 220 + } 221 + 222 + await writeTree(files, base); 223 + 224 + const cleanup = () => inner.deleteAll(path).then(() => undefined); 225 + onTestFinished(cleanup); 226 + 227 + return { 228 + root: base, 229 + resolve, 230 + cleanup, 231 + ...scoped, 232 + }; 233 + }
+2
src/commands/test-utils/index.ts
··· 1 + export { createFixture } from "./fixture.ts"; 2 + export { createMocks, type Mocks } from "./mock.ts";
+43
src/commands/test-utils/mock.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { createMocks } from "./mock.ts"; 3 + import { MockReadable, MockWritable } from "./stdio.ts"; 4 + 5 + describe("createMocks", () => { 6 + it("returns undefined streams when not requested", () => { 7 + const mocks = createMocks(); 8 + expect(mocks.input).toBeUndefined(); 9 + expect(mocks.output).toBeUndefined(); 10 + }); 11 + 12 + it("creates input stream with `true`", () => { 13 + const mocks = createMocks({ input: true }); 14 + expect(mocks.input).toBeInstanceOf(MockReadable); 15 + }); 16 + 17 + it("creates output stream with `true`", () => { 18 + const mocks = createMocks({ output: true }); 19 + expect(mocks.output).toBeInstanceOf(MockWritable); 20 + }); 21 + 22 + it("passes config to output stream", () => { 23 + const mocks = createMocks({ output: { columns: 120, rows: 40, isTTY: true } }); 24 + expect(mocks.output.columns).toBe(120); 25 + expect(mocks.output.rows).toBe(40); 26 + expect(mocks.output.isTTY).toBe(true); 27 + }); 28 + 29 + it("passes config to input stream", () => { 30 + const mocks = createMocks({ input: { isTTY: true } }); 31 + expect(mocks.input.isTTY).toBe(true); 32 + }); 33 + 34 + it("stubs env vars for duration of test", () => { 35 + createMocks({ env: { TEST_MOCK_VAR: "hello" } }); 36 + expect(process.env.TEST_MOCK_VAR).toBe("hello"); 37 + }); 38 + 39 + it("restores env vars after test finishes", async () => { 40 + // Previous test's onTestFinished should have cleaned up 41 + expect(process.env.TEST_MOCK_VAR).toBeUndefined(); 42 + }); 43 + });
+58
src/commands/test-utils/mock.ts
··· 1 + import { onTestFinished, vi } from "vitest"; 2 + import { MockReadable, MockWritable } from "./stdio.ts"; 3 + 4 + type InputConfig = true | ConstructorParameters<typeof MockReadable>[0]; 5 + type OutputConfig = true | ConstructorParameters<typeof MockWritable>[0]; 6 + 7 + export interface CreateMockOptions { 8 + /** Environment variables to set for the duration of the test. */ 9 + env?: Record<string, string | undefined>; 10 + /** Pass `true` for defaults, or a config object. Omit to skip. */ 11 + input?: InputConfig; 12 + /** Pass `true` for defaults, or a config object for columns/rows/isTTY. */ 13 + output?: OutputConfig; 14 + } 15 + 16 + export type Mocks<O extends CreateMockOptions = CreateMockOptions> = { 17 + input: O["input"] extends InputConfig ? MockReadable : undefined; 18 + output: O["output"] extends OutputConfig ? MockWritable : undefined; 19 + }; 20 + 21 + /** 22 + * Create a mock test environment with streams, env vars. 23 + * 24 + * Cleanup is automatic via `onTestFinished` — no `beforeAll`/`afterAll` needed. 25 + * 26 + * @example 27 + * ```ts 28 + * let mocks: Mocks; 29 + * beforeEach(() => { 30 + * mocks = createMocks({ env: { CI: 'true' }}); 31 + * }); 32 + * 33 + * it('works', () => { 34 + * doThing(mocks.input, mocks.output); 35 + * }); 36 + * ``` 37 + */ 38 + export function createMocks<O extends CreateMockOptions>(opts?: O): Mocks<O>; 39 + export function createMocks(opts: CreateMockOptions = {}): Mocks { 40 + const input = opts.input 41 + ? new MockReadable(typeof opts.input === "object" ? opts.input : undefined) 42 + : undefined; 43 + const output = opts.output 44 + ? new MockWritable(typeof opts.output === "object" ? opts.output : undefined) 45 + : undefined; 46 + if (opts.env) { 47 + for (const [key, value] of Object.entries(opts.env)) { 48 + vi.stubEnv(key, value); 49 + } 50 + } 51 + 52 + onTestFinished(() => { 53 + vi.unstubAllEnvs(); 54 + vi.restoreAllMocks(); 55 + }); 56 + 57 + return { input, output } as Mocks; 58 + }
+79
src/commands/test-utils/stdio.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { MockReadable, MockWritable } from "./stdio.ts"; 3 + 4 + describe("MockReadable", () => { 5 + it("defaults to non-TTY", () => { 6 + const r = new MockReadable(); 7 + expect(r.isTTY).toBe(false); 8 + expect(r.isRaw).toBe(false); 9 + }); 10 + 11 + it("respects isTTY config", () => { 12 + const r = new MockReadable({ isTTY: true }); 13 + expect(r.isTTY).toBe(true); 14 + }); 15 + 16 + it("setRawMode enables raw mode", () => { 17 + const r = new MockReadable(); 18 + r.setRawMode(); 19 + expect(r.isRaw).toBe(true); 20 + }); 21 + 22 + it("pushValue delivers data on read", () => 23 + new Promise<void>((resolve) => { 24 + const r = new MockReadable(); 25 + r.on("data", (chunk) => { 26 + expect(chunk.toString()).toBe("hello"); 27 + resolve(); 28 + }); 29 + r.pushValue("hello"); 30 + })); 31 + 32 + it("close ends the stream", async () => { 33 + const r = new MockReadable(); 34 + r.close(); 35 + 36 + const chunks: string[] = []; 37 + for await (const chunk of r) { 38 + chunks.push(chunk.toString()); 39 + } 40 + expect(chunks).toEqual([]); 41 + }); 42 + }); 43 + 44 + describe("MockWritable", () => { 45 + it("defaults to 80x20 non-TTY", () => { 46 + const w = new MockWritable(); 47 + expect(w.isTTY).toBe(false); 48 + expect(w.columns).toBe(80); 49 + expect(w.rows).toBe(20); 50 + }); 51 + 52 + it("accepts custom config", () => { 53 + const w = new MockWritable({ columns: 120, rows: 40, isTTY: true }); 54 + expect(w.isTTY).toBe(true); 55 + expect(w.columns).toBe(120); 56 + expect(w.rows).toBe(40); 57 + }); 58 + 59 + it("captures written chunks", () => { 60 + const w = new MockWritable(); 61 + w.write("hello"); 62 + w.write(" world"); 63 + expect(w.buffer).toEqual(["hello", " world"]); 64 + }); 65 + 66 + it("resize updates dimensions and emits event", () => { 67 + const w = new MockWritable({ columns: 80, rows: 20 }); 68 + let resized = false; 69 + w.on("resize", () => { 70 + resized = true; 71 + }); 72 + 73 + w.resize(120, 40); 74 + 75 + expect(w.columns).toBe(120); 76 + expect(w.rows).toBe(40); 77 + expect(resized).toBe(true); 78 + }); 79 + });
+68
src/commands/test-utils/stdio.ts
··· 1 + import { Readable, Writable, type ReadableOptions, type WritableOptions } from "node:stream"; 2 + 3 + export class MockReadable extends Readable { 4 + protected _buffer: unknown[] | null = []; 5 + public isTTY = false; 6 + public isRaw = false; 7 + public setRawMode() { 8 + this.isRaw = true; 9 + } 10 + 11 + constructor(config?: { isTTY?: boolean }, opts?: ReadableOptions) { 12 + super(opts); 13 + this.isTTY = config?.isTTY ?? false; 14 + } 15 + 16 + override _read() { 17 + if (this._buffer === null) { 18 + this.push(null); 19 + return; 20 + } 21 + 22 + for (const val of this._buffer) { 23 + this.push(val); 24 + } 25 + 26 + this._buffer = []; 27 + } 28 + 29 + pushValue(val: unknown): void { 30 + this._buffer?.push(val); 31 + } 32 + 33 + close(): void { 34 + this._buffer = null; 35 + } 36 + } 37 + 38 + export class MockWritable extends Writable { 39 + public buffer: string[] = []; 40 + public isTTY = false; 41 + public columns = 80; 42 + public rows = 20; 43 + 44 + constructor( 45 + config?: { columns?: number; rows?: number; isTTY?: boolean }, 46 + opts?: WritableOptions, 47 + ) { 48 + super(opts); 49 + this.isTTY = config?.isTTY ?? false; 50 + this.columns = config?.columns ?? 80; 51 + this.rows = config?.rows ?? 20; 52 + } 53 + 54 + public resize(columns: number, rows: number): void { 55 + this.columns = columns; 56 + this.rows = rows; 57 + this.emit("resize"); 58 + } 59 + 60 + override _write( 61 + chunk: any, 62 + _encoding: BufferEncoding, 63 + callback: (error?: Error | null | undefined) => void, 64 + ): void { 65 + this.buffer.push(chunk.toString()); 66 + callback(); 67 + } 68 + }
+11
src/commands/test-utils/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + exclude: ['dist/**', 'node_modules/**'], 6 + env: { 7 + FORCE_COLOR: '1', 8 + }, 9 + snapshotSerializers: ['vitest-ansi-serializer'], 10 + }, 11 + });
+16 -4
src/commands/test.ts
··· 1 + import { existsSync } from "node:fs"; 2 + import { fileURLToPath } from "node:url"; 1 3 import { x } from "tinyexec"; 2 4 import type { CommandContext } from "../context.ts"; 3 5 import { local } from "../utils.ts"; 4 6 7 + function resolveConfig(): string { 8 + // Built output (.mjs) or source (.ts) 9 + for (const ext of [".mjs", ".ts"]) { 10 + const url = new URL(`./test-utils/vitest.config${ext}`, import.meta.url); 11 + const path = fileURLToPath(url); 12 + if (existsSync(path)) return path; 13 + } 14 + throw new Error("Could not resolve vitest.config file"); 15 + } 16 + 5 17 export async function test(ctx: CommandContext) { 6 - const stdio = x(local("vitest"), ["run", ...ctx.args]); 18 + const stdio = x(local("vitest"), ["run", "--config", resolveConfig(), ...ctx.args]); 7 19 8 - for await (const line of stdio) { 9 - console.log(line); 10 - } 20 + for await (const line of stdio) { 21 + console.log(line); 22 + } 11 23 }
-41
src/test-utils.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 - import { existsSync } from "node:fs"; 3 - import { createFixture } from "./test-utils.ts"; 4 - 5 - describe("createFixture", () => { 6 - it("creates files on disk from inline tree", async () => { 7 - const fixture = await createFixture({ 8 - "hello.txt": "hello world", 9 - }); 10 - expect(await fixture.text("hello.txt")).toBe("hello world"); 11 - }); 12 - 13 - it("creates nested directories from slash-separated keys", async () => { 14 - const fixture = await createFixture({ 15 - "src/index.ts": "export const x = 1", 16 - "src/utils/helpers.ts": "export function help() {}", 17 - }); 18 - expect(await fixture.isFile("src/index.ts")).toBe(true); 19 - expect(await fixture.isFile("src/utils/helpers.ts")).toBe(true); 20 - }); 21 - 22 - it("resolve returns absolute path within fixture root", async () => { 23 - const fixture = await createFixture({ "a.txt": "" }); 24 - expect(fixture.resolve("a.txt").toString()).toContain(fixture.root.toString()); 25 - }); 26 - 27 - it("text reads the actual file", async () => { 28 - const fixture = await createFixture({ "a.txt": "Empty" }); 29 - expect(await fixture.text("a.txt")).toEqual("Empty"); 30 - await fixture.write("a.txt", "Hello world!"); 31 - expect(await fixture.text("a.txt")).toEqual("Hello world!"); 32 - }); 33 - 34 - it("cleanup removes the temp directory", async () => { 35 - const fixture = await createFixture({ "a.txt": "" }); 36 - const path = fixture.root; 37 - expect(fixture.isDirectory(fixture.root)).toBe(true); 38 - await fixture.cleanup(); 39 - expect(existsSync(path)).toBe(false); 40 - }); 41 - });
-233
src/test-utils.ts
··· 1 - import { mkdtemp, symlink as fsSymlink } from "node:fs/promises"; 2 - import { tmpdir } from "node:os"; 3 - import { sep } from "node:path"; 4 - import { fileURLToPath, pathToFileURL } from "node:url"; 5 - import { NodeHfs } from "@humanfs/node"; 6 - import type { HfsImpl } from "@humanfs/types"; 7 - import { expect, onTestFinished } from "vitest"; 8 - 9 - interface ScopedHfsImpl extends HfsImpl { 10 - text(file: string | URL): Promise<string | undefined>; 11 - json(file: string | URL): Promise<unknown | undefined>; 12 - } 13 - 14 - /** 15 - * A temporary fixture directory with a scoped `hfs` filesystem. 16 - * 17 - * Includes all `hfs` methods — paths are resolved relative to the fixture root. 18 - */ 19 - export interface Fixture extends ScopedHfsImpl { 20 - /** The fixture root as a `file://` URL. */ 21 - root: URL; 22 - /** Resolve a relative path within the fixture root. */ 23 - resolve: (...segments: string[]) => URL; 24 - /** Delete the fixture directory. Also runs automatically via `onTestFinished`. */ 25 - cleanup: () => Promise<void>; 26 - } 27 - 28 - /** Context passed to dynamic file content functions. */ 29 - export interface FileContext { 30 - /** 31 - * Metadata about the fixture root, analogous to `import.meta`. 32 - * 33 - * - `url` — the fixture root as a `file://` URL string 34 - * - `filename` — absolute filesystem path to the fixture root 35 - * - `dirname` — same as `filename` (root is a directory) 36 - * - `resolve(path)` — resolve a relative path against the fixture root 37 - */ 38 - importMeta: { 39 - url: string; 40 - filename: string; 41 - dirname: string; 42 - resolve: (path: string) => string; 43 - }; 44 - /** 45 - * Create a symbolic link to `target`. 46 - * 47 - * Returns a `SymlinkMarker` — the fixture will create the symlink on disk. 48 - * 49 - * @example 50 - * ```ts 51 - * { 'link.txt': ({ symlink }) => symlink('./target.txt') } 52 - * ``` 53 - */ 54 - symlink: (target: string) => SymlinkMarker; 55 - } 56 - 57 - const SYMLINK = Symbol("symlink"); 58 - 59 - /** Opaque marker returned by `ctx.symlink()`. */ 60 - export interface SymlinkMarker { 61 - [SYMLINK]: true; 62 - target: string; 63 - } 64 - 65 - /** 66 - * A value in the file tree. 67 - * 68 - * | Type | Example | 69 - * |------|---------| 70 - * | `string` | `'file content'` | 71 - * | `object` / `array` | `{ name: 'cool' }` — auto-serialized as JSON for `.json` keys | 72 - * | `Buffer` | `Buffer.from([0x89, 0x50])` | 73 - * | Nested directory | `{ dir: { 'file.txt': 'content' } }` | 74 - * | Function | `({ importMeta, symlink }) => symlink('./target')` | 75 - */ 76 - export type FileTreeValue = 77 - | string 78 - | Buffer 79 - | Record<string, unknown> 80 - | unknown[] 81 - | FileTree 82 - | ((ctx: FileContext) => string | Buffer | SymlinkMarker); 83 - 84 - /** A recursive tree of files and directories. */ 85 - export interface FileTree { 86 - [key: string]: FileTreeValue; 87 - } 88 - 89 - function isSymlinkMarker(value: unknown): value is SymlinkMarker { 90 - return typeof value === "object" && value !== null && SYMLINK in value; 91 - } 92 - 93 - function isFileTree(value: unknown): value is FileTree { 94 - return ( 95 - typeof value === "object" && 96 - value !== null && 97 - !Buffer.isBuffer(value) && 98 - !Array.isArray(value) && 99 - !isSymlinkMarker(value) 100 - ); 101 - } 102 - 103 - function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl { 104 - const r = (p: string | URL) => new URL(`./${p}`, base); 105 - const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const; 106 - 107 - return { 108 - text: (p: string | URL) => inner.text(r(p)), 109 - json: (p: string | URL) => inner.json(r(p)), 110 - bytes: (p) => inner.bytes(r(p)), 111 - write: (p, c) => inner.write(r(p), c), 112 - append: (p, c) => inner.append(r(p), c), 113 - isFile: (p) => inner.isFile(r(p)), 114 - isDirectory: (p) => inner.isDirectory(r(p)), 115 - createDirectory: (p) => inner.createDirectory(r(p)), 116 - delete: (p) => inner.delete(r(p)), 117 - deleteAll: (p) => inner.deleteAll(r(p)), 118 - list: (p) => inner.list(r(p)), 119 - size: (p) => inner.size(r(p)), 120 - lastModified: (p) => inner.lastModified(r(p)), 121 - copy: (s, d) => inner.copy(...r2(s, d)), 122 - copyAll: (s, d) => inner.copyAll(...r2(s, d)), 123 - move: (s, d) => inner.move(...r2(s, d)), 124 - moveAll: (s, d) => inner.moveAll(...r2(s, d)), 125 - }; 126 - } 127 - 128 - /** 129 - * Create a temporary fixture directory from an inline file tree. 130 - * 131 - * Returns a {@link Fixture} with all `hfs` methods scoped to the fixture root. 132 - * 133 - * @example 134 - * ```ts 135 - * const fixture = await createFixture({ 136 - * 'hello.txt': 'hello world', 137 - * 'package.json': { name: 'test', version: '1.0.0' }, 138 - * 'icon.png': Buffer.from([0x89, 0x50]), 139 - * src: { 140 - * 'index.ts': 'export default 1', 141 - * }, 142 - * 'link.txt': ({ symlink }) => symlink('./hello.txt'), 143 - * 'info.txt': ({ importMeta }) => `Root: ${importMeta.url}`, 144 - * }) 145 - * 146 - * const text = await fixture.text('hello.txt') 147 - * const json = await fixture.json('package.json') 148 - * ``` 149 - */ 150 - export async function createFixture(files: FileTree): Promise<Fixture> { 151 - const raw = expect.getState().currentTestName ?? "bsh"; 152 - const prefix = raw 153 - .toLowerCase() 154 - .replace(/[^a-z0-9]+/g, "-") 155 - .replace(/^-|-$/g, ""); 156 - const root = new URL(`${prefix}-`, `file://${tmpdir()}/`); 157 - const path = await mkdtemp(fileURLToPath(root)); 158 - const base = pathToFileURL(path + sep); 159 - 160 - const inner = new NodeHfs(); 161 - const scoped = scopeHfs(inner, base); 162 - const resolve = (...segments: string[]) => new URL(`./${segments.join("/")}`, base); 163 - 164 - const ctx: FileContext = { 165 - importMeta: { 166 - url: base.toString(), 167 - filename: fileURLToPath(base), 168 - dirname: fileURLToPath(base), 169 - resolve: (p: string) => new URL(`./${p}`, base).toString(), 170 - }, 171 - symlink: (target: string): SymlinkMarker => ({ [SYMLINK]: true, target }), 172 - }; 173 - 174 - async function writeTree(tree: FileTree, dir: URL): Promise<void> { 175 - for (const [name, raw] of Object.entries(tree)) { 176 - const url = new URL(name, dir); 177 - 178 - // Nested directory object (not a plain value) 179 - if ( 180 - typeof raw !== "function" && 181 - !Buffer.isBuffer(raw) && 182 - !Array.isArray(raw) && 183 - isFileTree(raw) && 184 - !name.includes(".") 185 - ) { 186 - await inner.createDirectory(url); 187 - // Trailing slash so nested entries resolve relative to the dir 188 - await writeTree(raw, new URL(`${url}/`)); 189 - continue; 190 - } 191 - 192 - // Ensure parent directory exists 193 - const parent = new URL("./", url); 194 - await inner.createDirectory(parent); 195 - 196 - // Resolve functions 197 - const content = typeof raw === "function" ? raw(ctx) : raw; 198 - 199 - // Symlink 200 - if (isSymlinkMarker(content)) { 201 - await fsSymlink(content.target, url); 202 - continue; 203 - } 204 - 205 - // Buffer 206 - if (Buffer.isBuffer(content)) { 207 - await inner.write(url, content); 208 - continue; 209 - } 210 - 211 - // JSON auto-serialization for .json files with non-string content 212 - if (name.endsWith(".json") && typeof content !== "string") { 213 - await inner.write(url, JSON.stringify(content, null, 2)); 214 - continue; 215 - } 216 - 217 - // String content 218 - await inner.write(url, content as string); 219 - } 220 - } 221 - 222 - await writeTree(files, base); 223 - 224 - const cleanup = () => inner.deleteAll(path).then(() => undefined); 225 - onTestFinished(cleanup); 226 - 227 - return { 228 - root: base, 229 - resolve, 230 - cleanup, 231 - ...scoped, 232 - }; 233 - }