Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions examples/dummy/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,12 @@ Feature slice: `apps/web/app/<feature>/{entity,repo,service,actions,mutator,live
- Every prompt carries `<name>.evals.ts` (the cases) and `<name>.vN.baseline.json` (the recorded
scores). A prompt with no eval fails `x verify` with `X_EVAL_MISSING`; the gate is the drop from
the baseline, so re-record with `ULTIMATE_EVAL_RECORD=1 x test eval` and commit the diff.
- Test fixtures come from `scripts/test-setup.ts`, the one preload in `bunfig.toml`. `clock`,
`mail` and `runJobs` are the framework's; `seed` and `actorFor` are Postly's. A test that
destructures anything else fails with `X_TEST_FIXTURE_UNKNOWN` — register it there, once.
- Test fixtures come from `scripts/test-setup.ts`, the one preload in `bunfig.toml`. `seed` and
`actorFor` are Postly's; everything else is the framework's — `clock`, `mail`, `network`,
`runJobs` built in-process, and `page`, `budget`, `signIn`, `deploy`, `subscribe` waiting on a
driver (`X_TEST_FIXTURE_UNAVAILABLE` until one is installed). A test that destructures a name
nobody registered fails with `X_TEST_FIXTURE_UNKNOWN` — register it there, once. Never register
a framework name here: two apps would then disagree about what a `page` is.

## Boundaries (build errors, not lint warnings)

Expand Down
33 changes: 17 additions & 16 deletions examples/dummy/scripts/test-setup.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
// Postly's test preload: the fixtures the APP owns. `clock`, `mail` and `runJobs` arrive with
// the framework's own preload, imported below — an app registers only what the framework cannot
// know, which here is the seed graph and how a member becomes an actor.
//
// [test]
// preload = ["./scripts/test-setup.ts"]
//
// Postly's test preload: the fixtures the APP owns, which is two of them — the seed graph, and
// how a member becomes an actor. Everything else in the bag arrives with the framework's own
// preload, imported below, so an app registers only what the framework cannot know.

// The framework is imported by relative path rather than by `@ultimat3/*`: this directory is not
// a workspace member yet (issue #9), and a preload runs before anything else, so it must not
// depend on workspace symlinks. A generated app writes `@ultimat3/testing` here. For the same
// reason `scripts/` is not in tsconfig's `include` — a composite project cannot reach across
// into another one's sources. Both go away when the app joins the workspace.
//
// Booting `apps/web/api` is the other job, and it belongs HERE rather than in a test file. It is
// the registration pass: it stamps each export name onto its declaration, which is what gives a
// projection a stable name to project under. A test in `app/` that imported it would be `app/`
// reaching into `api/` at runtime — the boundary `x verify` rejects with `X_BOUNDARY_VIOLATION`,
// because it is the edge along which a page could call a handler instead of the typed client.
// The preload is outside both, runs once for the whole suite, and is already where the app says
// what its tests need. Same relative-path convention, same reason.

import '../../../packages/testing/src/preload';
// The registration pass, and it belongs HERE rather than in a test file: importing the API stamps
// each export name onto its declaration, which is what gives a projection a stable name to project
// under. A test in `app/` that imported it would be `app/` reaching into `api/` at runtime — the
// boundary `x verify` rejects with `X_BOUNDARY_VIOLATION`, because it is the edge along which a
// page could call a handler instead of the typed client. The preload is outside both, runs once
// for the whole suite, and is already where the app says what its tests need. Same relative-path
// convention, same reason.
import '../apps/web/api';
import { assert, userActor } from '../../../packages/core/src/index';
import type { Driver, EntityCore, Repo, Seed } from '../../../packages/entity/src/index';
Expand Down Expand Up @@ -113,6 +108,12 @@ const actorFor = (member: SeedRow) =>
roles: [String(member['role'])],
});

/**
* Two names, and deliberately no more. `clock`, `mail`, `network`, `runJobs` and the driver-backed
* `page`, `budget`, `signIn`, `deploy` and `subscribe` all arrive with the framework's preload:
* registering `page` here would be Postly deciding for itself what a page is, and two apps would
* then disagree about it.
*/
defineFixtures({
seed: createSeed,
actorFor: () => actorFor,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cmd-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './works
export const VERIFY_STEPS: readonly VerifyStep[] = [
{
name: 'typecheck',
summary: 'tsc across every workspace',
summary: 'tsc -b across every project the root references',
async run(ctx) {
const result = await ctx.runner(['bunx', 'tsc', '-b', '--pretty', 'false'], {
cwd: ctx.root,
Expand Down
28 changes: 15 additions & 13 deletions packages/cli/src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@ import { flagBool, flagString, parseArgs } from './parse';
import { thrownBy } from './thrown-by';

const SPECS: readonly CommandSpec[] = [
{
name: 'verify',
summary: 'the gate',
usage: 'x verify',
flags: [{ name: 'only', type: 'string', summary: 'steps' }],
},
// `verify` really does declare no flags — narrowing the gate would make "green" mean whatever
// the caller chose (axiom 5), so the fixture carries no `--only`/`--skip` either.
{ name: 'verify', summary: 'the gate', usage: 'x verify', flags: [] },
{
name: 'db',
summary: 'database',
usage: 'x db <sub>',
subcommands: ['gen', 'migrate', 'branch'],
flags: [{ name: 'name', type: 'string', summary: 'migration or branch name' }],
},
{ name: 'g', aliases: ['generate'], summary: 'scaffold', usage: 'x g <kind> <name>' },
{ name: 'help', summary: 'help', usage: 'x help' },
Expand Down Expand Up @@ -43,9 +41,11 @@ describe('unit · parseArgs', () => {
});

test('reads string flags in both --flag value and --flag=value form', () => {
expect(flagString(parseArgs(['verify', '--only', 'lint'], SPECS), 'only')).toBe('lint');
expect(flagString(parseArgs(['verify', '--only=lint,drift'], SPECS), 'only')).toBe(
'lint,drift',
expect(flagString(parseArgs(['db', 'branch', '--name', 'feat-billing'], SPECS), 'name')).toBe(
'feat-billing',
);
expect(flagString(parseArgs(['db', 'branch', '--name=feat-billing'], SPECS), 'name')).toBe(
'feat-billing',
);
});

Expand All @@ -59,9 +59,9 @@ describe('unit · parseArgs', () => {
});

test('everything after -- is passthrough, not a flag', () => {
const args = parseArgs(['verify', '--', '--only', 'nonsense'], SPECS);
expect(args.passthrough).toEqual(['--only', 'nonsense']);
expect(flagString(args, 'only')).toBeUndefined();
const args = parseArgs(['db', 'branch', '--', '--name', 'nonsense'], SPECS);
expect(args.passthrough).toEqual(['--name', 'nonsense']);
expect(flagString(args, 'name')).toBeUndefined();
});

test('an unknown command throws X_CLI_UNKNOWN_COMMAND with a suggestion', () => {
Expand All @@ -84,7 +84,9 @@ describe('unit · parseArgs', () => {
});

test('a string flag with no value is an error, not a silent empty string', () => {
expect(() => parseArgs(['verify', '--only'], SPECS)).toThrow();
const failure = thrownBy(() => parseArgs(['db', 'branch', '--name'], SPECS));
expect(failure.code).toBe('X_CLI_BAD_FLAG');
expect(String(failure.cause)).toContain('expects a value');
});

test('bare argv and --help both route to the help command', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/testing/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ factories only**, so a test that never destructures `mail` never loads the mail
| No wall clock | `frozenClock` / `advanceClock`; `Date.now()` is frozen by the preload |
| No unmocked egress | `sealed-network.ts` patches fetch; a miss is `X_TEST_NETWORK_SEALED` |
| Self is not egress | a port core's `markListening()` announced passes through — a socket test never unseals |
| Offline is a state, not a mock | `network.offline()` / `.drop()` fail every request as `X_TEST_NETWORK_OFFLINE`, ahead of the mocks — the app's own offline path runs |
| One way offline | the `network` fixture. `setNetworkState` is the gate's only writer and is not exported — setting it from a test body skips the fixture's disposal and leaves every later file offline |
| No retries | a flake is fixed or deleted the day it flakes; there is no `retry: 3` |
| Test names | the filename picks the step; `testName(type, name)` on the outer `describe` puts that type on every failure line under it. Never on the inner `test` too — the prefix would print twice |
| Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
| Fixtures | the preload registers `clock`, `mail`, `runJobs` — an app registers the rest |
| Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
| Built vs declared | `clock` `mail` `network` `runJobs` are built in-process; `page` `budget` `signIn` `deploy` `subscribe` are declared and wait for a driver (`X_TEST_FIXTURE_UNAVAILABLE`) |
| One seam for drivers | a driver registers over a declaration with `defineFixtures` — merges, last wins. Never a second registration mechanism |
| A driver arrives whole | `defineFixtures` holds every name `Fixtures` declares to its declared type, so a half-built `page` is a compile error at the registration, not a missing method three awaits later |
| Registry hygiene | the fixture registry is process-global; a test that clears it snapshots with `fixtureSnapshot()` and hands it back in `afterAll` |
| Fixture teardown | a fixture that installs process-global state (the ambient job or mail driver) implements `Symbol.dispose` / `Symbol.asyncDispose` and restores what was there; `fixtureTest` disposes in reverse build order even when the body throws |
| Building one by hand | `createRunJobs()` outside `fixtureTest` is not disposed for you — reset the driver in `afterEach`, or the next file in the process inherits your queue |
Expand Down
27 changes: 23 additions & 4 deletions packages/testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
| `test-types.ts` | the six test types and their helpers |
| `matchers.ts` | `toBeUltimateError` `toDenyPolicy` `toEmitSteps` `toMatchOpenApi` `toBeWithinBudget` `toRejectInput` |
| `fixtures.ts` | the registry + `test('…', ({ clock }) => …)` injection |
| `fixture-{clock,mail,jobs}.ts` | the three fixtures the framework owns |
| `fixture-{clock,mail,jobs,network}.ts` | the four fixtures the framework builds in-process |
| `fixture-drivers.ts` | the five it declares but a driver must build — `page` `budget` `signIn` `deploy` `subscribe` |
| `framework-fixtures.ts` | registers both sets; the app registers only what it owns |
| `preload.ts` | the bunfig preload that installs all of the above |

## Install
Expand All @@ -42,14 +44,29 @@ test('the three-day sleep releases the worker', async ({ clock, runJobs }) => {
});
```

| Fixture | Is | Registered by |
| Fixture | Is | Built by |
|---|---|---|
| `clock` | `now()` · `advance('3d')` · `set(instant)` on the frozen clock | the preload |
| `mail` | `outbox()` · `lastTo(address)` · `failOnce(mail)` over an in-memory transport | the preload |
| `network` | `offline()` · `drop()` · `online()` · `state()` over the sealed network | the preload |
| `runJobs` | a worker: call it to enqueue+drain, then `drain()` `due()` `inFlight()` `depth()` | the preload |
| `page` | the browser: `goto` `gotoStreamed` `getByRole` `evaluate` `waitForServiceWorker` | a browser driver |
| `budget` | `jsBytes(route)` measured off the built output | a browser driver |
| `signIn` | put the browser session in a member's shoes | a browser driver |
| `deploy` | `newBuild()` — same app, new build id, page still open | a browser driver |
| `subscribe` | one subscriber's `rows()` `patches()` `settled()` `lsn()` | a replicator |
| anything else | whatever the app registers | the app's `scripts/test-setup.ts` |

`mail` and `runJobs` install a process-global driver for the length of one test and hand the previous one back afterwards. A fixture that takes over a global does the same: implement `Symbol.dispose` or `Symbol.asyncDispose` on what the factory returns, and `fixtureTest` calls it in reverse build order — including when the test body throws.
The last five are **declared but not built**: the name resolves, and destructuring one in a process
with no driver fails as `X_TEST_FIXTURE_UNAVAILABLE`, naming the driver rather than telling you to
register a fixture that is not yours to define. A driver arrives through the same registry —
`defineFixtures` merges, last registration wins — so there is no second seam to learn.

The declaration is also the driver's type: `defineFixtures` holds every name `Fixtures` declares to
the type it was declared with, so a half-built `page` is a compile error at the registration rather
than a missing method three awaits into a later test.

`mail`, `network` and `runJobs` install a process-global driver for the length of one test and hand the previous one back afterwards — the state they *found*, not a fixed default, so an outer fixture already offline stays offline when an inner one disposes. A fixture that takes over a global does the same: implement `Symbol.dispose` or `Symbol.asyncDispose` on what the factory returns, and `fixtureTest` calls it in reverse build order — including when the test body throws. Going offline is the `network` fixture's job and only its job; the gate's writer is not exported, because a test that set it directly would skip that disposal and take every later file down with it.

An app adds its own with `defineFixtures` and widens the type by augmenting `Fixtures`:

Expand All @@ -64,7 +81,9 @@ declare module '@ultimat3/testing' {
```

Destructuring a name nobody registered fails with `X_TEST_FIXTURE_UNKNOWN`, which names the set
that *is* registered — never `undefined is not an object` from inside the body.
that *is* registered — never `undefined is not an object` from inside the body. A name that is
registered but has no driver fails with `X_TEST_FIXTURE_UNAVAILABLE` instead; the two are different
instructions, so they are different codes.

## The six test types

Expand Down
41 changes: 41 additions & 0 deletions packages/testing/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core';

export const TESTING_ERROR_CODES = [
'X_TEST_NETWORK_SEALED',
'X_TEST_NETWORK_OFFLINE',
'X_TEST_DB_UNAVAILABLE',
'X_TEST_NONDETERMINISTIC',
'X_TEST_FIXTURE_UNKNOWN',
'X_TEST_FIXTURE_UNAVAILABLE',
] as const;

export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];

export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> = {
X_TEST_NETWORK_SEALED: 'a test tried to reach the network',
X_TEST_NETWORK_OFFLINE: 'the test network is offline',
X_TEST_DB_UNAVAILABLE: 'no Postgres for the test template',
X_TEST_NONDETERMINISTIC: 'a test read wall-clock time or unseeded randomness',
X_TEST_FIXTURE_UNKNOWN: 'a test requested a fixture nobody registered',
X_TEST_FIXTURE_UNAVAILABLE: 'a declared fixture has no driver in this process',
};

// Titles must be registered for `format()` to render the contract's first line. Every code above is
Expand Down Expand Up @@ -96,3 +100,40 @@ export class FixtureUnknownError extends UltimateError {

export const fixtureUnknown = (name: string, registered: readonly string[]): UltimateError =>
new FixtureUnknownError({ name, registered });

/**
* Different failure from `X_TEST_FIXTURE_UNKNOWN`, and the distinction is the whole point: the
* name IS registered, so "register it" is the wrong instruction. What is missing is the driver
* underneath — a browser, a replicator — which the framework declares but deliberately does not
* bundle. Naming what it needs turns "undefined is not an object" into a decision the reader can
* make: install the driver, or stop asking for the fixture.
*/
export class FixtureUnavailableError extends UltimateError {
constructor(input: { name: string; needs: string }) {
super({
code: 'X_TEST_FIXTURE_UNAVAILABLE',
cause: `fixture "${input.name}" is declared but nothing in this process drives it — it needs ${input.needs}`,
fix: `install one in the test preload: defineFixtures({ ${input.name}: () => yourDriver() })`,
docs: docsFor('X_TEST_FIXTURE_UNAVAILABLE'),
});
}
}

export const fixtureUnavailable = (name: string, needs: string): UltimateError =>
new FixtureUnavailableError({ name, needs });

/**
* A request made while `network.offline()` (or `network.drop()`) is in force. Coded rather than a
* bare `TypeError` because a test that lands here uncaught needs to know which of the two it was:
* the app's offline path not running, or a fixture left offline by the test before it.
*/
export class NetworkOfflineError extends UltimateError {
constructor(input: { url: string; method: string; mode: 'offline' | 'dropped' }) {
super({
code: 'X_TEST_NETWORK_OFFLINE',
cause: `${input.method} ${input.url} while the test network is ${input.mode}`,
fix: 'network.online() before the call — or assert the offline path instead of the request',
docs: docsFor('X_TEST_NETWORK_OFFLINE'),
});
}
}
Loading
Loading