Skip to content

Commit 9071ca0

Browse files
authored
Merge pull request #747 from 2chanhaeng/fixture
Document `@fedify/fixture` boundary and add a usage checker
2 parents b4801a9 + 63dd349 commit 9071ca0

5 files changed

Lines changed: 454 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ A detailed step-by-step guide is available across three skills:
179179
3. Follow the pattern from existing database adapter packages
180180
4. Implement both KV store and message queue interfaces as needed
181181

182+
### Writing tests with `@fedify/fixture`
183+
184+
See *CONTRIBUTING.md* “Writing tests with `@fedify/fixture`” section and
185+
*packages/fixture/README.md* for detailed instructions on using the fixture
186+
package for runtime-agnostic testing.
187+
182188
### Adding a new package
183189

184190
See *CONTRIBUTING.md* “Adding a new package” section for the complete checklist

CONTRIBUTING.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,38 @@ A patch set should include the following:
177177
Feature pull requests should target the *main* branch for non-breaking changes,
178178
or the *next* branch for breaking changes.
179179

180+
### Writing tests with `@fedify/fixture`
181+
182+
The monorepo-private [`@fedify/fixture`] package provides the shared test
183+
infrastructure used across every workspace package. Reach for it whenever you
184+
add or modify a unit test:
185+
186+
- `test()`: A drop-in `Deno.test()`-compatible wrapper that runs the same
187+
test on Deno, Node.js, Bun, and the Cloudflare Workers harness.
188+
- `testDefinitions`: The array of every registered `test()`, consumed by
189+
the Workers test runner.
190+
- `mockDocumentLoader()`: A document loader that resolves
191+
ActivityPub/JSON-LD URLs from on-disk fixtures under
192+
*packages/fixture/src/fixtures/* instead of issuing real HTTP requests.
193+
- `TestSpanExporter`/`createTestTracerProvider()`: Helpers for asserting
194+
on OpenTelemetry spans and events recorded by the code under test.
195+
196+
See *[packages/fixture/README.md]* for the full API, fixture layout, and
197+
runtime-specific notes.
198+
199+
> [!CAUTION]
200+
>
201+
> `@fedify/fixture` is a private workspace package and is **not** published
202+
> to npm or JSR. Importing it from any file that ships to end users will
203+
> break consumers as soon as they install the package from a registry.
204+
>
205+
> Restrict every import of `@fedify/fixture` to files matching
206+
> `**/*.test.ts`. Keeping the boundary at the filename level makes it
207+
> trivial to audit. You can check this with `mise run check:fixture-usage`.
208+
209+
[`@fedify/fixture`]: packages/fixture/
210+
[packages/fixture/README.md]: packages/fixture/README.md
211+
180212
### Adding a new package
181213

182214
When adding a new package to the monorepo, the following files must be updated:

mise.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ depends = [
4747
"check:md",
4848
"check-versions",
4949
"check:manifest:workspace-protocol",
50+
"check:fixture-usage",
5051
]
5152

5253
[tasks."check:fmt"]
@@ -76,6 +77,10 @@ else
7677
fi
7778
'''
7879

80+
[tasks."check:fixture-usage"]
81+
description = "Ensure @fedify/fixture is only used in **/*.test.ts files"
82+
run = "deno run --allow-read scripts/check_fixture_usage.ts"
83+
7984
[tasks."check:manifest:workspace-protocol"]
8085
description = "Check for invalid workspace: specifiers without version (*, ^, ~)"
8186
run = '''

packages/fixture/README.md

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
<!-- deno-fmt-ignore-file -->
2+
3+
@fedify/fixture: Cross-runtime test helpers and ActivityPub fixtures
4+
====================================================================
5+
6+
This package provides the shared test infrastructure used by every package in
7+
the [Fedify] monorepo. It bundles three things that are needed by virtually
8+
every test file:
9+
10+
1. A `test()` function that runs the same test code on Deno, Node.js, and
11+
Bun (and forwards the registrations to a Cloudflare Workers harness).
12+
2. A `mockDocumentLoader()` that resolves ActivityPub/JSON-LD documents from
13+
on-disk JSON fixtures instead of making real HTTP requests.
14+
3. A `TestSpanExporter` for asserting on OpenTelemetry spans recorded by the
15+
code under test.
16+
17+
This package is private to the monorepo (`"private": true` in *package.json*,
18+
`"publish": false` in *deno.json*). It is not published to npm or JSR and is
19+
intended only as a `workspace:` dependency of other packages in this
20+
repository.
21+
22+
[Fedify]: https://fedify.dev/
23+
24+
25+
Installation
26+
------------
27+
28+
You do not install `@fedify/fixture` from a registry. Add it as a workspace
29+
dependency to the package that needs it:
30+
31+
~~~~ jsonc
32+
// packages/<your-package>/package.json
33+
{
34+
"devDependencies": {
35+
"@fedify/fixture": "workspace:^"
36+
}
37+
}
38+
~~~~
39+
40+
For Deno, the `imports` entry resolves to the in-tree source through the
41+
workspace at the repository root, so you don't need to add it to *deno.json*.
42+
For Node.js and Bun, pnpm links the local package by virtue of the `workspace:`
43+
specifier; remember to run `mise run install` (or `pnpm install`) at the
44+
repository root after the edit.
45+
46+
47+
Usage
48+
-----
49+
50+
### `test()` — cross-runtime test registration
51+
52+
`test()` accepts the same call signatures as [`Deno.test()`] and dispatches to
53+
the appropriate runtime test API:
54+
55+
- On Deno, it forwards to `Deno.test()` directly.
56+
- On Bun, it forwards to `Bun.jest(...).test` and translates
57+
`Deno.TestContext` so that nested `t.step()` calls keep working.
58+
- On Node.js (and on `node --test` in `dist-tests/`), it forwards to
59+
[`node:test`] and adapts the context the same way.
60+
- In any environment the test definition is also pushed to the exported
61+
`testDefinitions` array so that the Cloudflare Workers test harness in
62+
*packages/fedify/src/cfworkers/* can iterate over them.
63+
64+
Pick whichever signature matches the test you are writing:
65+
66+
~~~~ typescript
67+
import { test } from "@fedify/fixture";
68+
import { equal } from "node:assert/strict";
69+
70+
// (1) Object form
71+
test({
72+
name: "addition is commutative",
73+
fn() {
74+
equal(1 + 2, 2 + 1);
75+
},
76+
});
77+
78+
// (2) Name + function
79+
test("subtraction works", () => {
80+
equal(5 - 3, 2);
81+
});
82+
83+
// (3) Name + options + function
84+
test("ignored on this runtime", { ignore: true }, () => {
85+
// never runs
86+
});
87+
88+
// Nested steps via t.step() work on every runtime
89+
test("nested steps", async (t) => {
90+
await t.step("step 1", () => {
91+
equal(1, 1);
92+
});
93+
await t.step("step 2", () => {
94+
equal(2, 2);
95+
});
96+
});
97+
~~~~
98+
99+
#### Logging behavior
100+
101+
On Deno, `test()` configures [LogTape] before every test and resets it
102+
afterwards. By default log records are captured in memory and only flushed to
103+
the console if the test throws — this keeps successful runs quiet. Set the
104+
environment variable `LOG=always` to stream every log record to stdout
105+
regardless of test outcome, which is useful when you are debugging a flaky test:
106+
107+
~~~~ bash
108+
LOG=always mise run test:deno
109+
~~~~
110+
111+
[`Deno.test()`]: https://docs.deno.com/api/deno/~/Deno.test
112+
[`node:test`]: https://nodejs.org/api/test.html
113+
[LogTape]: https://logtape.org/
114+
115+
### `testDefinitions` — registered test list
116+
117+
Every call to `test()` appends to this array. The Cloudflare Workers test
118+
harness (and any custom runner you build) can read it to enumerate tests
119+
without depending on a specific runtime test API:
120+
121+
~~~~ typescript
122+
import { testDefinitions } from "@fedify/fixture";
123+
124+
for (const def of testDefinitions) {
125+
console.log(def.name);
126+
}
127+
~~~~
128+
129+
The array contains plain `Deno.TestDefinition` objects. In the Fedify package
130+
it is re-exported from *src/testing/mod.ts* so that the Workers entry point in
131+
*src/cfworkers/server.ts* can drive the suite.
132+
133+
### `mockDocumentLoader()` — fixture-backed JSON-LD loader
134+
135+
`mockDocumentLoader()` is a drop-in replacement for the document loader
136+
parameter accepted by Fedify's signature, vocabulary, and lookup APIs. It
137+
never opens a socket; instead it imports a JSON file from the
138+
`src/fixtures/<host>/<pathname>.json` tree shipped with this package.
139+
140+
For example, `mockDocumentLoader("https://example.com/object")` resolves
141+
[`src/fixtures/example.com/object.json`](src/fixtures/example.com/object.json),
142+
returning it as a `RemoteDocument` with `documentUrl` set to the original URL
143+
and `contextUrl` set to `null`.
144+
145+
~~~~ typescript
146+
import { mockDocumentLoader, test } from "@fedify/fixture";
147+
import { lookupObject } from "@fedify/vocab";
148+
import { ok } from "node:assert/strict";
149+
150+
test("lookupObject() resolves a fixture", async () => {
151+
const object = await lookupObject("https://example.com/object", {
152+
documentLoader: mockDocumentLoader,
153+
contextLoader: mockDocumentLoader,
154+
});
155+
ok(object != null);
156+
});
157+
~~~~
158+
159+
#### Adding a new fixture
160+
161+
1. Create the JSON file under
162+
[`src/fixtures/<host>/<path>.json`](src/fixtures/). The path must mirror
163+
the URL exactly: e.g. `https://w3id.org/security/v1` becomes
164+
*src/fixtures/w3id.org/security/v1.json*.
165+
2. Run `pnpm --filter @fedify/fixture build` once so that the fixture is
166+
copied into *dist/fixtures/* — Node.js and Bun consumers import the file
167+
through the `./fixtures/*` subpath export, which points at the *dist/*
168+
copy. (The `pretest` and `prepack` scripts do this automatically.)
169+
3. Reference the URL from your test through `mockDocumentLoader`.
170+
171+
The `./fixtures/*` subpath export is also useful when a test needs to read
172+
the raw JSON without going through the loader:
173+
174+
~~~~ typescript
175+
import object from "@fedify/fixture/fixtures/example.com/object.json"
176+
with { type: "json" };
177+
~~~~
178+
179+
#### Cloudflare Workers
180+
181+
Workers cannot import JSON from the filesystem at runtime. When
182+
`mockDocumentLoader()` detects `navigator.userAgent === "Cloudflare-Workers"`
183+
it instead `fetch()`es the URL with `.test` appended to the hostname (e.g.
184+
`https://example.com.test/object`); the Workers test harness in
185+
*packages/fedify/src/cfworkers/* serves the fixture tree from that
186+
pseudo-domain. No changes are needed in test code.
187+
188+
### `TestSpanExporter` & `createTestTracerProvider()` — OpenTelemetry assertions
189+
190+
Use these when you want to assert that the code under test recorded specific
191+
OpenTelemetry spans or events. `createTestTracerProvider()` returns a
192+
`[BasicTracerProvider, TestSpanExporter]` tuple wired up with a
193+
`SimpleSpanProcessor`; pass the provider to whatever API accepts a
194+
`tracerProvider` and read assertions off the exporter:
195+
196+
~~~~ typescript
197+
import {
198+
createTestTracerProvider,
199+
mockDocumentLoader,
200+
test
201+
} from "@fedify/fixture";
202+
import { lookupObject } from "@fedify/vocab";
203+
import { deepStrictEqual } from "node:assert/strict";
204+
205+
test("lookupObject() records a span", async () => {
206+
const [tracerProvider, exporter] = createTestTracerProvider();
207+
208+
await lookupObject("https://example.com/object", {
209+
documentLoader: mockDocumentLoader,
210+
contextLoader: mockDocumentLoader,
211+
tracerProvider,
212+
});
213+
214+
const spans = exporter.getSpans("activitypub.lookup_object");
215+
deepStrictEqual(spans.length, 1);
216+
deepStrictEqual(
217+
spans[0].attributes["activitypub.object.id"],
218+
"https://example.com/object",
219+
);
220+
221+
const events = exporter.getEvents(
222+
"activitypub.lookup_object",
223+
"activitypub.object.fetched",
224+
);
225+
deepStrictEqual(events.length, 1);
226+
});
227+
~~~~
228+
229+
`TestSpanExporter` exposes:
230+
231+
- `spans`: the raw `ReadableSpan[]` accumulated so far.
232+
- `getSpans(name)`: every span whose `name` matches.
233+
- `getSpan(name)`: the first such span, or `undefined`.
234+
- `getEvents(spanName, eventName?)`: events from spans named `spanName`,
235+
optionally filtered by `eventName`.
236+
- `clear()`: empty the buffer (useful between sub-cases inside one test).
237+
- `forceFlush()` / `shutdown()`: implement the `SpanExporter` contract;
238+
`shutdown()` also clears the buffer.
239+
240+
241+
How a test file fits together
242+
-----------------------------
243+
244+
A typical test file in this monorepo combines all three utilities:
245+
246+
~~~~ typescript
247+
import {
248+
createTestTracerProvider,
249+
mockDocumentLoader,
250+
test,
251+
} from "@fedify/fixture";
252+
import { deepStrictEqual, ok } from "node:assert/strict";
253+
import { someApiUnderTest } from "./mod.ts";
254+
255+
test("someApiUnderTest() does the thing", async () => {
256+
const [tracerProvider, exporter] = createTestTracerProvider();
257+
258+
const result = await someApiUnderTest("https://example.com/object", {
259+
documentLoader: mockDocumentLoader,
260+
tracerProvider,
261+
});
262+
263+
ok(result != null);
264+
deepStrictEqual(exporter.getSpans("the.expected.span").length, 1);
265+
});
266+
~~~~
267+
268+
Run it with the runtime of your choice:
269+
270+
~~~~ bash
271+
mise run test # Test all packages
272+
mise run test-each <PACKAGES> # Test specific packages
273+
~~~~
274+
275+
276+
Caution: Don't import `@fedify/fixture` from non-test files
277+
-----------------------------------------------------------
278+
279+
**Never import `@fedify/fixture` from any file that ships to end users.**
280+
Because the package is private it is absent from the published artifacts;
281+
any non-test file that imports it will fail to resolve once the consumer
282+
package is installed from [npm] or [JSR].
283+
284+
Restrict every import of `@fedify/fixture` to files matching
285+
`**/*.test.ts`. Keeping the boundary at the filename level makes it
286+
trivial to audit. You can check this with this command:
287+
288+
~~~~ bash
289+
mise run check:fixture-usage
290+
~~~~
291+
292+
It scans `packages/<pkg>/src/` for any non-`*.test.ts` file that contains an
293+
`import`/`export ... from "@fedify/fixture"` statement and fails if it finds
294+
one. The check is also part of `mise run check`.
295+
296+
Genuinely justified exceptions can be added to the `ALLOWLIST` constant in
297+
*[scripts/check\_fixture\_usage.ts](../../scripts/check_fixture_usage.ts)*
298+
together with an inline comment explaining why.
299+
300+
[npm]: https://www.npmjs.com/
301+
[JSR]: https://jsr.io/
302+
303+
304+
Repository layout
305+
-----------------
306+
307+
- *src/test.ts*: `test()` and `testDefinitions`.
308+
- *src/docloader.ts*: `mockDocumentLoader()`.
309+
- *src/otel.ts*: `TestSpanExporter`, `createTestTracerProvider()`.
310+
- *src/fixtures/*: JSON fixtures, organized by host and pathname.
311+
- *tsdown.config.ts*: builds *dist/* (ESM + CJS + types) and copies
312+
fixtures into *dist/fixtures/* so the `./fixtures/*` export resolves on
313+
Node.js and Bun.

0 commit comments

Comments
 (0)