@ultimat3/testing 22.14.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +29 -36
- package/README.md +247 -8
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/determinism.ts +20 -0
- package/src/e2e-app.ts +14 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +5 -0
- package/src/e2e-spawn.ts +3 -0
- package/src/errors.ts +2 -0
- package/src/failure-context.ts +42 -0
- package/src/fixture-jobs.ts +110 -25
- package/src/fixtures.ts +9 -4
- package/src/frozen-scheduler.ts +49 -0
- package/src/index.ts +29 -1
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -0
- package/src/local-tx.ts +87 -0
- package/src/matcher-row.ts +93 -0
- package/src/matcher-surface.ts +7 -0
- package/src/matchers.ts +6 -0
- package/src/per-test-reset.ts +43 -0
- package/src/preload.ts +13 -0
- package/src/quiet-logs.ts +56 -0
- package/src/render-view.ts +118 -0
- package/src/test-seal-key.ts +51 -0
- package/src/test-types.ts +53 -30
- package/src/e2e-dom-fixture.ts +0 -122
package/CLAUDE.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Tier 5. May import tiers 0–4. Imported by every package's tests and by generated apps.
|
|
4
4
|
|
|
5
|
-
Deps: `core` (tier 0), plus `time`, `jobs`, `mail`, `db` and `
|
|
6
|
-
inside the fixture factories only
|
|
5
|
+
Deps: `core` (tier 0), plus `time`, `jobs`, `mail`, `db`, `entity`, `render` and `http` — imported **dynamically
|
|
6
|
+
inside the fixture factories only** (`render` inside `render-view.ts`, `http` inside `auth-request.ts`), so a test that never destructures `mail` never loads the mail
|
|
7
7
|
package and a `packages/core` test never loads the entity registry. `entity` is a dependency for
|
|
8
8
|
exactly one call: `nPlusOne()`, so the strict fixture reports the error `x dev` reports, with the
|
|
9
9
|
`fix:` the schema's own relations spell. A second N+1 code owned here would be a second answer to
|
|
@@ -20,7 +20,11 @@ is its own entry point and not part of the barrel.
|
|
|
20
20
|
| Self is not egress | a port core's `markListening()` announced passes through — a socket test never unseals |
|
|
21
21
|
| 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 |
|
|
22
22
|
| 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 |
|
|
23
|
+
| The seal key is a test process's, and nobody's export | `test-seal-key.ts`: installed by the preload only when `NODE_ENV === 'test'` and neither `ULTIMATE_SECRETS_KEY` nor `.secrets.key` exists; `startE2eApp` hands it to the child by VALUE (`testSealKeyEnv`), because the spawned app is a server and must never be able to install one. The constant is module-private and off the barrel (`test-seal-key.test.ts`). Never loosen the `NODE_ENV` check: the key is public |
|
|
24
|
+
| `toEqualRow` never prints a server-only value | `matcher-row.ts` compares every OWN property (`toEqual` cannot see a `.sealed()` column — empty diff). A non-enumerable property that differs is NAMED; its value and its length stay out of the message, which lands in CI logs |
|
|
23
25
|
| No retries | a flake is fixed or deleted the day it flakes; there is no `retry: 3` |
|
|
26
|
+
| A mutator's `local()` runs against `memoryLocalTx()` | `local-tx.ts`: keyed rows with the record store's rules (update invents nothing, `undefined` leaves a column, rows frozen). Never a hand-rolled table per test |
|
|
27
|
+
| A page test RENDERS, with no cast | `render-view.ts`: `renderRoute(module, { url, actor })` resolves `load` once and hands `meta` and the page that one object, as every render mode does; `renderView(Component, props)` is one component. `@ultimat3/render` is imported INSIDE both, never at module scope — the barrel is what a tier-0 test imports for `expect` |
|
|
24
28
|
| `toBeUltimateError` reads THREE fields | an `X_` code plus a `cause` plus a `fix`, all through core's `stringField`. `typeof value.code === 'string'` alone passed a Node `ENOENT` |
|
|
25
29
|
| One matcher WAITS, and it is `toBeVisible` | `await expect(locator).toBeVisible()` retries `isVisible()` to a budget — 5000ms every 100ms, Playwright's own default, narrowable per call. |
|
|
26
30
|
| The budget is counted in LOOKS, not milliseconds | this package freezes `Date.now()`, so a deadline computed from the clock never expires and the loop spins forever. `attemptsFor(budget)` is `1 + floor(timeout / interval)` |
|
|
@@ -29,12 +33,18 @@ is its own entry point and not part of the barrel.
|
|
|
29
33
|
| Wrong receiver throws, wrong value returns | a matcher handed a page where a locator belongs is not FALSE, it is unanswerable — `pass: false` would read as "the element was hidden". A wrong-shaped receiver is a coded throw |
|
|
30
34
|
| A matcher MESSAGE is a thunk, and never `JSON.stringify` | `result(pass, () => …)` — `expect.extend` reads `message()` only on the wrong verdict, and the string used to be built EAGERLY. |
|
|
31
35
|
| Every message thunk needs a test that PROVOKES it | a thunk is a function, so an unread message is an UNCOVERED function and `bun run scripts/coverage-gate.ts --package testing` is what says so |
|
|
32
|
-
| Test names | the filename picks the step; `testName(type, name)`
|
|
33
|
-
| `test`
|
|
36
|
+
| Test names | the filename picks the step; a typed helper prefixes its own name. `testName(type, name)` is for an outer `describe` only — never on the inner `test` too, the prefix would print twice |
|
|
37
|
+
| One body shape, five registrars | `test` (= `fixtureTest`), `unitTest`, `contractTest`, `liveTest`, `jobTest` all take `FixtureBody` + `{ timeoutMs }`: `unitTest('…', async ({ runJobs }) => …)`. Never `describe(testName('unit', …))` + `test` to get a fixture |
|
|
38
|
+
| `runJobs` says who and what | `{ actor }` is the WORKER's identity (the org stays the job's declared tenant), `{ tenantId }` the enqueuer's row; `executions[n].result` is the body's return. A fresh event bus per fixture |
|
|
39
|
+
| A `runJobs` pass is a real worker's `tick()` | `fixture-jobs.ts` builds an unstarted `createWorker` per pass, so admission runs: `concurrency` waits and `whenBusy: 'fail'` refuses (`X_JOB_KEY_BUSY`) exactly as in a fleet. Never `executeJob` on a bare `claim()`. The lease and slot renew every 1 ms of TEST time (`RENEW_MS`, `frozen-scheduler.ts` on `onClockMoved`), never on a wall-clock interval: cancel, `clock.advance(1)`, and the body hears `X_JOB_LEASE_LOST` — no hand-built worker |
|
|
40
|
+
| Per-test state is `per-test-reset.ts` | the app preload's `beforeEach` resets the jobs event bus — only when `@ultimat3/jobs` is already loaded, so a core test never loads it |
|
|
41
|
+
| An island test is `describeIslandState` | one block per declared state: mounted in `beforeAll` (60 s), disposed in `afterAll`, one build per file. `mountIslandState` + `using` for one test's own mount; an undeclared id is `X_TEST_ISLAND_STATE_UNKNOWN` |
|
|
42
|
+
| An authenticator is tested with `authRequest()` | the hook's own two arguments, real — never `as unknown as Parameters<…>` |
|
|
43
|
+
| `LOG_LEVEL` above `info` filters the TERMINAL | `quiet-logs.ts` pins the logger at `info` while core loads, so a collecting `setLogSink` still sees `warn` under `LOG_LEVEL=error`. It stays the preload's FIRST import |
|
|
34
44
|
| Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
|
|
35
|
-
| Fixtures | the preload registers the
|
|
36
|
-
| e2e without a driver | `e2eTest` becomes `test.skip
|
|
37
|
-
| The seam has an inverse | `resetE2eDriver()
|
|
45
|
+
| Fixtures | the preload registers the framework bag — an app registers only what it alone knows (`seed`, `actorFor`) |
|
|
46
|
+
| e2e without a driver | `e2eTest` becomes `test.skip`; the gate reports the step SKIPPED (or `X_VERIFY_SUITE_VANISHED` when `x.verify.json` requires it), never passed |
|
|
47
|
+
| The seam has an inverse | `resetE2eDriver()`: `useE2eDriver` writes MODULE scope, so a file that installs a browser undoes it |
|
|
38
48
|
| Built vs declared | `clock` `mail` `network` `runJobs` `statements` `subscribe` are built in-process; `page` `budget` `signIn` `deploy` are declared and wait for a driver (`X_TEST_FIXTURE_UNAVAILABLE`). |
|
|
39
49
|
| `page` HAS a driver; `budget`, `signIn`, `deploy` do not | `installE2eDriver({ page, baseUrl })` registers `page` only. Byte counts, a sign-in route and a new build id are facts no page port can answer; a no-op would read as proof |
|
|
40
50
|
| `network` is THIS process's fetch, and an e2e page is not in this process | `network.offline()` beside `page` is a no-op on the browser. `E2eFixtures.offline()` is the browser-side spelling and forwards to `E2eBrowserPage.offline()`; a page port with none refuses by name |
|
|
@@ -53,7 +63,7 @@ is its own entry point and not part of the barrel.
|
|
|
53
63
|
| Leaks are the file's, not the next file's | `installRegistryLeakGuard()` runs from the preload and fails the run naming the FILE that left cache tags declared or a cache tier registered after its last test (`X_TEST_REGISTRY_LEAK`). |
|
|
54
64
|
| The baseline is not a hook | measured on Bun 1.3.14 the order is onLoad → module eval → file `beforeAll` → describe `beforeAll` → preload `beforeEach`, so a preload hook cannot sample before the file's own `beforeAll` |
|
|
55
65
|
| Reported and restored are different sets | the guard also RESTORES, at the same file boundary, the registries whose module-scope declarations a neighbour's cleanup destroys |
|
|
56
|
-
| A catalog restore is a MERGE, never a replace
|
|
66
|
+
| A catalog restore is a MERGE, never a replace | the other three registries are replaced with the snapshot. `registerCatalog` has no inverse, so all a file can cost its neighbour is a `resetCatalogs()` |
|
|
57
67
|
| Permissions are still REPLACED at a file boundary, and it is measured | an app's `definePermissions()` reached only by a dynamic `loadApp()` is dropped at that file's boundary exactly as catalogs were; the union rule would leak `permissions.test.ts`'s declarations into every later file. Open, its own piece of work (history) |
|
|
58
68
|
| Guarded state is boot state | only the two registries whose honest invariant is "clean when the file ends" — `declareTags` and `registerTier` are boot installs. |
|
|
59
69
|
| Filled and CLEARED are different questions | and the row above answers only the first. "Idiomatic to leave filled" is about the leak REPORT |
|
|
@@ -64,37 +74,37 @@ is its own entry point and not part of the barrel.
|
|
|
64
74
|
| A boot that rejects is its own teardown | `acquireWorkerDatabase`, `seed` or `boot` throwing returns no `BootedHarness`, so no caller can ever reach `close()` |
|
|
65
75
|
| A found template is not a migrated one | `template-db.ts` tolerates "already exists" for the `CREATE DATABASE` alone. `config.migrate` runs unconditionally and un-swallowed |
|
|
66
76
|
| 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 |
|
|
67
|
-
| Building one by hand | `createRunJobs()` outside
|
|
77
|
+
| Building one by hand | `createRunJobs()` outside a fixture body is not disposed for you — dispose it in `afterEach`, or the next file inherits your queue and your event bus |
|
|
68
78
|
| Factory strategy | an association is built with the strategy that asked for it: `build()` never reaches a database, `create()` writes the parent first. Never a third strategy |
|
|
69
79
|
| One write seam | `usePersister` is the only place `create()` writes. A factory that took a repo argument would put the seam at every call site |
|
|
70
80
|
| Factory seeds | derived from the table name unless given, so two entities never draw the same uuid stream. `reset()` cascades into associated parents — a half-reset row is worse than none |
|
|
71
81
|
| Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
|
|
72
82
|
| An island needs a BUILDER, not an import | `buildIslands` is `@ultimat3/cli`'s and both packages are tier 5; the one declared edge is `cli → testing`, so the reverse is a `bun run boundaries` failure. |
|
|
73
83
|
| `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited |
|
|
74
|
-
| Dispose STOPS the island, then restores the globals |
|
|
84
|
+
| Dispose STOPS the island, then restores the globals | when `mount` resolved to a function, `[Symbol.dispose]` calls it BEFORE `restore()` |
|
|
75
85
|
| The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. `packages/ui/src/fake-dom.ts` is a second one for keyboard code, and `ui -> testing` is upward, so it is not a copy to collapse. `bun test` has no DOM and no DOM library may be added |
|
|
76
86
|
| `style` and `classList` RECORD | `FakeStyle` is one declaration map behind all four spellings compiled Solid uses (static attribute, `setProperty`, `cssText`, `removeAttribute`), so a test can assert the component set `--form-gap` |
|
|
77
87
|
| `classList` is the class attribute | not a list of its own, so `classList.toggle` — what the compiler emits INLINE for `classList={{ … }}`, with no runtime helper in front of it — and `className` can never answer one element two ways. |
|
|
78
88
|
| A `document` listener is the documentElement's | no bubbling: `document.addEventListener` registers on `documentElement`, and `fire(mounted.documentElement, 'keydown', …)` drives it. One handler per type, last wins |
|
|
79
89
|
| `querySelector` skips `this` | descendants only, as the DOM's does. Matching the element it is called on made a host `<div>` answer `find('div')` with the container the test built rather than the markup the island rendered |
|
|
80
90
|
| The selector grammar is SMALL and REFUSES | `island-selector.ts`: compounds of tag, `#id`, `.class`, `[attr]`, `[attr="value"]`, joined by space or `>`. Anything else is `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` with the offset, never an empty answer |
|
|
81
|
-
| The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are
|
|
91
|
+
| The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are writable numbers, default 0; `getBoundingClientRect()` derives from the offset box |
|
|
82
92
|
| `ResizeObserver` records and never fires on its own | `island-observers.ts`, one registry PER DOCUMENT. |
|
|
83
|
-
| A mount installs process globals | so `MountedIsland` is `Disposable` and a `mount` that THROWS restores before it rethrows. A fake `document` left installed reaches every later FILE in the run
|
|
93
|
+
| A mount installs process globals | so `MountedIsland` is `Disposable` and a `mount` that THROWS restores before it rethrows. A fake `document` left installed reaches every later FILE in the run |
|
|
84
94
|
| `fire` answers whether a handler ran | a selector matching nothing and an island that attached no handler are the same silence otherwise — the second is a bug, the first a typo. |
|
|
85
95
|
| A states file is PURE DATA, and it is enforced | `defineIslandStates` declares the states an island can be photographed in — error, empty, over-quota, read-only |
|
|
86
|
-
| The rule is the RELATIVENESS, not the extension
|
|
96
|
+
| The rule is the RELATIVENESS, not the extension | `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun, so any relative runtime import is refused |
|
|
87
97
|
| `import type` is not an import, and it is the one way to name the component | `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type` |
|
|
88
98
|
| Unreadable is not pure | a computed specifier — ``import(`./${name}.island`)``, `require(SPEC)` — is refused as `IslandStatesOpaqueImportError` rather than passed. |
|
|
89
|
-
| What the scan does NOT follow
|
|
99
|
+
| What the scan does NOT follow | a BARE specifier other than `solid-js`, an ABSOLUTE path (both holes), and a specifier inside a string LITERAL (a false refusal, the safe direction) |
|
|
90
100
|
| Props are JSON or they are refused | they ride `data-x-props`, which `@ultimat3/render`'s `emitIslandProps` `JSON.stringify`s, so anything else is a prop the component never receives. |
|
|
91
101
|
| The clock is pinned in the vocabulary, zone included | `timeZone` defaults to `ISLAND_SHOT_TIME_ZONE` (`UTC`) and `now` to this package's own `DEFAULT_NOW`, and both ride onto every `IslandShotTarget`. |
|
|
92
102
|
| Loose in, strict out | `findIslandStates` resolves `Settings`, `settings`, `settings.island.tsx` and the full path to one manifest, and refuses a name nothing answers to by listing EVERY valid one |
|
|
93
103
|
| The disk check is not in `defineIslandStates` | a declaration evaluates wherever it is imported from, so a rule that reads the filesystem at import time fails on the cwd rather than on the path. |
|
|
94
104
|
| The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package. |
|
|
95
105
|
| The chunk is imported from a temp FILE | named by its SHA-256, so an edit is a new module and nothing is left in the app. Never a `data:` URL: `bun test --coverage` panics importing one past ~4 kB (Bun 1.4.0); `fixture-island.test.ts` pins it |
|
|
96
|
-
| The scratch directory is lazy and removed
|
|
97
|
-
| Attaching a node MOVES it | `appendChild`, `insertBefore
|
|
106
|
+
| The scratch directory is lazy and removed | made on the first mount, deleted on `exit`; never at module scope — this module is on the `.` barrel |
|
|
107
|
+
| Attaching a node MOVES it | `appendChild`, `insertBefore`, `replaceChild` detach it from its old parent first; `removeChild` clears `parentNode` |
|
|
98
108
|
| Globals install all-or-nothing | `installGlobals` saves DESCRIPTORS, not values — a saved value cannot tell "no such global" from "a global holding `undefined`", and the teardown deleted both |
|
|
99
109
|
| Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. |
|
|
100
110
|
|
|
@@ -140,6 +150,7 @@ lives beside the `PageLike` it implements. `cli` imports it over the declared `c
|
|
|
140
150
|
| `cdp-e2e-session.ts` | the BROWSER half, `E2eSession`: every target auto-attached at browser level and PAUSED until its Network domain is on (a SharedWorker opens its socket at start-up) |
|
|
141
151
|
| `cdp-e2e-page.ts` | one TAB, `E2eTab`: `E2eBrowserPage`'s five methods plus `reload`, `waitFor`, `indexedDbNames`, `close`. `offline()` forwards to the session — the switch is browser-wide |
|
|
142
152
|
| `e2e-app.ts` | `startE2eApp({ root, mode, seed })`: reset + seed + spawn on a THROWAWAY `ULTIMATE_STATE_DIR` and free ports, `/readyz`-gated, spawned through the app's own `@ultimat3/cli` bin (`xBin`); `stop()` removes the directory |
|
|
153
|
+
| `failure-context.ts` | a FAILED test's message ends with the e2e app's last 40 log lines (≤ 4 kB): `e2e-run.ts` installs `app.log()`, `fixtureTest` and `e2eTest` append it. Never on a pass |
|
|
143
154
|
| `e2e-preload.ts` + `e2e-browser-handle.ts` | the `e2e` step's preload (`@ultimat3/testing/e2e-preload`): with `ULTIMATE_E2E_ROOT` set it spawns the app, opens ONE browser, installs its first tab, and publishes `e2eBrowser()`, `e2eApp()`, `e2eBaseUrl()` |
|
|
144
155
|
| `packages/cli/src/verify-e2e.ts` | `withE2eApp` (cli's): in an app on a machine with Chrome, the `e2e` step runs the suite with that preload; otherwise exactly as before |
|
|
145
156
|
| `cdp-errors.ts` | one constructor per way the browser half refuses |
|
|
@@ -148,26 +159,8 @@ lives beside the `PageLike` it implements. `cli` imports it over the declared `c
|
|
|
148
159
|
answers `false` and the gate's `e2e` step refuses rather than passing over a browser it lacks.
|
|
149
160
|
GitHub-hosted `ubuntu-latest` ships Chrome at `/usr/bin/google-chrome`.
|
|
150
161
|
|
|
151
|
-
|
|
152
|
-
(`
|
|
153
|
-
server's `ui.*` tools launch on the same `launchChrome` since 22.0.0
|
|
154
|
-
(`packages/cli/src/cdp-shot-driver.ts`) — one launcher, one wire. A CDP event listener is handed
|
|
155
|
-
the event's `sessionId` (`CdpEventListener`), so a subscriber owning one page ignores the rest.
|
|
156
|
-
|
|
157
|
-
**The load EVENT is the completion signal, never `Page.navigate`'s reply** — Chrome drops the reply
|
|
158
|
-
when a navigation swaps the render process. The waiter goes up before the send; the reply is read
|
|
159
|
-
only for `errorText`.
|
|
160
|
-
|
|
161
|
-
**Every call is deadlined, and a close settles every call in flight.** Four codes, four repairs:
|
|
162
|
-
`X_CDP_BROWSER_MISSING` (install one), `X_CDP_LAUNCH_FAILED` (read its stderr, in the cause),
|
|
163
|
-
`X_CDP_CALL_FAILED` (look at the page), `X_CDP_TIMEOUT` (raise the deadline).
|
|
164
|
-
|
|
165
|
-
**`evaluate` is the lossy edge.** Only `Function.prototype.toString()` crosses: a zero-parameter
|
|
166
|
-
closure over page globals works; native, bound, parameterised and method-shorthand closures are
|
|
167
|
-
refused statically; a missing binding comes back named from the page's own `ReferenceError`.
|
|
168
|
-
|
|
169
|
-
**`update()` still refuses** — a second build under a new build id is a SERVER fact no page port
|
|
170
|
-
can speak for. `offline()`/`online()` forward to `E2eBrowserPage.offline`.
|
|
162
|
+
How the raw-CDP driver behaves — the pipe, the load event, deadlines, the lossy `evaluate`, the
|
|
163
|
+
refusing `update()` — is [`docs/history/testing.md`](../../docs/history/testing.md#the-e2e-driver-in-brief-moved-2026-10-01).
|
|
171
164
|
|
|
172
165
|
The reasoning behind every rule above, verbatim, is [`docs/history/testing.md`](../../docs/history/testing.md).
|
|
173
166
|
|
package/README.md
CHANGED
|
@@ -15,8 +15,14 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
|
|
|
15
15
|
| `factory-registry.ts` | `factoriesFor(registry)` — one factory per entity, defaults read off column names |
|
|
16
16
|
| `factory-persist.ts` | `usePersister` — the one seam `create()` writes through |
|
|
17
17
|
| `shared-examples.ts` | `sharedExamples` / `behavesLike` — one rule, many subjects |
|
|
18
|
-
| `test-types.ts` | the six test types and their helpers |
|
|
19
|
-
| `
|
|
18
|
+
| `test-types.ts` | the six test types and their helpers — four of them take the fixture bag, as `test` does |
|
|
19
|
+
| `auth-request.ts` | `authRequest()` — the two arguments an app's authenticator is handed, built for real |
|
|
20
|
+
| `island-state-mount.ts` | `describeIslandState()` / `mountIslandState()` — one declared island state, mounted, with the setup and teardown a mount owes |
|
|
21
|
+
| `matchers.ts` | `toBeUltimateError` `toDenyPolicy` `toEmitSteps` `toMatchOpenApi` `toBeWithinBudget` `toRejectInput` `toAcceptInput` `toEqualRow` `toBeVisible` |
|
|
22
|
+
| `matcher-row.ts` | `toEqualRow` — equality over every OWN property, so a `.sealed()` column (not enumerable on a repository row) is compared; a server-only difference is named, never printed |
|
|
23
|
+
| `test-seal-key.ts` | the throwaway master key a test process seals under when the app has none — installed by the preload, handed to the app `startE2eApp` spawns, exported from nowhere |
|
|
24
|
+
| `local-tx.ts` | `memoryLocalTx()` — the client store a mutator's `local()` half writes into under test, with the record store's own rules |
|
|
25
|
+
| `render-view.ts` | `renderView()` / `renderRoute()` — what the server renders, for a unit test: one component, or a whole route module with its `load`, `meta` and islands |
|
|
20
26
|
| `retry.ts` | the one retry loop every waiting assertion is built from — a budget, a fixed interval, and an injectable sleep |
|
|
21
27
|
| `fixtures.ts` | the registry + `test('…', ({ clock }) => …)` injection |
|
|
22
28
|
| `fixture-{clock,mail,jobs,network,statements}.ts` | the five fixtures the framework builds in-process |
|
|
@@ -35,6 +41,7 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
|
|
|
35
41
|
| `registry-leak-guard.ts` | fails the run naming the FILE that left a process-global registry dirty, and restores the ones that can be restored at the same boundary |
|
|
36
42
|
| `registry-snapshot.ts` | `captureProcessRegistries()` / `restoreProcessRegistries()` — the locale config, the catalogs, the permission set and the role map, put back as a file inherited them. A module-scope declaration evaluates once per process (`bun test` without `--isolate`, `As of 2026-08`), so a neighbour's `clearPermissions()` is otherwise permanent |
|
|
37
43
|
| `registry-isolation.ts` | `isolateEntityRegistry()` — an empty entity registry, and the process's back after. Its own entry point (`@ultimat3/testing/registry-isolation`), never the barrel: it value-imports `@ultimat3/entity`, and the barrel is what a tier-0 test imports for `expect` |
|
|
44
|
+
| `quiet-logs.ts` | a green run prints the reporter and nothing else: the framework's log lines go to a sink that drops them. `LOG_LEVEL=info bun test <file>` shows them — naming `LOG_LEVEL` is the one escape hatch, and a level above `info` filters what the TERMINAL shows, never what the logger emits (below). A test asserting on log output hands `createLogger({ level, writer })` its own writer, or installs its own `setLogSink` and restores the previous one |
|
|
38
45
|
| `preload.ts` | the bunfig preload that installs all of the above |
|
|
39
46
|
|
|
40
47
|
## Install
|
|
@@ -45,15 +52,30 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
|
|
|
45
52
|
preload = ["@ultimat3/testing/preload"]
|
|
46
53
|
```
|
|
47
54
|
|
|
55
|
+
The preload also sets `ULTIMATE_SECRETS_KEY` to a fixed throwaway key, so a test over a
|
|
56
|
+
`.sealed()` column runs on a fresh clone and in CI, where `.secrets.key` (gitignored) is absent.
|
|
57
|
+
|
|
58
|
+
| Rule | |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Only a test process | `NODE_ENV=test`, which `bun test` sets. A server that imported the preload gets no key |
|
|
61
|
+
| The app's key wins | nothing is installed when the variable is set or `.secrets.key` exists |
|
|
62
|
+
| e2e | `startE2eApp` hands the same key to the reset, the seed and the app it spawns |
|
|
63
|
+
| Not an export | the key is module-private; `installTestSealKey` is not on the barrel |
|
|
64
|
+
|
|
48
65
|
## Fixtures
|
|
49
66
|
|
|
50
|
-
`test` from this package
|
|
51
|
-
|
|
67
|
+
`test` from this package — and `unitTest`, `contractTest`, `liveTest`, `jobTest` — passes a
|
|
68
|
+
fixture bag as the first argument, and builds only what the body destructures: a test that never
|
|
69
|
+
names `runJobs` never starts a queue, and one that destructures nothing builds nothing.
|
|
52
70
|
|
|
53
71
|
```ts
|
|
54
|
-
import {
|
|
72
|
+
import type { JobHandle } from '@ultimat3/jobs';
|
|
73
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
declare const onboardOrg: JobHandle<{ orgId: string }>; // yours
|
|
76
|
+
declare const orgId: string;
|
|
77
|
+
|
|
78
|
+
unitTest('the three-day sleep releases the worker', async ({ clock, runJobs }) => {
|
|
57
79
|
await runJobs(onboardOrg, { orgId });
|
|
58
80
|
expect(await runJobs.inFlight()).toBe(0); // suspended, not waiting
|
|
59
81
|
clock.advance('3d');
|
|
@@ -61,12 +83,15 @@ test('the three-day sleep releases the worker', async ({ clock, runJobs }) => {
|
|
|
61
83
|
});
|
|
62
84
|
```
|
|
63
85
|
|
|
86
|
+
One body shape for all five (`FixtureBody`), so a job body under the `unit` step is `unitTest`
|
|
87
|
+
with `{ runJobs }` — never `describe(testName('unit', …))` around a bare `test` to reach a fixture.
|
|
88
|
+
|
|
64
89
|
| Fixture | Is | Built by |
|
|
65
90
|
|---|---|---|
|
|
66
91
|
| `clock` | `now()` · `advance('3d')` · `set(instant)` on the frozen clock | the preload |
|
|
67
92
|
| `mail` | `outbox()` · `lastTo(address)` · `failOnce(mail)` over an in-memory transport | the preload |
|
|
68
93
|
| `network` | `offline()` · `drop()` · `online()` · `state()` over the sealed network | the preload |
|
|
69
|
-
| `runJobs` | a worker: call it to enqueue+drain, then `drain()` `due()` `inFlight()` `depth()` | the preload |
|
|
94
|
+
| `runJobs` | a worker: call it to enqueue+drain, then `enqueue()` `drain()` `due()` `inFlight()` `depth()` — see [below](#a-job-under-runjobs) | the preload |
|
|
70
95
|
| `statements` | every statement the test issued: `all()` `count(fingerprint?)` `shapes()` — and an N+1 throws | the preload |
|
|
71
96
|
| `page` | the browser: `goto` `gotoStreamed` `getByRole` `evaluate` `waitForServiceWorker` | a browser driver |
|
|
72
97
|
| `budget` | `jsBytes(route)` measured off the built output | a browser driver |
|
|
@@ -103,6 +128,41 @@ that *is* registered — never `undefined is not an object` from inside the body
|
|
|
103
128
|
registered but has no driver fails with `X_TEST_FIXTURE_UNAVAILABLE` instead; the two are different
|
|
104
129
|
instructions, so they are different codes.
|
|
105
130
|
|
|
131
|
+
## A job under `runJobs`
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { serviceActor } from '@ultimat3/core';
|
|
135
|
+
import type { JobHandle } from '@ultimat3/jobs';
|
|
136
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
137
|
+
|
|
138
|
+
declare const reindexPost: JobHandle<{ id: string; orgId: string }>; // yours
|
|
139
|
+
declare const chargeInvoices: JobHandle<{ orgId: string }>;
|
|
140
|
+
declare const id: string;
|
|
141
|
+
declare const orgId: string;
|
|
142
|
+
|
|
143
|
+
unitTest('a row deleted before the run is skipped, not failed', async ({ runJobs }) => {
|
|
144
|
+
const trace = await runJobs(reindexPost, { id, orgId });
|
|
145
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
146
|
+
expect(trace.executions[0]?.result).toEqual({ skipped: true }); // what the BODY returned
|
|
147
|
+
expect(Object.keys(trace.steps)).toEqual(['load']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
unitTest('the sweep runs as the billing worker', async ({ runJobs }) => {
|
|
151
|
+
const worker = serviceActor({ id: 'billing-worker', scopes: ['billing:run'] });
|
|
152
|
+
await runJobs(chargeInvoices, { orgId }, { actor: worker, tenantId: orgId });
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| | |
|
|
157
|
+
|---|---|
|
|
158
|
+
| **`trace.executions[n].result`** | what the body returned, on a completed run — `undefined` for a body that returns nothing, and no such key on a run that failed, suspended or was interrupted. It is `executeJob`'s own (`JobExecution.result`): `{ skipped: true }` and "did the work" are otherwise one trace |
|
|
159
|
+
| **`trace.steps[name]`** | `executions` (a replay from storage does not count), `attempts`, `status` — cumulative for the test |
|
|
160
|
+
| **`{ actor }`** | on the call and on `drain()`: the identity the WORKER runs as — what an app wires through `WorkerOptions.context()`. Its org is replaced by the job's declared tenant, as a real worker's is; `tenant: 'none'` strips it. Absent is core's anonymous actor |
|
|
161
|
+
| **`{ tenantId }`** | on the call and on `enqueue()`: the ENQUEUER's tenant on the row — the limiter's bucket and the dedupe namespace, the half of `handle.as(actor, input)` that reaches the queue. Never the tenant the body runs under: that is the job's own `tenant:` |
|
|
162
|
+
| **one event bus per fixture** | `step.waitForEvent` reads an ambient bus that STORES; the fixture installs a fresh one and hands the process's back, so one test's `publishEvent` cannot resume the next test's run |
|
|
163
|
+
| **a real worker's pass** | each round is `createWorker(...).tick()`: admission, the limiter and keyed `concurrency` (`whenBusy: 'fail'` settles `refused`, `X_JOB_KEY_BUSY`) hold exactly as in production |
|
|
164
|
+
| **a cancel reaches a running body** | the worker renews its lease on the TEST clock — every millisecond of it — and a renewal that misses the row aborts `ctx.signal` with `X_JOB_LEASE_LOST`: start `runJobs.drain()`, cancel the row (`cancelJob(jobDriver(), id)` or the app's own action), `clock.advance(1)`, await the drain. No hand-built worker, no sleep, no wall-clock timer |
|
|
165
|
+
|
|
106
166
|
## An N+1 fails the test it happened in
|
|
107
167
|
|
|
108
168
|
```ts
|
|
@@ -141,6 +201,12 @@ there is no flag on the fixture and no code to silence.
|
|
|
141
201
|
| `e2eTest` | a browser driver; with none registered it SKIPS, and the gate's `e2e` step passes over the skip — ask `hasE2eDriver()` rather than reading that as a pass | `e2e` |
|
|
142
202
|
| `evalTest` | LLM output scoring against a threshold | `eval` |
|
|
143
203
|
|
|
204
|
+
Each takes an optional `{ timeoutMs }` after the body — `unitTest(name, body, { timeoutMs: 30_000 })`,
|
|
205
|
+
and `test` takes the same; `evalTest` reads it off its options — handed to `bun:test` as that
|
|
206
|
+
test's own deadline (`TestOptions`). Absent is Bun's 5 s default. An `E2eDriver` receives it as its
|
|
207
|
+
third argument. `unitTest`, `contractTest`, `liveTest` and `jobTest` hand their body the
|
|
208
|
+
[fixture bag](#fixtures); `e2eTest` hands its own (`E2eFixtures`).
|
|
209
|
+
|
|
144
210
|
**Registering one, `As of 2026-08-25`.** `@ultimat3/cli`'s `installE2eDriver({ page, baseUrl })` is
|
|
145
211
|
the driver that exists — a `PageLike` over `@ultimat3/scraping`'s browser — and an app's test preload
|
|
146
212
|
is what calls it. It returns the undo, and `resetE2eDriver()` is the seam's inverse for anything that
|
|
@@ -258,6 +324,117 @@ core's `markListening()`, so a test may call its own `handle.url()` on a kernel-
|
|
|
258
324
|
the seal fully on. Unsealing (`ULTIMATE_TEST_ALLOW_NET=1`) stays reserved for a deliberate live
|
|
259
325
|
integration — never for a socket test.
|
|
260
326
|
|
|
327
|
+
## Rendering a page or a component
|
|
328
|
+
|
|
329
|
+
A page is a function of props and a route is a module, so a unit test renders either in process —
|
|
330
|
+
no server, no browser, and no cast between Solid's JSX types and the server factory's.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
import type { Actor } from '@ultimat3/core';
|
|
334
|
+
import type { RouteModule } from '@ultimat3/testing';
|
|
335
|
+
import { expect, renderRoute, renderView, unitTest } from '@ultimat3/testing';
|
|
336
|
+
|
|
337
|
+
// Yours: `import { Shell } from '../../shared/shell'` and `import * as page from './page'`.
|
|
338
|
+
declare const Shell: (props: { nav: string; children: string }) => unknown;
|
|
339
|
+
declare const page: RouteModule<{ rows: readonly unknown[] }>;
|
|
340
|
+
declare const viewer: Actor;
|
|
341
|
+
declare const t: (key: string) => string;
|
|
342
|
+
|
|
343
|
+
unitTest('the dashboard counts the posts of the org that is looking', async () => {
|
|
344
|
+
const view = await renderRoute(page, { url: 'https://example.test/dashboard', actor: viewer });
|
|
345
|
+
expect(view.data.rows).toHaveLength(2); // what `load` resolved
|
|
346
|
+
expect(view.meta.title).toBe(t('app.dashboard.title')); // what `meta` answered for that data
|
|
347
|
+
expect(view.text).toContain(t('app.dashboard.tableTitle'));
|
|
348
|
+
expect(view.html).toMatch(/<a href="\/dashboard" aria-current="page"/);
|
|
349
|
+
expect(view.islands.map((island) => island.moduleId)).toEqual(['shared-theme-toggle']);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
unitTest('the shell keeps the page in <main>', async () => {
|
|
353
|
+
const view = await renderView(Shell, { nav: 'dashboard', children: 'the page body' });
|
|
354
|
+
expect(view.html).toMatch(/<main[^>]*>the page body<\/main>/);
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
| | |
|
|
359
|
+
|---|---|
|
|
360
|
+
| **`renderRoute(module, { url?, params?, actor? })`** | the route module passed WHOLE (`import * as page`), so the page is found by the router's own rule (`pageComponentOf`). `load` runs once; `meta` and the page are handed that one object; islands are collected under the route's own `hydrate` |
|
|
361
|
+
| **`renderView(Component, props)`** | one component. `props` is the component's own type — a renamed prop is a compile error in the test. A component that renders an `island()` is refused here: its timing is a route's |
|
|
362
|
+
| **`view.html`** | the markup as the document carries it |
|
|
363
|
+
| **`view.text`** | what a reader sees: tags, `<script>` and `<style>` bodies removed, entities decoded, whitespace collapsed. Compare with `t('<key>')` — markup escapes what the catalog does not |
|
|
364
|
+
| **`actor`** | `load` and the page run inside `runWithContext(createContext({ actor }))`. Without one they run outside any request context, as a static page does |
|
|
365
|
+
| **the file stays `.test.ts`** | no JSX in a test: the registry-leak guard's loader covers `.test.ts` only, and children that need markup are a string |
|
|
366
|
+
|
|
367
|
+
`@ultimat3/render` is imported inside the two functions, so the barrel still loads no renderer.
|
|
368
|
+
|
|
369
|
+
## Testing an authenticator
|
|
370
|
+
|
|
371
|
+
An app's `configureAuthenticator()` function is handed a request and its context. `authRequest`
|
|
372
|
+
builds both for real — a `UltimateRequest` over a `RequestContext` — so the test calls the
|
|
373
|
+
function with no server and no `as unknown as Parameters<…>`.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import type { Authenticator } from '@ultimat3/http';
|
|
377
|
+
import { authRequest, expect, unitTest } from '@ultimat3/testing';
|
|
378
|
+
|
|
379
|
+
declare const authenticate: Authenticator; // yours — the function `configureAuthenticator()` takes
|
|
380
|
+
declare const token: string;
|
|
381
|
+
declare const key: string;
|
|
382
|
+
|
|
383
|
+
unitTest('a session cookie resolves the member it was issued to', async () => {
|
|
384
|
+
const actor = await authenticate(...(await authRequest({ cookies: { session: token } })));
|
|
385
|
+
expect(actor?.id).toBe('ada');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
unitTest('an API key authenticates only under /v1', async () => {
|
|
389
|
+
const [request, ctx] = await authRequest({ url: 'https://example.test/v1/posts', bearer: key });
|
|
390
|
+
expect(await authenticate(request, ctx)).not.toBeNull();
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
| Key | Is |
|
|
395
|
+
|---|---|
|
|
396
|
+
| `cookies` | by name, sent as the ONE `cookie` header a browser sends, values percent-encoded |
|
|
397
|
+
| `bearer` | `Authorization: Bearer <token>` |
|
|
398
|
+
| `headers` | any other header; `cookies` and `bearer` win over the same header spelled here |
|
|
399
|
+
| `url` · `method` | `https://example.test/` and `GET` when absent |
|
|
400
|
+
|
|
401
|
+
The answer is the hook's own parameter list (`Parameters<Authenticator>`), so a parameter the hook
|
|
402
|
+
gains is a compile error in the test. The context is the one the `auth` stage sees: its actor is
|
|
403
|
+
still anonymous, because resolving it is the function under test. `@ultimat3/http` is imported
|
|
404
|
+
inside the function.
|
|
405
|
+
|
|
406
|
+
## Running a mutator's local half
|
|
407
|
+
|
|
408
|
+
`local(tx, input)` is replayed on every rebase, so what a test proves is the row it leaves and that
|
|
409
|
+
a second application leaves the same one. `memoryLocalTx` is the store it writes into — keyed rows,
|
|
410
|
+
with the page record store's rules — so the test holds no hand-rolled table and no cast.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
import type { MemoryLocalTx } from '@ultimat3/testing';
|
|
414
|
+
import { expect, memoryLocalTx, unitTest } from '@ultimat3/testing';
|
|
415
|
+
|
|
416
|
+
type RenameInput = { id: string; orgId: string; title: string };
|
|
417
|
+
declare const renamePost: { local(tx: MemoryLocalTx['tx'], input: RenameInput): void }; // yours
|
|
418
|
+
declare const id: string;
|
|
419
|
+
declare const orgId: string;
|
|
420
|
+
|
|
421
|
+
unitTest('the local twin marks the row pending, and a replay lands in the same place', () => {
|
|
422
|
+
const store = memoryLocalTx({ posts: { [id]: { id, title: 'before', pending: false } } });
|
|
423
|
+
renamePost.local(store.tx, { id, orgId, title: 'after' });
|
|
424
|
+
const once = store.rows('posts');
|
|
425
|
+
expect(once).toEqual({ [id]: { id, title: 'after', pending: true } });
|
|
426
|
+
renamePost.local(store.tx, { id, orgId, title: 'after' });
|
|
427
|
+
expect(store.rows('posts')).toEqual(once);
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
| | |
|
|
432
|
+
|---|---|
|
|
433
|
+
| **seed** | `{ <table>: { <key>: <row> } }`. Copied and frozen: a twin that mutates a row in place throws here rather than corrupting synced truth in a browser |
|
|
434
|
+
| **`update`** | a NO-OP for a key the table does not hold — a twin must not invent a row |
|
|
435
|
+
| **`upsert` / `update`** | an `undefined` field leaves the column alone |
|
|
436
|
+
| **`store.rows(table)`** | what the table holds now, by key; `{}` for a table never seen |
|
|
437
|
+
|
|
261
438
|
## Testing an island
|
|
262
439
|
|
|
263
440
|
An island is the only client-side code Ultimate ships, so it is the only code an app cannot test
|
|
@@ -316,6 +493,52 @@ which surfaced as `document is not defined` thrown into whichever later test hap
|
|
|
316
493
|
running, and as one file's fetch stub receiving POSTs from another file's island. A `mount` that
|
|
317
494
|
returns nothing disposes as it always did.
|
|
318
495
|
|
|
496
|
+
### One block per declared state
|
|
497
|
+
|
|
498
|
+
Every island declares its states (below), so an island test mounts THOSE — the props a picture is
|
|
499
|
+
taken of are the props the test asserts on. `describeIslandState` owns what a mount owes: the
|
|
500
|
+
lookup, the `beforeAll` that builds and mounts, the `afterAll` that disposes.
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { join } from 'node:path';
|
|
504
|
+
import { buildIslands } from '@ultimat3/cli';
|
|
505
|
+
import type { IslandStatesManifest } from '@ultimat3/testing';
|
|
506
|
+
import { describeIslandState, expect, test } from '@ultimat3/testing';
|
|
507
|
+
|
|
508
|
+
declare const counterStates: IslandStatesManifest; // yours — './counter.island.states'
|
|
509
|
+
|
|
510
|
+
const island = {
|
|
511
|
+
build: buildIslands,
|
|
512
|
+
root: join(import.meta.dir, '..', '..', '..'),
|
|
513
|
+
shell: '<span>shell</span>', // what the server rendered; `mount` replaces it
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
describeIslandState(counterStates, 'idle', island, (mounted) => {
|
|
517
|
+
test('a click reaches the DOM through the signal', () => {
|
|
518
|
+
expect(mounted().fire('button', 'click')).toBe(true);
|
|
519
|
+
expect(mounted().text('[data-role="count"]')).toBe('1');
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describeIslandState(counterStates, 'long-label', island, (mounted) => {
|
|
524
|
+
test('the label arrives whole', () => {
|
|
525
|
+
expect(mounted().text('button')).toBe('Abrechnungseinstellungen anzeigen');
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
| | |
|
|
531
|
+
|---|---|
|
|
532
|
+
| **`mounted()`** | an accessor, for `describeApp`'s reason: the block is declared at module scope and the mount exists only once `beforeAll` has run. Outside its block's tests it throws |
|
|
533
|
+
| **one mount per block** | shared by the block's tests, in order — a mount installs a process-global `document`, so two cannot be live at once |
|
|
534
|
+
| **one build per file** | the bundle is kept per `(build, root, island)`; a second state mounts the chunk the first one built. A build that rejects is not kept |
|
|
535
|
+
| **`timeoutMs`** | the mount's deadline, build included. `ISLAND_MOUNT_TIMEOUT_MS` (60 s) when absent — Bun's 5 s default is what a cold runner misses |
|
|
536
|
+
| **`shell` · `globals` · `size`** | `mountIsland`'s own, per block. `file` and `props` are the manifest's and are not options |
|
|
537
|
+
| **an id the manifest does not declare** | `X_TEST_ISLAND_STATE_UNKNOWN`, listing the declared ones |
|
|
538
|
+
| **`mountIslandState(manifest, id, options)`** | the same lookup for ONE test that needs its own mount — a fetch stub per test: `using island = await mountIslandState(states, 'idle', { build, root })` |
|
|
539
|
+
|
|
540
|
+
`mountIsland` stays the call for props no state declares.
|
|
541
|
+
|
|
319
542
|
### Selectors
|
|
320
543
|
|
|
321
544
|
`find`, `all`, `text`, `fire`, `resize`, `scroll` and `observing` take one grammar, `As of
|
|
@@ -486,7 +709,7 @@ fails on a page that simply has not painted yet.
|
|
|
486
709
|
`X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
|
|
487
710
|
`X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK`
|
|
488
711
|
`X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` `X_TEST_ISLAND_SELECTOR_UNSUPPORTED`
|
|
489
|
-
`X_TEST_ISLAND_STATES_EMPTY`
|
|
712
|
+
`X_TEST_ISLAND_STATES_EMPTY` `X_TEST_ISLAND_STATE_UNKNOWN`
|
|
490
713
|
`X_TEST_ISLAND_STATES_NOT_PURE` `X_TEST_ISLAND_STATES_MISSING_FILE` `X_TEST_ISLAND_STATES_UNKNOWN`
|
|
491
714
|
`X_TEST_ISLAND_STATES_AMBIGUOUS` `X_TEST_ISLAND_STATE_ID_INVALID` `X_TEST_ISLAND_STATE_DUPLICATE`
|
|
492
715
|
`X_TEST_ISLAND_STATE_JSON_INVALID` `X_TEST_ISLAND_STATE_CLOCK_INVALID`
|
|
@@ -506,6 +729,22 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
506
729
|
| `RegistryLeakError` | `X_TEST_REGISTRY_LEAK` | `src/errors.ts` |
|
|
507
730
|
| `TestDatabaseUnavailableError` | `X_TEST_DB_UNAVAILABLE` | `src/errors.ts` |
|
|
508
731
|
|
|
732
|
+
## Log lines under test
|
|
733
|
+
|
|
734
|
+
| `LOG_LEVEL` | The terminal shows | A `setLogSink` a test installs receives |
|
|
735
|
+
|---|---|---|
|
|
736
|
+
| unset | nothing — the reporter only | `info` and above |
|
|
737
|
+
| `info` | `info` and above | `info` and above |
|
|
738
|
+
| `debug` · `trace` | that level and above | that level and above — asking for more lines is asking for them everywhere |
|
|
739
|
+
| `warn` · `error` · `fatal` · `silent` | that level and above | **`info` and above, still** |
|
|
740
|
+
|
|
741
|
+
The last row is the rule: a level above `info` filters the TERMINAL, never the process logger.
|
|
742
|
+
Core reads `LOG_LEVEL` once, when it is first imported, so `LOG_LEVEL=error bun test` used to raise
|
|
743
|
+
the logger itself and a test asserting on an `info` audit line collected nothing. `quiet-logs.ts`
|
|
744
|
+
pins the level at `info` for the moment core loads, restores the variable (a spawned child inherits
|
|
745
|
+
what was named), and installs the filter. It is the preload's first import for that reason.
|
|
746
|
+
`As of 2026-10`.
|
|
747
|
+
|
|
509
748
|
## One process, one registry
|
|
510
749
|
|
|
511
750
|
`bun test` runs every file of one invocation in the same process — only `x verify`'s shards pass
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.0.0",
|
|
4
4
|
"description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"src",
|
|
25
25
|
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*-fixture.ts",
|
|
26
27
|
"CLAUDE.md",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
@@ -35,18 +36,19 @@
|
|
|
35
36
|
"test": "bun test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@ultimat3/action": "
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/db": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
47
|
-
"@ultimat3/
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
39
|
+
"@ultimat3/action": "23.0.0",
|
|
40
|
+
"@ultimat3/cache": "23.0.0",
|
|
41
|
+
"@ultimat3/core": "23.0.0",
|
|
42
|
+
"@ultimat3/db": "23.0.0",
|
|
43
|
+
"@ultimat3/entity": "23.0.0",
|
|
44
|
+
"@ultimat3/http": "23.0.0",
|
|
45
|
+
"@ultimat3/i18n": "23.0.0",
|
|
46
|
+
"@ultimat3/jobs": "23.0.0",
|
|
47
|
+
"@ultimat3/mail": "23.0.0",
|
|
48
|
+
"@ultimat3/policy": "23.0.0",
|
|
49
|
+
"@ultimat3/query": "23.0.0",
|
|
50
|
+
"@ultimat3/realtime": "23.0.0",
|
|
51
|
+
"@ultimat3/render": "23.0.0",
|
|
52
|
+
"@ultimat3/time": "23.0.0"
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// `authRequest()` — the two arguments `hooks.authenticate` is handed, built the way the pipeline
|
|
2
|
+
// builds them, so a test of an app's authenticator calls it with no server and no cast.
|
|
3
|
+
// `@ultimat3/http` is imported INSIDE the function: this module is on the `.` barrel, which a
|
|
4
|
+
// tier-0 test imports for `expect` alone.
|
|
5
|
+
|
|
6
|
+
import type { Authenticator } from '@ultimat3/http';
|
|
7
|
+
|
|
8
|
+
/** What an authenticator reads off a request: where it came in, and the credentials it carries. */
|
|
9
|
+
export interface AuthRequestInit {
|
|
10
|
+
/** The URL the request names. `https://example.test/` when absent. */
|
|
11
|
+
readonly url?: string;
|
|
12
|
+
/** `GET` when absent. */
|
|
13
|
+
readonly method?: string;
|
|
14
|
+
/** Any header. `cookies` and `bearer` below win over the same header spelled here. */
|
|
15
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
16
|
+
/** Cookies by name, sent as the ONE `cookie` header a browser sends, values percent-encoded. */
|
|
17
|
+
readonly cookies?: Readonly<Record<string, string>>;
|
|
18
|
+
/** `Authorization: Bearer <token>`. */
|
|
19
|
+
readonly bearer?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_URL = 'https://example.test/';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The authenticator's own parameter list — `await authenticate(...(await authRequest({ cookies })))`
|
|
26
|
+
* — as a real `UltimateRequest` over a real `RequestContext`. Typed off `Authenticator`, so a
|
|
27
|
+
* third parameter on the hook is a compile error here rather than an `undefined` in a test.
|
|
28
|
+
*
|
|
29
|
+
* The context is the one the `auth` stage sees: the actor is still anonymous, because resolving
|
|
30
|
+
* it is the function under test.
|
|
31
|
+
*/
|
|
32
|
+
export async function authRequest(init: AuthRequestInit = {}): Promise<Parameters<Authenticator>> {
|
|
33
|
+
const http = await import('@ultimat3/http');
|
|
34
|
+
const url = new URL(init.url ?? DEFAULT_URL);
|
|
35
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
36
|
+
const headers = new Headers(init.headers);
|
|
37
|
+
const cookies = Object.entries(init.cookies ?? {});
|
|
38
|
+
if (cookies.length > 0) {
|
|
39
|
+
headers.set(
|
|
40
|
+
'cookie',
|
|
41
|
+
cookies.map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; '),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (init.bearer !== undefined) headers.set('authorization', `Bearer ${init.bearer}`);
|
|
45
|
+
const ctx = http.createRequestContext({
|
|
46
|
+
url,
|
|
47
|
+
method,
|
|
48
|
+
role: 'web',
|
|
49
|
+
// One process, one test: the limiter's scope has no default, and nothing here spends a bucket.
|
|
50
|
+
config: http.defineHttpConfig({ rateLimit: { scope: 'process' } }),
|
|
51
|
+
requestHeaders: headers,
|
|
52
|
+
});
|
|
53
|
+
return [new http.UltimateRequest(new Request(url, { method, headers }), ctx), ctx];
|
|
54
|
+
}
|