@ultimat3/testing 21.0.0 → 22.1.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 CHANGED
@@ -14,114 +14,98 @@ is its own entry point and not part of the barrel.
14
14
  |---|---|
15
15
  | No mocks of the DB | clone a template database; `template-db.ts` is the only DB path |
16
16
  | No wall clock | `frozenClock` / `advanceClock`; `Date.now()` is frozen by the preload |
17
- | Frozen ≠ different | `globalThis.Date` becomes a subclass, so `FrozenDate[Symbol.hasInstance]` brands on the `[[DateValue]]` slot — `Date.prototype.getTime.call(value)` throws or it doesn't. Without it `value instanceof Date` is false for every Date the runtime built itself — a `timestamptz` off a Postgres socket, a `structuredClone`, anything from another realm — and the guards that read it fail under test and nowhere else |
17
+ | Frozen ≠ different | `globalThis.Date` becomes a subclass, so `FrozenDate[Symbol.hasInstance]` brands on the `[[DateValue]]` slot — `Date.prototype.getTime.call(value)` throws or it doesn't. |
18
18
  | Slot, not prototype | the brand is cross-realm on purpose: `instanceof RealDate` misses a `node:vm` or worker Date, and `Object.prototype.toString` is spoofable by `Symbol.toStringTag: 'Date'`. Only the slot is both |
19
19
  | No unmocked egress | `sealed-network.ts` patches fetch; a miss is `X_TEST_NETWORK_SEALED` |
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
23
  | No retries | a flake is fixed or deleted the day it flakes; there is no `retry: 3` |
24
- | `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` — so a suite pinning "never throw a bare `Error`" stayed green through exactly the regression it guards (`matchers.test.ts`). Same discriminator `packages/cli/src/output.ts` uses to decide what the terminal shows, and a hostile getter answers `undefined` instead of raising inside the assertion |
25
- | 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. **`.not` waits for the element to GO**: it reads bun's `isNot` (measured — the flag is real but is not in bun's published types) and inverts what it waits FOR, never just the answer. Inverting a single look is a no-op that passes on a page which has not painted yet, which is the failure a retrying assertion exists to remove. `expect(await locator.isVisible()).toBe(true)` is the point-in-time spelling and stays a different assertion |
26
- | 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)` — one free look plus one per whole interval — and it is what a test asserts, because elapsed time is the one thing a frozen clock cannot give it. `retryUntil`'s `sleep` is a DEFAULT PARAMETER for the same reason a `random = Math.random` default is: a test injects a counting stub and measures how many times it looked and slept |
27
- | A FIXED interval, and never a curve | doubling the gap makes the last look land long after the state changed, and the caller's deadline is the contract. There is exactly one backoff curve in the framework (`@ultimat3/core`'s `backoff.ts`) and this is deliberately not a second one. A zero or negative interval is REFUSED: the failure mode is a test that never fails — it hangs, and CI reports a runner timeout with no assertion in it |
28
- | A matcher that may throw a coded error MUST NOT be `async` | measured against Bun 1.4.0: an `async` matcher has any error it throws REPLACED by bun's own `Matcher \`x\` returned a promise that rejected`, so the code, the cause and the fix are gone before a reader sees them. `X_TEST_SCHEMA_EXPECTED` and `X_TEST_JOB_EXPECTED` were declared, registered, titled — and unreachable by any caller for their whole life, because nothing asserted them. The shape is a SYNCHRONOUS prologue that validates the receiver (`assertStandardSchema`, `assertJobDeclaration`, `assertVisibilityProbe`) and a promise returned after it. `matcher-receiver.test.ts` pins all four |
29
- | 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; a wrong value is `result(false, …)` |
30
- | 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. `JSON.stringify` refuses a BigInt and throws on anything cyclic, so `expect(schema).toRejectInput({ n: 1n })` — a schema that DID reject — came back as ``Matcher `toRejectInput` returned a promise that rejected``, with the real answer gone, on the PASSING path. Three matchers quoted their input that way. `renderCauseValue` (`@ultimat3/core`) is the renderer: it quotes what JSON can quote and names the shape of what it cannot, so the message still reads `expected the schema to reject {"id":"ok"}` |
31
- | 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 — the eleven thunks landed with two of them read and took the package from 95.62% to 94.03%. That number is the useful half: `.not` on a `pass: false` never asks for the message, so `expect({ paths: {} }).not.toMatchOpenApi(…)` passes with the type guard deleted, and the same test reads as covering the matcher. `messageOf()` in `matchers.test.ts` awaits the FAILING side and asserts the sentence, whichever way bun delivers it |
24
+ | `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
+ | 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
+ | 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)` |
27
+ | A FIXED interval, and never a curve | doubling the gap makes the last look land long after the state changed, and the caller's deadline is the contract. |
28
+ | A matcher that may throw a coded error MUST NOT be `async` | bun REPLACES an `async` matcher's thrown error with `returned a promise that rejected`, so code, cause and fix are gone. A synchronous prologue validates the receiver, then a promise is returned; `matcher-receiver.test.ts` pins all four |
29
+ | 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
+ | 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
+ | 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
32
  | 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 |
33
- | `test` here is `fixtureTest`, and it takes no timeout | `(name, body)`, nothing else (`fixtures.ts:248`, exported as `test` by `index.ts:107`) — `bunTest` is called with two arguments, so bun's own third one is unreachable and no case can ask for more than the default 5s. Slow work goes in `beforeAll(fn, 60_000)`, which is `bun:test`'s own re-export and does take one; that is where both generated island tests and `examples/dummy/apps/web/app/settings/settings.island.test.ts` build their chunk, once, for every case to share — a Babel pass plus a browser bundle is seconds. A case that needs 60s of its own is work sitting in the wrong place |
33
+ | `test` here is `fixtureTest`, and it takes no timeout | `(name, body)`, nothing else (`fixtures.ts:248`, exported as `test` by `index.ts:107`) |
34
34
  | Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
