Skip to content

Commit ffba54e

Browse files
authored
feat(test-utils): improve test config (#19)
1 parent 22ea4b9 commit ffba54e

File tree

16 files changed

+591
-295
lines changed

16 files changed

+591
-295
lines changed

.changeset/ripe-foxes-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bomb.sh/tools": patch
3+
---
4+
5+
Ignores colocated `*.test.ts` files in build

.changeset/silent-camels-train.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bomb.sh/tools": patch
3+
---
4+
5+
Adds automatic `vitest` config with `vitest-ansi-serializer`

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
},
4141
"./skills/*": "./skills/*",
4242
"./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"
4545
},
4646
"./*": "./dist/*",
4747
"./package.json": "./package.json",
@@ -70,7 +70,8 @@
7070
"publint": "^0.3.18",
7171
"tinyexec": "^1.0.1",
7272
"tsdown": "^0.21.0-beta.2",
73-
"vitest": "^4.0.18"
73+
"vitest": "^4.0.18",
74+
"vitest-ansi-serializer": "^0.2.1"
7475
},
7576
"devDependencies": {
7677
"@changesets/cli": "^2.28.1",

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/build.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@ import { build as tsdown } from "tsdown";
33
import type { CommandContext } from "../context.ts";
44

55
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+
});
99

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"];
1111

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+
});
2222
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
});

src/commands/test-utils/fixture.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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+
}

src/commands/test-utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { createFixture } from "./fixture.ts";
2+
export { createMocks, type Mocks } from "./mock.ts";

0 commit comments

Comments
 (0)