Color mode

Testing

Riebeckite tests run on the Node.js built-in test runner (node:test) with tsx for TypeScript, so no separate test framework is required. Output that is expensive to assert by hand is recorded as committed golden files, which keeps changes to that output visible in review.

End-to-end coverage of a real site lives separately in tests/external-site and runs with pnpm test:e2e:external; this document covers package unit tests. The reusable engine that drives it lives in @riebeckite/test/e2e, while the repository-specific fixture, package list, and assertions stay in tests/external-site.

Third-party plugin authoring and packaging are exercised from an external package's perspective in tests/plugin-dx (its own pnpm workspace):

sh
pnpm test:plugin-dx            # unit tests for plugins that use only public APIs
pnpm test:plugin-dx:external   # pack tarballs, install into an isolated site, verify

test:plugin-dx:external packs @riebeckite/core and fixture plugins, installs them into a site outside this monorepo, and confirms the plugin works through published packages only. Use it as a regression check for a plugin you distribute.

Running tests

Command What it does
pnpm test Run every package that defines a test script (pnpm -r test)
pnpm --filter @riebeckite/plugin-toc test Run one package's tests
pnpm test:update Rewrite every golden file with the current output
pnpm test:e2e:external Run the external-site integration suite
pnpm test:plugin-dx Run the external-package plugin fixtures
pnpm test:plugin-dx:external Pack tarballs, install into an isolated site, and verify plugins
pnpm test:registry Run the scaffold install contracts against npm-published artifacts

The scaffold install contracts validate the generated site against locally packed workspace artifacts by default, so a branch that adds a new package can be verified before it is published. pnpm test:registry switches the same contracts to install from npm and assert the published artifacts resolve; run it after a release.

For a single package you can also update only its golden files by setting UPDATE_GOLDEN=1 before its test script. In PowerShell:

powershell
$env:UPDATE_GOLDEN=1; pnpm --filter @riebeckite/plugin-toc test

Where tests live