35
35
  | Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
36
- | e2e without a driver | `e2eTest` becomes `test.skip`, and the gate reports the step GREEN over it — `bun test` exits 0 on a skip and the exit code is the only channel between the step and the child that registers the driver. `hasE2eDriver()` is what a harness asks instead of reading an all-skipped run as a pass. Zero drivers are registered BY DEFAULT and that is the design, not a gap: `@ultimat3/cli`'s `installE2eDriver()` is the one that exists (`packages/cli/src/e2e-driver.ts`), an app's test preload is what calls it, and CI has no browser |
37
- | The seam has an inverse, `As of 2026-08-25` | `resetE2eDriver()`. `useE2eDriver` writes MODULE scope and `bun test` is one process, so a file that installed a browser handed every later file in the run an `e2eTest` that opened a page nobody asked for — `test-types.test.ts` was itself doing it, at module scope, with nothing to undo it. Same shape and same reason as `@ultimat3/scraping`'s `resetScrapeDriver()` |
38
- | 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`). The four left all need a browser or a second build — things the framework genuinely cannot bundle |
39
- | `page` HAS a driver now, and the other three still do not, `As of 2026-08-25` | `installE2eDriver({ page, baseUrl })` registers `page` over its declaration and leaves `budget`, `signIn` and `deploy` refusing, on purpose: byte counts come off a built `dist/`, a sign-in route is the APP's, and a new build id is a fact about the SERVER — a page port can answer for none of the three, and a fixture that silently no-opped would make the assertion after it read as proof |
40
- | `network` is THIS process's fetch, and an e2e page is not in this process | `sealed-network.ts` patches `globalThis.fetch` here; a browser's requests never pass through it. So `network.offline()` in a test that also destructures `page` is a **no-op on the browser** — the app's online page passing an offline test. `E2eFixtures.offline()` is the browser-side spelling, and `As of 2026-08-27` it FORWARDS — through `E2eBrowserPage.offline()` to `ScrapePage.offline()` to `CdpPageLike.setOfflineMode`, which `cdp-port.ts` has declared since #351. This row said the CDP driver refused it "because `CdpPageLike` declares no `setOfflineMode`", and that was untrue in the commit that wrote it; the refusal it describes now fires only for a hand-rolled `E2eBrowserPage` with no `offline()`, and names that. Two words, two mechanisms, and only one of them can put a browser offline |
41
- | `subscribe` is a whole `sync` node | `live-node.ts` assembles what `x dev --role sync` assembles minus the listener — real `LiveQueryRegistry`, real `liveQueryDefinition` bridge, real per-subscriber gate, real cursor — over a socket that is two objects handing each other the JSON a WebSocket would. `live-replicator.ts` feeds it from `@ultimat3/entity`'s `setRowObserver`, which is the change SOURCE a test process never had: PGlite has no walsender and the memory driver no log, so `InMemoryChangeFeed` had nothing upstream of it. The WAL decoder is the only thing substituted; everything downstream of it is production code |
42
- | What `subscribe` does NOT hold | a client store, an offline queue or a rebase log — so `feed.local()` answers `undefined` rather than the server row. A twin reported as applied whether or not a mutator ran is coverage that reads as proof, which is worse than none. That half is `useMutation` / `useMutationQueue` and an e2e |
43
- | Draining is a macrotask yield | the node dispatches `message` into a floating async task, so `settled()` yields with `setImmediate` until the frame count stops moving. Counting microtask turns is a number that is right until someone adds an `await` — the first version drained 32 and read `examples/dummy`'s deeper feed read as "the node answered nothing" |
44
- | A lone subscriber cannot resume | the retained window is the registry's ENTRY and the entry is dropped when its last subscriber goes, so a reconnect with nobody else holding it re-snapshots — correctly. `fixture-subscribe.test.ts` asserts both halves |
45
- | Strict is opt-in by destructuring | `statements` installs the N+1 detector in throw mode for one test. A fixture nobody names is a fixture nobody built, so there is no `strict: true` and no suite-wide switch — and no way to leave it on for the next file |
36
+ | e2e without a driver | `e2eTest` becomes `test.skip`, and the gate reports the step GREEN over it — `bun test` exits 0 on a skip and the exit code is the only channel between the step and the child that registers the driver. |
37
+ | The seam has an inverse | `resetE2eDriver()`. `useE2eDriver` writes MODULE scope and `bun test` is one process, so a file that installs a browser must undo it |
38
+ | 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
+ | `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
+ | `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 |
41
+ | `subscribe` is a whole `sync` node | `live-node.ts` assembles what `x dev --role sync` assembles minus the listener — real `LiveQueryRegistry`, real `liveQueryDefinition` bridge, real per-subscriber gate, real cursor |
42
+ | What `subscribe` does NOT hold | a client store, an offline queue or a rebase log — so `feed.local()` answers `undefined` rather than the server row. |
43
+ | Draining is a macrotask yield | the node dispatches `message` into a floating async task, so `settled()` yields with `setImmediate` until the frame count stops moving. |
44
+ | A lone subscriber cannot resume | the retained window is the registry's ENTRY and the entry is dropped when its last subscriber goes, so a reconnect with nobody else holding it re-snapshots — correctly. |
45
+ | Strict is opt-in by destructuring | `statements` installs the N+1 detector in throw mode for one test. A fixture nobody names is a fixture nobody built, so there is no `strict: true` and no suite-wide switch |
46
46
  | One threshold, one error | `N_PLUS_ONE_THRESHOLD` and `nPlusOne()` are `@ultimat3/entity`'s. A number or a message written here would make a loop that fails a test a different loop from the one `x dev` warns about |
47
- | The unit of work is the test | `x dev`'s ledger tallies per `Ctx` and ignores a statement issued outside a request; this counts every statement from build to disposal, because `posts.findById(id)` in a unit test has no request and is exactly the loop worth catching |
48
- | Throws once per shape | the failing line is the loop's own statement (the seam lets `onStatement` throw for this reason alone). It keeps counting after, so a body that catches the error still reports the whole loop through `shapes()` — the same hole Bullet's `raise` has, named rather than papered over |
47
+ | The unit of work is the test | `x dev`'s ledger tallies per `Ctx` and ignores a statement issued outside a request |
48
+ | Throws once per shape | the failing line is the loop's own statement (the seam lets `onStatement` throw for this reason alone). |
49
49
  | Measure vs judge | `all()` `count()` `shapes()` count `expectedQueryLoop` statements too; only the verdict honours the suppression, so "this page issues two statements" never depends on who declared what |
50
50
  | One seam for drivers | a driver registers over a declaration with `defineFixtures` — merges, last wins. Never a second registration mechanism |
51
51
  | 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 |
52
52
  | Registry hygiene | the fixture registry is process-global; a test that clears it snapshots with `fixtureSnapshot()` and hands it back in `afterAll` |
53
- | 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`). `bun test` is one process, so without it the failure lands on an innocent suite in another package. What a file's MODULE graph declares is its environment; what the file installs after that is its own to undo |
54
- | 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` — a `declareTags()` there read as environment and the run went green. The load handler appends the sample to the file's source instead: after evaluation, before any hook the file registers. It is also the only signal carrying file identity, which `bun:test` hooks do not |
55
- | 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 — the locale config, the catalogs, the permission set and the role map (`registry-snapshot.ts`). A module evaluates once per process, so a later file's own `import` is a cache hit that declares nothing: `clearPermissions()` in one CLI test took `admin:*` from `@ultimat3/admin`'s barrel for the whole run, and a `defineCatalogs()` inside a loaded app narrowed `supported` so `Accept-Language: de-DE` answered `en` in files that never mentioned locales. Nothing restored is reported and nothing reported is restored — a repair followed by a failure over it would be two answers to one question |
56
- | A catalog restore is a MERGE, never a replace, `As of 2026-08-23` | the other three registries are replaced with the snapshot; the catalogs are not. `registerCatalog` has no inverse, so the only thing a file can cost its neighbour is a `resetCatalogs()` — and that is all this repairs. Everything the live registry still holds survives, a key first registered during the file and an override of a framework base string alike, because both are one-time MODULE-scope declarations: `loadApp()` in a test body dynamically imports the app's i18n package after the file's baseline was sampled, so a replace dropped 519 keys nothing could re-add and `t()` answered `⟦brand.name⟧` for the rest of the process (#312, measured: `bun test apps/admin` in `dummy/social-media-clone`, 4 fail → 0). The override half is the same defect with no `⟦…⟧` to show it — the demo app overrides `admin.denied.body`, and reverting it rendered `@ultimat3/i18n`'s own copy. The cost, stated: a file that CLOBBERS an inherited key owns the cleanup, and the cleanup is `resetCatalogs()` in its own `afterAll`, which this repair is built around |
57
- | The same is still true of permissions, and it is MEASURED, `As of 2026-08-23` | permissions, roles and the locale config are still replaced with the file's baseline, so an app's `definePermissions()` reached only by a dynamic `loadApp()` is dropped at that file's boundary exactly as the catalogs were. Reproduced: `bun test apps packages` in `dummy/social-media-clone` — one process, unsharded — leaves 6 `.contract.` cases failing on `knownPermissions()` missing `dashboard:read`, and every one of them passes when its file runs alone. The catalog fix took that run from 16 fail to 6; these are the 6. Not fixed here, and the reason is not that it is a different defect — it is the same one — but that the same union rule applied to permissions leaks every permission `packages/policy/src/permissions.test.ts` declares into every later file, and judging that needs a repo-wide `bun test` this package cannot run for itself. Its own piece of work, not a rider on this one. The `unit` step is green over it because `.contract.` is a different step and it shards |
58
- | Guarded state is boot state | only the two registries whose honest invariant is "clean when the file ends" — `declareTags` and `registerTier` are boot installs. `entity()`, `job()` and `defineRoute()` register at MODULE scope, which is how an app declares itself, so a filled registry there is idiomatic and unREPORTED |
59
- | Filled and CLEARED are different questions | and the row above answers only the first. "Idiomatic to leave filled" is about the leak REPORT; it says nothing about a file that calls `clear*()`/`reset*()` and takes a module-scope declaration away from every file after it, which is what the RESTORE half exists for. The restore covers four registries and roughly nine have a reset export — routes (`clearRoutes`, `@ultimat3/render`), jobs and tasks (`@ultimat3/jobs`), actions and queries (`resetRegistry`), models/prompts/agents (`@ultimat3/ai`), mails (`@ultimat3/mail`). The table in `registry-leak-guard.ts` is the list. **Not closed, `As of 2026-08-23`**, and the reason is the shape rather than the difficulty: `jobs`, `action`, `query` and `mail` publish a lister and a reset but no RESTORE, so each needs the pair `@ultimat3/policy` got (`restorePermissions` / `restoreRoles`) before one line here can use it — and it has to land as one change, because anything less means editing `ProcessRegistrySnapshot` twice |
60
- | An empty registry is a premise you state | a test whose subject is "nothing is declared" — `x db gen` with nothing to generate — calls `isolateEntityRegistry()` and restores in a `finally`. Inheriting it means the test passes until a neighbouring file imports an entity |
61
- | That one helper is off the barrel | `@ultimat3/testing/registry-isolation`, its own entry point. It is the only module here that value-imports `@ultimat3/entity` — the restore is handed back synchronously, so it cannot be a dynamic import inside the call — and a static re-export from `src/index.ts` would load the entity registry into every test that imports this package for `expect` |
62
- | Teardown restores, never uninstalls | `describeApp`/`testApp` capture the seal and the determinism snapshot before booting and put those back — `restoreDeterminism()` in a scope hands the REAL clock and the REAL `fetch` to every later FILE in the process. `captureDeterminism()` / `restoreCapturedDeterminism()` are the pair for any nested install |
63
- | Teardown is a `finally` | an `app.close()` that rejects still reaches `db.drop()` and still restores the process state; the first failure is what the caller sees. A stranded clone is one `ultimate_test_template_wN` leaked per failing run |
64
- | A boot that rejects is its own teardown | `acquireWorkerDatabase`, `seed` or `boot` throwing returns no `BootedHarness`, so no caller can ever reach `close()` — `bootApp` drops the clone it acquired and restores the seal, the allow-list and the clock itself, then rethrows the boot's OWN error. A `drop` that also fails is swallowed: there is no handle left to report a second failure through |
65
- | 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 — on any Postgres that outlives one run the template is found, not created, and skipping it clones the first run's schema forever |
66
- | 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 |
53
+ | 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
+ | 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
+ | 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, `As of 2026-08-23` | the other three registries are replaced with the snapshot; the catalogs are not. `registerCatalog` has no inverse, so the only thing a file can cost its neighbour is a `resetCatalogs()` |
57
+ | 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
+ | 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
+ | Filled and CLEARED are different questions | and the row above answers only the first. "Idiomatic to leave filled" is about the leak REPORT |
60
+ | An empty registry is a premise you state | a test whose subject is "nothing is declared" — `x db gen` with nothing to generate — calls `isolateEntityRegistry()` and restores in a `finally`. |
61
+ | That one helper is off the barrel | `@ultimat3/testing/registry-isolation`, its own entry point. It is the only module here that value-imports `@ultimat3/entity` |
62
+ | Teardown restores, never uninstalls | `describeApp`/`testApp` capture the seal and the determinism snapshot before booting and put those back |
63
+ | Teardown is a `finally` | an `app.close()` that rejects still reaches `db.drop()` and still restores the process state; the first failure is what the caller sees. |
64
+ | A boot that rejects is its own teardown | `acquireWorkerDatabase`, `seed` or `boot` throwing returns no `BootedHarness`, so no caller can ever reach `close()` |
65
+ | 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
+ | 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
67
  | 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 |
68
68
  | 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
69
  | 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
70
  | 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
71
  | Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
72
- | 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. `mountIsland({ build, root, file })` takes the function as a parameter and declares only the two fields it reads — `IslandChunkLike` is `{ file, code }`, so a CSS artifact, a source map or a dev/production flag the bundler grows is invisible here. Moving the bundler down a tier was the alternative and it drags `@babel/core` and `babel-preset-solid` with it, into a package whose whole point is being importable from tier 0 |
73
- | `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited — the shipped runtime chains `import(e).then((m) => m.mount(el, props))` (`packages/render/src/hydrate.ts`) and only marks the element mounted when that settles, so an `async` mount is an ordinary island and `like.island.tsx` already is one. Typed `=> void` and called bare, the fixture returned before an island that opens a queue or a socket had rendered anything, so every assertion after it read an empty wrapper — and worse, the mount RESUMED after `restore()` had taken the fake `document` back out, failing with `document is not defined` inside whichever later test happened to be running. Not a breaking change: `IslandEntry` is module-private and an island module is matched structurally off an `unknown` import, so nothing implements it. |
74
- | Dispose STOPS the island, then restores the globals | `mountIsland` keeps what `mount` resolved to and, when it is a function, calls it in `[Symbol.dispose]` BEFORE `restore()` — the island's disposer clears an interval whose callback reads `document`, or removes a listener from it, and must find the `document` it was made against. Solid's `render` returns a disposer, so `return render(…)` is an island's whole side of it (`X_TEST_ISLAND_NO_MOUNT`'s fix line and `x g island`'s template both say so). Restoring the globals was the whole teardown until 2026-09-07, and an island that polled on an interval kept ticking after the DOM was gone: measured in ai-maxxing as `document is not defined` thrown into whichever later test was running and as one file's fetch stub receiving another file's POSTs. Once — `using` and an `afterAll` that also disposes by hand are both real — and a disposer that throws still hands the globals back in a `finally`; the throw is the test's to see |
75
- | The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. **"Once" is scoped to this job, and said so only from 2026-08-23**: `packages/ui/src/fake-dom.ts` is a second micro-DOM (201 lines, test-only, off that package's barrel) for a different one — focus, `activeElement`, `contains` and a `:not()` selector grammar for keyboard code, none of which parses a `<template>`. It is not a copy to collapse: `ui` is tier 4 and this package is tier 5, so `ui -> testing` is an upward import the boundary check refuses, and the merge would need the shared half moved down to a tier neither grammar belongs in. `bun test` has no DOM and no DOM library may be added; `generate: 'dom'` builds every element from `_$template("<label …>")`, so a stub without a parsed `<template>` cannot run one line of a compiled island. It lived twice, ~200 lines each, in `packages/cli/src/island-bundle.test.ts` and the reference app's island test |
76
- | `style` and `classList` RECORD, `As of 2026-08` | `FakeStyle` is one declaration map behind all four spellings compiled Solid uses on one element: a STATIC entry baked into the template's `style=` attribute, a dynamic one through `setStyleProperty` → `style.setProperty`, a whole-object or string prop through `style()` → `cssText`, and a cleared one through `removeAttribute`. It was `style = {}` until 2026-08-21 — `<Form>`, `<Stack>`, `<Grid>` and `<Container>` each set a CSS custom property, so every one of them died inside `mount` with `e.style.setProperty is not a function`, and `x g resource` emitted a plain `<form>` rather than the design system's. A design-system component kept out of generated code by the limits of a TEST DOUBLE. A no-op `setProperty` would have stopped the crash and left "the component set `--form-gap`" unassertable, which is the same hole one layer down |
77
- | `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. `add` was the only method the stand-in had and is the one Solid never calls |
78
- | A `document` listener is the documentElement's | this DOM has no bubbling at all, so `document.addEventListener` registers on `documentElement` and `fire(mounted.documentElement, 'keydown', …)` drives it through the surface `MountedIsland` already exposes. `@ultimat3/ui`'s Menu, Popover and focus trap each close on an Escape registered on `document` and never on their own node — a no-op here made all three mountable and unclosable. One handler per type, last wins: two components listening for the same event is this design's limit, and `listeners`' value type is public surface a fix would break |
72
+ | 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
+ | `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited |
74
+ | Dispose STOPS the island, then restores the globals | `mountIsland` keeps what `mount` resolved to and, when it is a function, calls it in `[Symbol.dispose]` BEFORE `restore()` |
75
+ | 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
+ | `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
+ | `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
+ | 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
79
  | `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
- | The selector grammar is SMALL and REFUSES, `As of 2026-09-05` | `island-selector.ts`: compounds of tag, `#id`, `.class`, `[attr]`, `[attr="value"]`, joined by a space (descendant) or `>` (child), matched right to left as CSS matches. Until then it was one regex — `[attr="value"]` or a bare tag — and a selector outside it MATCHED NOTHING: `find('[data-role="list"] button')` read as a tag with a space in it and answered `null`, so a virtualized list could be addressed one element at a time or not at all. Outside the grammar is `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` with the offset, never an empty answer. No `,`, no `+`/`~`, no pseudo-classes: each is a CSS engine's worth of edge cases, and the rule above about a DOM library holds |
81
- | The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are plain writable numbers, default 0, and `getBoundingClientRect()` derives from the offset box. This DOM lays nothing out, so any other number is a fact the harness invented — and `undefined`, which they were, turns a windowed list's `Math.ceil(height / row)` into `NaN` rows with no throw near the cause. `size:` on `mountIsland` writes the HOST's box before `mount` (the one element that exists then); `mounted.resize` writes everything the island created, after |
82
- | `ResizeObserver` records and never fires on its own | `island-observers.ts`, one registry PER DOCUMENT. `observe` fires nothing: a browser's initial notification lands at the next rendering opportunity, after `mount` returned, and here that opportunity is `mounted.resize(el, { width, height })` — which writes `client*` and `offset*`, delivers a spec-shaped entry to every observer of THAT element and no other, and answers whether one ran, for `fire`'s reason. `disconnect` leaves the registry, so `mounted.observing(el)` after an island's cleanup is the assertion that its `onCleanup` disconnected. `IntersectionObserver` is the same class of gap and is not modelled — no shipped island reads one |
80
+ | 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 plain writable numbers, default 0, and `getBoundingClientRect()` derives from the offset box. |
82
+ | `ResizeObserver` records and never fires on its own | `island-observers.ts`, one registry PER DOCUMENT. |
83
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, and fails somewhere with no thread back |
84
- | `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. It reads Solid's delegated `$$click` property or an `addEventListener` listener; a compiled island uses one or the other |
85
- | 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 — in a sibling file (`settings.island.states.ts`) that may not import the component, JSX or `solid-js`. Three consumers read that file and only one of them has a browser: the command that takes the pictures (which must know the complete expected list BEFORE a browser exists, or "produced nothing and exited 0" reads as success), the harness page, and `island-states-guard.test.ts`. `assertIslandStatesPure` is the static rule — `X_TEST_ISLAND_STATES_NOT_PURE` — because a module that imports Solid still evaluates perfectly well under Bun, so nothing dynamic can catch it |
86
- | The rule is the RELATIVENESS, not the extension, `As of 2026-08-23` | the scan refused `solid-js` and a specifier ENDING in `.tsx`/`.jsx`, and `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun — so the guard answered PURE for a file that drags Solid into a browser-free process, which is worse than no guard. Every RELATIVE runtime specifier is refused now (`IslandStatesSiblingImportError`), because `./helpers` reaches the component one hop further on and this scanner reads ONE file's text. `.json` is the one exemption: a JSON module has no imports, so it is the single relative target whose graph needs no following. That rule needs no copy of `ISLAND_EXTENSION` either, which is the row below still holding |
87
- | `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` — proved against Bun in `island-states-pure.test.ts`, which writes the pair to disk and asserts the sibling never evaluated, because the whole exemption rests on it. `examples/dummy`'s states file types its props that way and the rule may never refuse it. The other direction is the one that would leave the hole open: `import { type X } from './y'` is emitted as `import {} from './y'` and DOES evaluate `./y`, so an inline modifier is a runtime edge and is refused. Both directions are pinned |
88
- | Unreadable is not pure | a computed specifier — ``import(`./${name}.island`)``, `require(SPEC)` — is refused as `IslandStatesOpaqueImportError` rather than passed. "A file it cannot read is pure" was this scanner's stated totality and it is the same optimism the extensionless case shipped with |
89
- | What the scan does NOT follow, stated rather than silent | a BARE specifier other than `solid-js` (`@ultimat3/ui` re-exports Solid components and is not refused — a package list here would be the drift a list always is), an ABSOLUTE path specifier (`packages/cli`'s own test tree and this package's guard test both import the barrel by absolute path, which is the only way to reach it from a scratch directory with no `node_modules`), and a specifier inside a string LITERAL, which is read as an import. The first two are holes; the third is a false refusal, and the safe direction of the two |
90
- | 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. `jsonFault` is stricter than `JSON.stringify` in exactly the three places that DEGRADE rather than throw: `undefined` disappears, a non-finite number becomes `null`, and a `Date` becomes a string that no longer answers `.getTime()` |
91
- | 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`. A harness that freezes the instant and leaves the ZONE ambient photographs `12:00` on one machine and `14:00` on the next; the review diff then reports a component change that never happened |
92
- | 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 — a typo and an island whose states were never declared are one symptom and two edits. It is the only refusal in the vocabulary: `parseIslandAddress` falls back on an unknown theme instead, because a page that renders an error over a typo turns a mistyped address into a screenshot of the framework |
93
- | 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. `assertIslandFiles(manifests, root)` is separate and belongs to whoever knows a root — the guard test, and the command |
94
- | The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package. A manifest's `name` is therefore the island basename up to its FIRST dot, which needs no copy of that constant — and the shot directory is that name, so two islands sharing a basename are `X_TEST_ISLAND_STATES_AMBIGUOUS` rather than two sets of pictures in one folder |
95
- | The chunk is imported from a temp FILE, `As of 2026-08-21` | `mkdtemp`ed on the FIRST mount and named by the chunk's SHA-256, so an edited island is a different module rather than a cache hit on the same path and no test leaves a `.mjs` behind in the app it just built. It was a `data:` URL until 2026-08-21 and that read better: `bun test --coverage` panics with `range end index N out of range for slice of length 4096` on `import()` of any `data:` module past ~4 kB, and an island chunk is 12-55 kB — so every island test dumped core in the per-package CI job while the root gate stayed green. Measured on Bun 1.4.0; `fixture-island.test.ts` pins the file form as a source rule, because the failure is invisible to a `bun test` without `--coverage` |
96
- | The scratch directory is lazy and removed, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it — left one directory in `/tmp` forever. Created on the first `modulePathFor` and `rmSync`ed from `process.on('exit')`: the handler has to be synchronous, and it is per PROCESS while `MountedIsland`'s `Disposable` is per mount and is never reached by a mount that threw. `fixture-island-cleanup.test.ts` asserts both halves from a CHILD process, which is the only place either is observable |
97
- | Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode` — as the DOM does, and as `reconcileArrays` requires: a `<For>` re-order calls `parentNode.insertBefore(child, ref)` on a child ALREADY in that parent (`solid-js/web`'s `web.js:155`), so an attach that only pushed left the node in BOTH positions and a five-row list reconciled to ten. A removal that left `parentNode` set was the same defect read backwards — `indexOf` answers -1, so the orphan's `nextSibling` was its old parent's FIRST child instead of `null`. `textContent = ''` detaches too; it opens every island's `mount` |
98
- | 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 — and rolls the whole install back if one assignment throws. That rollback is the half with teeth: the install runs BEFORE `mountIsland`'s own `try`, so a getter-only own global among the caller's `globals` used to leave the fake `document` installed for the rest of the process |
99
- | Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. `x verify` DOES shard its parallel test steps, over `ULTIMATE_TEST_WORKER` and one database per worker; `live` and `e2e` stay serial because a replication slot is cluster-scoped and `e2e` has one built `dist/`. Say which command a claim is about |
100
-
101
- ## The frozen instant is screened, and so is the seed — `As of 2026-08-26`
102
-
103
- `installDeterminism({ now })`, `setFrozenClock`, `frozenClock` and `advanceClock` all write ONE
104
- module-level number, and `bun test` is one process: `new Date('yesterday').getTime()` is `NaN`, so
105
- one unreadable instant makes `Date.now()` answer `NaN` and `new Date()` answer `Invalid Date` for
106
- every file after it, where every `expiresAt > Date.now()` in the framework reads false and no
107
- assertion anywhere names the clock. Nothing repairs it either — `NaN + ms` is `NaN`, so
108
- `advanceClock` only carries it forward, which is why that required parameter is screened too. All
109
- four go through one `instantMs`, and `defineIslandStates` already refused an unpinned `now` at
110
- declaration (`isPinnedInstant`) — this is that rule where the clock is actually set.
111
-
112
- **The seed is NOT a bound, and it is screened anyway — measured before deciding.**
113
- `seededRandom` starts at `seed >>> 0`, so `NaN`, `±Infinity`, `0.5`, `-1` and `2 ** 32` all produce
114
- the SAME sequence as `seed: 0`. A non-finite seed therefore does not make a run non-deterministic;
115
- it silently makes it the seed-0 run, and the record of which seed produced it is false — in the
116
- package whose promise is reproducibility. `preload.ts` already screened its own environment read by
117
- hand (`Number.isFinite(seed) ? { seed } : {}`) while `harness.ts` passed an app's `seedValue`
118
- straight through, which is the repair-in-another-file shape. What the screen does NOT claim: `>>>`
119
- is modulo `2 ** 32`, so a seed above that still wraps onto another one.
84
+ | `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
+ | 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, `As of 2026-08-23` | the scan refused `solid-js` and a specifier ENDING in `.tsx`/`.jsx`, and `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun |
87
+ | `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
+ | 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, stated rather than silent | a BARE specifier other than `solid-js`, an ABSOLUTE path specifier, and a specifier inside a string LITERAL (read as an import). The first two are holes; the third is a false refusal, the safe direction |
90
+ | 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
+ | 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
+ | 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
+ | 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
+ | 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
+ | 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, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it |
97
+ | Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode` |
98
+ | 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
+ | Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. |
120
100
 
101
+
102
+ ## The frozen instant and the seed are screened
103
+
104
+ `installDeterminism({ now })`, `setFrozenClock`, `frozenClock` and `advanceClock` go through one
105
+ `instantMs`: an unreadable instant would make `Date.now()` answer `NaN` for every later file. The
106
+ seed is screened too — `seed >>> 0` maps `NaN`, `0.5` and `2 ** 32` onto seed 0 silently.
121
107
  `finiteOption`/`finiteCount` from `@ultimat3/core` are the one form; `determinism-bounds.test.ts`
122
- holds both sides, restores the captured instant after EVERY case, and pins seed `0` as legal.
123
- `retry.ts`'s budget was already screened, and `matcher-visible.ts` passes its `timeout`/`interval`
124
- into that screen rather than growing a second one.
108
+ holds both sides.
125
109
 
126
110
  Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
127
111
 
@@ -133,3 +117,57 @@ import `@ultimat3/core` and nothing else, so they cost a tier-0 test nothing. Th
133
117
  is `examples/dummy/apps/web/app/settings/settings.island.test.ts`; `fixture-island.test.ts` pins
134
118
  this package's own contract against modules written in the idiom `babel-preset-solid` emits, with
135
119
  no bundler, because a test here cannot import one.
120
+
121
+ ## The browser-backed e2e driver (moved here from `@ultimat3/cli` in 22.0.0)
122
+
123
+ It was in `@ultimat3/cli`, on the argument that the adapter joined `testing`'s `PageLike` to
124
+ `@ultimat3/scraping`'s browser and only `cli` could import both. That stopped being true when the
125
+ browser became RAW CDP (below): the driver needs `@ultimat3/core` and Bun, nothing else, so it
126
+ lives beside the `PageLike` it implements. `cli` imports it over the declared `cli -> testing` edge.
127
+
128
+ | File | Job |
129
+ |---|---|
130
+ | `e2e-driver.ts` | `installE2eDriver({ page, baseUrl })` — the ONE call an app's test preload makes. Registers `page` over its declaration and installs the `e2eTest` seam; returns the undo |
131
+ | `e2e-page.ts` | `PageLike` over four members of `ScrapePage`, declared structurally so a test stands one up in six lines |
132
+ | `e2e-locator.ts` | `LocatorLike` — a handle that resolves nothing until asked, one round trip per question |
133
+ | `e2e-selection.ts` | what a locator SELECTS, as data, and the one in-page expression that resolves it |
134
+ | `e2e-evaluate.ts` | the closure→string crossing, which is the only lossy edge in the adapter |
135
+ | `e2e-errors.ts` | one constructor per refusal |
136
+ | `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
137
+ | `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
138
+ | `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
139
+ | `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
140
+ | `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
+ | `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
+ | `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 |
143
+ | `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
+ | `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
+ | `cdp-errors.ts` | one constructor per way the browser half refuses |
146
+
147
+ **Absent by default.** Nothing here runs until `installE2eDriver` is called, so `hasE2eDriver()`
148
+ answers `false` and the gate's `e2e` step refuses rather than passing over a browser it lacks.
149
+ GitHub-hosted `ubuntu-latest` ships Chrome at `/usr/bin/google-chrome`.
150
+
151
+ **Raw CDP over Bun, no dependency.** A launched Chrome is driven over its debugging PIPE
152
+ (`cdp-pipe.ts`); a remote one over Bun's `WebSocket` (`cdpConnect`). `x shot` and the dev MCP
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`.
171
+
172
+ The reasoning behind every rule above, verbatim, is [`docs/history/testing.md`](../../docs/history/testing.md).
173
+
package/README.md CHANGED
@@ -190,7 +190,9 @@ test does not care about; `defineFactory` is for the rows it does.
190
190
  ```ts
191
191
  const anAuthenticatedAction = sharedExamples<Action>('an authenticated action', (subject) => {
192
192
  test('denies an anonymous actor', async () => {
193
- await expect(subject().call(input, { actor: anonymous })).toDenyPolicy();
193
+ // The POLICY is the receiver and the context is the argument — `toDenyPolicy` evaluates it.
194
+ // Awaited: the decision is async, and an un-awaited assertion can never fail the test.
195
+ await expect(subject().policy).toDenyPolicy({ actor: null, input: {} });
194
196
  });
195
197
  });
196
198
 
@@ -490,6 +492,20 @@ fails on a page that simply has not painted yet.
490
492
  `X_TEST_ISLAND_STATE_JSON_INVALID` `X_TEST_ISLAND_STATE_CLOCK_INVALID`
491
493
  `X_TEST_ISLAND_STATE_STUB_INVALID`
492
494
 
495
+ ### Error classes
496
+
497
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
498
+ a job boundary the class is gone and the `code` is what survives — match on that.
499
+
500
+ | Class | Code | Declared in |
501
+ |---|---|---|
502
+ | `FixtureUnavailableError` | `X_TEST_FIXTURE_UNAVAILABLE` | `src/errors.ts` |
503
+ | `NetworkOfflineError` | `X_TEST_NETWORK_OFFLINE` | `src/errors.ts` |
504
+ | `NetworkSealedError` | `X_TEST_NETWORK_SEALED` | `src/errors.ts` |
505
+ | `NondeterministicError` | `X_TEST_NONDETERMINISTIC` | `src/errors.ts` |
506
+ | `RegistryLeakError` | `X_TEST_REGISTRY_LEAK` | `src/errors.ts` |
507
+ | `TestDatabaseUnavailableError` | `X_TEST_DB_UNAVAILABLE` | `src/errors.ts` |
508
+
493
509
  ## One process, one registry
494
510
 
495
511
  `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": "21.0.0",
3
+ "version": "22.1.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",
@@ -16,7 +16,9 @@
16
16
  "exports": {
17
17
  ".": "./src/index.ts",
18
18
  "./preload": "./src/preload.ts",
19
- "./registry-isolation": "./src/registry-isolation.ts"
19
+ "./e2e-preload": "./src/e2e-preload.ts",
20
+ "./registry-isolation": "./src/registry-isolation.ts",
21
+ "./test-types": "./src/test-types.ts"
20
22
  },
21
23
  "files": [
22
24
  "src",
@@ -33,16 +35,16 @@
33
35
  "test": "bun test"
34
36
  },
35
37
  "dependencies": {
36
- "@ultimat3/cache": "21.0.0",
37
- "@ultimat3/core": "21.0.0",
38
- "@ultimat3/db": "21.0.0",
39
- "@ultimat3/entity": "21.0.0",
40
- "@ultimat3/i18n": "21.0.0",
41
- "@ultimat3/jobs": "21.0.0",
42
- "@ultimat3/mail": "21.0.0",
43
- "@ultimat3/policy": "21.0.0",
44
- "@ultimat3/query": "21.0.0",
45
- "@ultimat3/realtime": "21.0.0",
46
- "@ultimat3/time": "21.0.0"
38
+ "@ultimat3/cache": "22.1.0",
39
+ "@ultimat3/core": "22.1.0",
40
+ "@ultimat3/db": "22.1.0",
41
+ "@ultimat3/entity": "22.1.0",
42
+ "@ultimat3/i18n": "22.1.0",
43
+ "@ultimat3/jobs": "22.1.0",
44
+ "@ultimat3/mail": "22.1.0",
45
+ "@ultimat3/policy": "22.1.0",
46
+ "@ultimat3/query": "22.1.0",
47
+ "@ultimat3/realtime": "22.1.0",
48
+ "@ultimat3/time": "22.1.0"
47
49
  }
48
50
  }
@@ -0,0 +1,94 @@
1
+ // One responsibility: compose the halves — find a browser, launch it over its pipe, attach a page —
2
+ // into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
3
+ //
4
+ // **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
5
+ // with no Chrome must not turn the `e2e` step red for a reason unrelated to the change, which is
6
+ // the rule `packages/cli/CLAUDE.md` already states about `x shot`, `x pr` and `x ci`.
7
+ // `openE2eBrowserIfAvailable` is that door; `openE2eBrowser` refuses by name for a caller that has
8
+ // already decided a browser is required.
9
+
10
+ import { finiteCount } from '@ultimat3/core';
11
+ import type { E2eTab } from './cdp-e2e-page';
12
+ import type { E2eSession } from './cdp-e2e-session';
13
+ import { cdpE2eSession } from './cdp-e2e-session';
14
+ import { CdpBrowserMissingError } from './cdp-errors';
15
+ import type { LaunchedBrowser } from './cdp-launch';
16
+ import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
17
+
18
+ /** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
19
+ export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
20
+
21
+ export interface E2eBrowser {
22
+ /** The first tab — what `installE2eDriver({ page })` drives. */
23
+ readonly page: E2eTab;
24
+ /**
25
+ * The browser itself: more tabs in the same profile, init scripts, the offline switch for every
26
+ * page and worker, and the log of every socket and request. What a multi-tab acceptance suite
27
+ * drives, on the same launch as `page` — one harness, never a second one beside the driver.
28
+ */
29
+ readonly session: E2eSession;
30
+ /** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
31
+ close(): void;
32
+ }
33
+
34
+ export interface OpenE2eBrowserOptions {
35
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
36
+ readonly timeoutMs?: number | undefined;
37
+ }
38
+
39
+ /**
40
+ * Screened HERE, before a browser exists, and not where it lands. It becomes three deadlines — the
41
+ * launch, the handshake and every CDP call — and `Number(process.env.E2E_TIMEOUT ?? '')` is `NaN`
42
+ * for an unset variable and is not nullish, so `??` keeps it: a `setTimeout` given `NaN` fires at
43
+ * 1ms in this Bun, which makes every call report `X_CDP_TIMEOUT` against a browser that was
44
+ * answering. A misdiagnosis reported as a test failure is worse than the failure.
45
+ */
46
+ const budget = (options: OpenE2eBrowserOptions): number =>
47
+ finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
48
+
49
+ const compose = (launched: LaunchedBrowser, session: E2eSession, page: E2eTab): E2eBrowser => ({
50
+ page,
51
+ session,
52
+ // The connection first, then the process — `launched.close()` does both, in that order: closing
53
+ // the process out from under an open connection makes every in-flight call report "the browser
54
+ // closed the CDP connection", which is true and useless.
55
+ close: () => launched.close(),
56
+ });
57
+
58
+ /**
59
+ * Launch a browser and attach one page to it. Refuses with `X_CDP_BROWSER_MISSING` when there is
60
+ * nothing to launch — the caller that wants a skip asks `openE2eBrowserIfAvailable` instead.
61
+ */
62
+ export async function openE2eBrowser(options: OpenE2eBrowserOptions = {}): Promise<E2eBrowser> {
63
+ const timeoutMs = budget(options);
64
+ const executable = await findChrome(options.env ?? process.env);
65
+ if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
66
+ return openLaunched(executable, timeoutMs);
67
+ }
68
+
69
+ /** `undefined` when this machine has no browser. Every other failure still throws. */
70
+ export async function openE2eBrowserIfAvailable(
71
+ options: OpenE2eBrowserOptions = {},
72
+ ): Promise<E2eBrowser | undefined> {
73
+ const timeoutMs = budget(options);
74
+ const executable = await findChrome(options.env ?? process.env);
75
+ if (executable === undefined) return undefined;
76
+ return openLaunched(executable, timeoutMs);
77
+ }
78
+
79
+ /**
80
+ * The half both doors share. Each step undoes the ones before it on the way out: a Chrome that
81
+ * launched and then refused the CDP handshake would otherwise be left running, holding its profile
82
+ * directory, for the rest of the test process — one leaked browser per failing suite.
83
+ */
84
+ async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
85
+ const launched = await launchChrome({ executable, timeoutMs });
86
+ const { connection } = launched;
87
+ try {
88
+ const session = await cdpE2eSession({ connection, loadTimeoutMs: timeoutMs });
89
+ return compose(launched, session, await session.newTab());
90
+ } catch (error) {
91
+ launched.close();
92
+ throw error;
93
+ }
94
+ }