Tests with t.check

Location: test/<area>/<name>.test.ts, mirroring the src/ path Runner: tap with the @ianbicking/tap-check/tap plugin Run: pnpm test, or pnpm exec tap test/<path>.test.ts from beebox/, frontend tests included (src/frontend/test/<path>); tap takes files or a glob, not a directory

A test file is <name>.test.ts, run by tap. Each test compares whole values with t.check(actual, expected). The expected value is written as code, and the type checker checks it against the actual value's type.

This is the expect test pattern (Jane Street; matklad): state the whole observed value, so a regression shows as a diff.

A test file

// Mobile session cookies: a minted cookie verifies back to its device, and
// every malformed cookie returns null rather than throwing.

import { test } from "tap";
import { any } from "@ianbicking/tap-check/check";
import { signMobileSession, verifyMobileSession } from "../../../src/core/mobile/session.js";
import { makeTmpBox } from "../../helpers/test-helpers.js";

void test("a minted cookie verifies back to its device", async (t) => {
  const box = await makeTmpBox();
  t.teardown(() => box.cleanup());
  const cookie = signMobileSession(box.root, { deviceId: "device-1", createdBy: "[email protected]", ttlMs: 60_000 });

  // createdBy enforces capture ownership; a cookie without it weakens that check.
  t.check(verifyMobileSession(box.root, cookie), {
    deviceId: "device-1",
    createdBy: "[email protected]",
    exp: any(Number).int(),
  });
});

Finding the expected value

Leave the expected value out. The check fails and prints a suggestion:

t.check(await createNote("Draft"));
suggested: |-
  {
    id: uuid(),
    title: "Draft",
    created: isoDate(),
  }
generalized: |-
  $.id — UUID string
  $.created — ISO timestamp

Read the suggestion before you paste it. It is what the code does now, which is not always what it should do. generalized lists the values it replaced with patterns. Keep those patterns only where the value really varies between runs. A fixed clock or a fixed id is a stronger test.

Matching rules

Patterns

Patterns are imported from @ianbicking/tap-check/check. Each pattern is typed: any(String) is accepted only where the actual value is a string.

PatternMatches
any(String), any(Number), any(Boolean)any value of that type
.startsWith(s), .endsWith(s), .includes(s), .matches(re)refine a string pattern
.int(), .between(min, max) (inclusive), .below(n), .above(n) (strict), .positive(), .closeTo(n, tolerance)refine a number pattern
uuid()a UUID string
isoDate()an ISO date or timestamp string, or a valid Date
oneOf("a", "b")one of the listed values
not(x), not(p)any value except x, or any value p does not match: a new id that must differ from the old one, or text that must not hold a string
is(x)exactly x: for an object, the same instance, not an equal copy
instanceOf(C)an instance of class C, such as an error's class
str`/box/${any(String)}/notes`a string with patterns inside
optional(p)a key that may be absent; when present it matches p. Accepted only on a property that is optional in the actual value's type
unordered([...])an array in any order, each item matched once
containing([...])an array that holds these items, in any order, among others
[others]: pin an object: every key not listed must match p
capture(p)matches p and keeps the value as .value
anything()any value except undefined; refused as the whole expected value

A key the expected object does not list fails, unless the object has [others]:

t.check(counts, { notes: 3, cards: 1, [others]: any(Number) });

capture relates two values, for example "the list contains the note that was created":

const noteId = capture(uuid());
t.check(await createNote("Draft"), { id: noteId, title: "Draft" });
t.check(await listNotes(), [{ id: noteId.value, title: "Draft" }]);

capture is also how to use a value after checking it. Check the whole value with captures in it, rather than t.ok(device) followed by an if (!device) return:

const deviceId = capture(any(String));
t.check(await redeemTicket(token), { deviceId, label: "phone" });
await revokeDevice(deviceId.value);

Text and errors

t.checkText(actual, text) compares a string, or a promise of one, with text. The text may hold «» wildcards («int», «uuid», «date», «*»). Use it for command output and other text; use t.check for values.

t.checkText(runStatus(), `ready: «int» cards
last sync: «date»`);

t.checkThrows(fn, expected) checks that fn throws or rejects. "Name" checks the error's name; "Name: message" checks both.

await t.checkThrows(() => parseCard("no frontmatter"), "CardParseError: missing frontmatter");

A strong test

Time, load, and real processes

The full suite runs many files at once on a loaded host. A test that depends on how fast a child process, the filesystem, or the network responds fails there.

Reading a failure

A failure prints three parts:

Fix the code when the behavior is wrong. Fix the expected value when the behavior is right.

Helpers

Service fakes

External dependencies (APIs, CLIs) sit behind typed service interfaces with in-memory fakes. The service layer is documented in src/services/AGENTS.md. Each service has an interface (the subset we use), a real factory, and a fake factory with domain-specific constructor parameters and observable state:

const cal = createFakeGoogleCalendar({ calendars: [{ id: "primary", summary: "Home" }] });
await cal.insertEvent("primary", { summary: "Dentist", start, end });
t.check(cal.events.length, 1);

Pass fakes to a route with makeTestServer({ services: { calendar: cal } }), then check what the route did through the fake's state.

ServiceFactoryKey constructor paramsObservable state
Claude CLIcreateFakeClaudeCli(){ loggedIn? }.loggedIn
Google CalendarcreateFakeGoogleCalendar(){ calendars?, events? }.calendars[], .events[]
OpenAI AudiocreateFakeOpenAIAudio(){ transcriptionText? }.calls[]
IMAPcreateFakeImap(){ messages? }.connected, .lockedMailbox
Google AuthcreateFakeGoogleAuth(){ accessToken? }none

A connector test uses makeTmpBox({ git: true }), seeds config files, injects a fake, and runs sync(). Credentials go through the machine secret store (secrets): setSecret and grantSecret (src/core/secrets/lifecycle.ts) do what bbx secrets set and grant do.

const box = await makeTmpBox({ git: true });
t.teardown(() => box.cleanup());
await initBox(box.root);
box.commitAll("init box");
await setSecret({ name, value });
await grantSecret({ slug, name, access: "server" });

const connector = createGmailConnector(box.root, createFakeGoogleGmail({ messages }));
await t.check(connector.sync(), { created: 1, updated: 0, pushed: 0 });

DOM tests

A frontend test can render a component two ways. renderToStaticMarkup (react-dom/server) shows the first render's HTML and runs no effects. mount() mounts the component in a happy-dom document, from src/frontend/test/helpers/dom-harness.ts. Use mount() for what only a live tree shows: effects and their cleanups, event handlers, state across re-renders, and which components re-render.

Every test process loads frontend modules through the React Compiler, with the options the Vite build uses (src/frontend/test/helpers/react-compiler-hooks.ts, registered in .taprc). Memoization in a test therefore matches the browser. REACT_COMPILER=0 turns it off for debugging. Compiled modules are cached in src/frontend/node_modules/.cache/.

import { mount } from "../helpers/dom-harness.js";
import { createElement as h, useState } from "react";

function Counter() {
  const [n, setN] = useState(0);
  return h("button", { onClick: () => setN(n + 1) }, `clicked ${n}`);
}

void test("a click re-renders the counter once", async (t) => {
  const view = await mount(h(Counter));
  t.teardown(() => view.unmount());
  await view.act(() => view.container.querySelector("button")?.click());
  t.check({ text: view.container.textContent, renders: view.renders() }, {
    text: "clicked 1",
    renders: { Counter: 2 },
  });
});

Render counts cover committed renders of ordinary trees. An aborted render is not counted, and Suspense fallbacks get no special handling.