Each package keeps its tests in test/*.test.ts and declares its own test script (node --import tsx --test "test/*.test.ts"). Test directories and test files are excluded from type checking, from builds, and from published files, so they never ship or affect consumers.

Shared test utilities live in the workspace package @riebeckite/test. Add it to a package's devDependencies and import from @riebeckite/test.

Writing a test

Import the public surface of the package under test and assert on the output. Most plugin logic is pure and can be tested without a full build pipeline.

ts
import assert from "node:assert/strict";
import { test } from "node:test";
 
import { renderBreadcrumbNav } from "../src/render.ts";
 
test("renders nothing for a single root item", () => {
  assert.equal(renderBreadcrumbNav([]), "");
});

Content-facing behavior is usually exercised through an in-memory ContentSource and resolveConfig, then ContentManager:

ts
const source = {
  async scan() {
    return [{ path: "notes/index.md" }];
  },
  async read(entry) {
    return `# ${entry.path}`;
  },
};
const config = resolveConfig({ content: { directory: "." } });
const manager = new ContentManager(source, [], { config });
const manifest = await manager.getManifest();

Testing a plugin

A plugin can be tested at four levels. You do not need all of them; start from the smallest level that proves the behavior you changed.

text
Level 1  Pure logic               A normal test runner is enough.
Level 2  Markdown / HTML          Pass the plugin to a Pipeline and assert the transformed output.
Level 3  Content / lifecycle      Use ContentManager with an in-memory ContentSource.
Level 4  Public package boundary  Pack the tarball and install it into an isolated site.

Level 1: Pure logic

Option resolution, string transforms, and AST helpers that do not depend on Riebeckite run under any runner such as node:test. No @riebeckite/test or ContentManager is needed at this level.

Level 2: Markdown / HTML transformation

Pass the plugin to the public Pipeline:

ts
import { Pipeline } from "@riebeckite/core";
import { tipPlugin } from "../src/index.ts";
 
const pipeline = new Pipeline(new Map(), new Map(), undefined, {
  plugins: [tipPlugin()],
});
 
const { html } = await pipeline.execute(":::tip\nSave often.\n:::");
assert.match(html, /<aside class="rr-tip">/);

The first argument is the content index and the second is the permalink map; empty Maps are enough for a single document. The third argument is only needed when the plugin resolves embeds or other content. This is the level most plugin tests need.

Level 3: Content / lifecycle

Use ContentManager with an in-memory ContentSource (the same shape shown above) to test content loading, hooks, manifests, body slots, or Page Types. getProcessedContent() returns the result of the pipeline plus content hooks; getManifest() returns the manifest including resolved plugins.

Level 4: Public package boundary

For a package you distribute, pnpm pack it and install the tarball into an isolated site outside the monorepo. This catches missing public exports, accidental @riebeckite/core/src/** imports, undeclared dependencies, and missing declarations. tests/plugin-dx in this repository is the working example and runs with pnpm test:plugin-dx and pnpm test:plugin-dx:external.

The role of @riebeckite/test

@riebeckite/test provides shared test helpers such as the golden-file assertions assertGolden and assertGoldenJson. It is optional: most Level 1-3 tests can be written with just node:test and the public core APIs (Pipeline / ContentManager). @riebeckite/test/e2e is the repository-oriented engine that builds an external site; third-party plugins do not normally need it.

Golden files

@riebeckite/test provides two helpers for large or structured output:

  • assertGolden(actual, goldenUrl) for text (HTML, Markdown, serialized JSON).
  • assertGoldenJson(value, goldenUrl) for objects, which formats the value with JSON.stringify(value, null, 2) before comparing.

Pass the expected file as a URL built from import.meta.url and keep the recorded file under test/__golden__/:

ts
import { assertGolden } from "@riebeckite/test";
 
test("renders the table of contents", () => {
  assertGolden(renderToc(entries), new URL("./__golden__/toc.html", import.meta.url));
});

On default normalization the helper converts CRLF/CR to LF and collapses trailing newlines to one, so golden files stay stable across platforms and editors. Pass { normalize: false } when byte-exact output matters.

A missing or mismatched golden file fails the test. When the new output is correct, regenerate it with pnpm test:update and review the resulting diff. Golden files are committed on purpose: a change to recorded output should be readable in a pull request.

Node's built-in snapshot assertion (--test-update-snapshots) is intentionally not used because it requires Node 22.3+, while Riebeckite supports Node 20.19+.

Adding tests to a package

  1. Add tsx to the package's devDependencies and a test script: node --import tsx --test "test/*.test.ts".
  2. If the tests use the golden helpers, add @riebeckite/test to devDependencies and import them from @riebeckite/test.
  3. If the package is a plugin, add its directory name to the hasTests list in scripts/package_metadata.mjs.
  4. Run pnpm install when dependencies change, then pnpm check:packages to confirm the expected metadata matches.

scripts/check_packages.mjs compares each package's metadata against scripts/package_metadata.mjs, so a missing test script or an unlisted plugin is reported as a failure.

What to test

Prefer deterministic, pure behavior:

  • Option resolution, validation, and default handling.
  • Content processing such as permalink resolution, TOC construction, and metadata extraction.
  • Rendered HTML and generated JSON, captured as golden files.
  • Boundary cases: empty input, missing frontmatter, duplicate slugs, and malformed attributes.

Avoid tests that depend on the network, the wall clock, or absolute paths. When time or randomness matters, inject it rather than asserting on real values.

History

1 changesCollapseExpand
1 + # Testing
2 +
3 + Riebeckite tests run on the Node.js built-in test runner (`node:test`) with
4 + `tsx` for TypeScript, so no separate test framework is required. Output that is
5 + expensive to assert by hand is recorded as committed golden files, which keeps
6 + changes to that output visible in review.
7 +
8 + End-to-end coverage of a real site lives separately in `tests/external-site` and
9 + runs with `pnpm test:e2e:external`; this document covers package unit tests. The
10 + reusable engine that drives it lives in `@riebeckite/test/e2e`, while the
11 + repository-specific fixture, package list, and assertions stay in
12 + `tests/external-site`.
13 +
14 + Third-party plugin authoring and packaging are exercised from an external
15 + package's perspective in `tests/plugin-dx` (its own pnpm workspace):
16 +
17 + ```sh
18 + pnpm test:plugin-dx # unit tests for plugins that use only public APIs
19 + pnpm test:plugin-dx:external # pack tarballs, install into an isolated site, verify
20 + ```
21 +
22 + `test:plugin-dx:external` packs `@riebeckite/core` and fixture plugins, installs
23 + them into a site outside this monorepo, and confirms the plugin works through
24 + published packages only. Use it as a regression check for a plugin you
25 + distribute.
26 +
27 + ## Running tests
28 +
29 + |Command|What it does|
30 + |---|---|
31 + |`pnpm test`|Run every package that defines a `test` script (`pnpm -r test`)|
32 + |`pnpm --filter @riebeckite/plugin-toc test`|Run one package's tests|
33 + |`pnpm test:update`|Rewrite every golden file with the current output|
34 + |`pnpm test:e2e:external`|Run the external-site integration suite|
35 + |`pnpm test:plugin-dx`|Run the external-package plugin fixtures|
36 + |`pnpm test:plugin-dx:external`|Pack tarballs, install into an isolated site, and verify plugins|
37 + |`pnpm test:registry`|Run the scaffold install contracts against npm-published artifacts|
38 +
39 + The scaffold install contracts validate the generated site against **locally
40 + packed workspace artifacts** by default, so a branch that adds a new package can
41 + be verified before it is published. `pnpm test:registry` switches the same
42 + contracts to install from npm and assert the published artifacts resolve; run it
43 + after a release.
44 +
45 + For a single package you can also update only its golden files by setting
46 + `UPDATE_GOLDEN=1` before its test script. In PowerShell:
47 +
48 + ```powershell
49 + $env:UPDATE_GOLDEN=1; pnpm --filter @riebeckite/plugin-toc test
50 + ```
51 +
52 + ## Where tests live
53 +
54 + Each package keeps its tests in `test/*.test.ts` and declares its own `test`
55 + script (`node --import tsx --test "test/*.test.ts"`). Test directories and test
56 + files are excluded from type checking, from builds, and from published `files`,
57 + so they never ship or affect consumers.
58 +
59 + Shared test utilities live in the workspace package `@riebeckite/test`. Add it
60 + to a package's `devDependencies` and import from `@riebeckite/test`.
61 +
62 + ## Writing a test
63 +
64 + Import the public surface of the package under test and assert on the output.
65 + Most plugin logic is pure and can be tested without a full build pipeline.
66 +
67 + ```ts
68 + import assert from "node:assert/strict";
69 + import { test } from "node:test";
70 +
71 + import { renderBreadcrumbNav } from "../src/render.ts";
72 +
73 + test("renders nothing for a single root item", () => {
74 + assert.equal(renderBreadcrumbNav([]), "");
75 + });
76 + ```
77 +
78 + Content-facing behavior is usually exercised through an in-memory
79 + `ContentSource` and `resolveConfig`, then `ContentManager`:
80 +
81 + ```ts
82 + const source = {
83 + async scan() {
84 + return [{ path: "notes/index.md" }];
85 + },
86 + async read(entry) {
87 + return `# ${entry.path}`;
88 + },
89 + };
90 + const config = resolveConfig({ content: { directory: "." } });
91 + const manager = new ContentManager(source, [], { config });
92 + const manifest = await manager.getManifest();
93 + ```
94 +
95 + ## Testing a plugin
96 +
97 + A plugin can be tested at four levels. You do not need all of them; start from the smallest level that proves the behavior you changed.
98 +
99 + ```text
100 + Level 1 Pure logic A normal test runner is enough.
101 + Level 2 Markdown / HTML Pass the plugin to a Pipeline and assert the transformed output.
102 + Level 3 Content / lifecycle Use ContentManager with an in-memory ContentSource.
103 + Level 4 Public package boundary Pack the tarball and install it into an isolated site.
104 + ```
105 +
106 + ### Level 1: Pure logic
107 +
108 + Option resolution, string transforms, and AST helpers that do not depend on Riebeckite run under any runner such as `node:test`. No `@riebeckite/test` or `ContentManager` is needed at this level.
109 +
110 + ### Level 2: Markdown / HTML transformation
111 +
112 + Pass the plugin to the public `Pipeline`:
113 +
114 + ```ts
115 + import { Pipeline } from "@riebeckite/core";
116 + import { tipPlugin } from "../src/index.ts";
117 +
118 + const pipeline = new Pipeline(new Map(), new Map(), undefined, {
119 + plugins: [tipPlugin()],
120 + });
121 +
122 + const { html } = await pipeline.execute(":::tip\nSave often.\n:::");
123 + assert.match(html, /<aside class="rr-tip">/);
124 + ```
125 +
126 + The first argument is the content index and the second is the permalink map; empty `Map`s are enough for a single document. The third argument is only needed when the plugin resolves embeds or other content. This is the level most plugin tests need.
127 +
128 + ### Level 3: Content / lifecycle
129 +
130 + Use `ContentManager` with an in-memory `ContentSource` (the same shape shown above) to test content loading, hooks, manifests, body slots, or Page Types. `getProcessedContent()` returns the result of the pipeline plus content hooks; `getManifest()` returns the manifest including resolved plugins.
131 +
132 + ### Level 4: Public package boundary
133 +
134 + For a package you distribute, `pnpm pack` it and install the tarball into an isolated site outside the monorepo. This catches missing public exports, accidental `@riebeckite/core/src/**` imports, undeclared dependencies, and missing declarations. `tests/plugin-dx` in this repository is the working example and runs with `pnpm test:plugin-dx` and `pnpm test:plugin-dx:external`.
135 +
136 + ### The role of `@riebeckite/test`
137 +
138 + `@riebeckite/test` provides shared test helpers such as the golden-file assertions `assertGolden` and `assertGoldenJson`. It is **optional**: most Level 1-3 tests can be written with just `node:test` and the public core APIs (`Pipeline` / `ContentManager`). `@riebeckite/test/e2e` is the repository-oriented engine that builds an external site; third-party plugins do not normally need it.
139 +
140 + ## Golden files
141 +
142 + `@riebeckite/test` provides two helpers for large or structured output:
143 +
144 + - `assertGolden(actual, goldenUrl)` for text (HTML, Markdown, serialized JSON).
145 + - `assertGoldenJson(value, goldenUrl)` for objects, which formats the value
146 + with `JSON.stringify(value, null, 2)` before comparing.
147 +
148 + Pass the expected file as a `URL` built from `import.meta.url` and keep the
149 + recorded file under `test/__golden__/`:
150 +
151 + ```ts
152 + import { assertGolden } from "@riebeckite/test";
153 +
154 + test("renders the table of contents", () => {
155 + assertGolden(renderToc(entries), new URL("./__golden__/toc.html", import.meta.url));
156 + });
157 + ```
158 +
159 + On default normalization the helper converts CRLF/CR to LF and collapses
160 + trailing newlines to one, so golden files stay stable across platforms and
161 + editors. Pass `{ normalize: false }` when byte-exact output matters.
162 +
163 + A missing or mismatched golden file fails the test. When the new output is
164 + correct, regenerate it with `pnpm test:update` and review the resulting diff.
165 + Golden files are committed on purpose: a change to recorded output should be
166 + readable in a pull request.
167 +
168 + Node's built-in snapshot assertion (`--test-update-snapshots`) is intentionally
169 + not used because it requires Node 22.3+, while Riebeckite supports Node 20.19+.
170 +
171 + ## Adding tests to a package
172 +
173 + 1. Add `tsx` to the package's `devDependencies` and a `test` script:
174 + `node --import tsx --test "test/*.test.ts"`.
175 + 2. If the tests use the golden helpers, add `@riebeckite/test` to
176 + `devDependencies` and import them from `@riebeckite/test`.
177 + 3. If the package is a plugin, add its directory name to the `hasTests` list in
178 + `scripts/package_metadata.mjs`.
179 + 4. Run `pnpm install` when dependencies change, then `pnpm check:packages` to
180 + confirm the expected metadata matches.
181 +
182 + `scripts/check_packages.mjs` compares each package's metadata against
183 + `scripts/package_metadata.mjs`, so a missing `test` script or an unlisted plugin
184 + is reported as a failure.
185 +
186 + ## What to test
187 +
188 + Prefer deterministic, pure behavior:
189 +
190 + - Option resolution, validation, and default handling.
191 + - Content processing such as permalink resolution, TOC construction, and
192 + metadata extraction.
193 + - Rendered HTML and generated JSON, captured as golden files.
194 + - Boundary cases: empty input, missing frontmatter, duplicate slugs, and
195 + malformed attributes.
196 +
197 + Avoid tests that depend on the network, the wall clock, or absolute paths.
198 + When time or randomness matters, inject it rather than asserting on real values.
199 +