@ultimat3/testing 2.0.0 → 4.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 CHANGED
@@ -21,6 +21,7 @@ is its own entry point and not part of the barrel.
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 |
24
25
  | 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 |
25
26
  | Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
26
27
  | Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
@@ -36,6 +37,7 @@ is its own entry point and not part of the barrel.
36
37
  | Registry hygiene | the fixture registry is process-global; a test that clears it snapshots with `fixtureSnapshot()` and hands it back in `afterAll` |
37
38
  | 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 |
38
39
  | 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 |
40
+ | 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 |
39
41
  | 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 unguarded |
40
42
  | 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 |
41
43
  | 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` |
package/README.md CHANGED
@@ -21,7 +21,8 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
21
21
  | `fixture-{clock,mail,jobs,network,statements}.ts` | the five fixtures the framework builds in-process |
22
22
  | `fixture-drivers.ts` | the five it declares but a driver must build — `page` `budget` `signIn` `deploy` `subscribe` |
23
23
  | `framework-fixtures.ts` | registers both sets; the app registers only what it owns |
24
- | `registry-leak-guard.ts` | fails the run naming the FILE that left a process-global registry dirty |
24
+ | `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 |
25
+ | `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 |
25
26
  | `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` |
26
27
  | `preload.ts` | the bunfig preload that installs all of the above |
27
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "2.0.0",
3
+ "version": "4.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",
@@ -33,12 +33,14 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/cache": "2.0.0",
37
- "@ultimat3/core": "2.0.0",
38
- "@ultimat3/db": "2.0.0",
39
- "@ultimat3/entity": "2.0.0",
40
- "@ultimat3/jobs": "2.0.0",
41
- "@ultimat3/mail": "2.0.0",
42
- "@ultimat3/time": "2.0.0"
36
+ "@ultimat3/cache": "4.0.0",
37
+ "@ultimat3/core": "4.0.0",
38
+ "@ultimat3/db": "4.0.0",
39
+ "@ultimat3/entity": "4.0.0",
40
+ "@ultimat3/i18n": "4.0.0",
41
+ "@ultimat3/jobs": "4.0.0",
42
+ "@ultimat3/mail": "4.0.0",
43
+ "@ultimat3/policy": "4.0.0",
44
+ "@ultimat3/time": "4.0.0"
43
45
  }
44
46
  }
package/src/factories.ts CHANGED
@@ -63,7 +63,15 @@ export interface FactoryOptions<TRow, TTraits extends TraitMap<TRow> = TraitMap<
63
63
  /** Values for every column the entity requires; called once per built row. */
64
64
  defaults(index: number, ids: FactoryIds): TRow;
65
65
  readonly traits?: TTraits;
66
- readonly associations?: AssociationMap<TRow>;
66
+ /**
67
+ * `NoInfer`, because `AssociationMap<TRow>` is homomorphic over `keyof TRow` and TypeScript
68
+ * reverse-maps it into an inference candidate: a factory declaring `associations: { orgId }`
69
+ * inferred `TRow = { orgId: string }` and silently dropped every other column from `build()`,
70
+ * `Partial<TRow>` overrides and `Trait<TRow>`. Associations are a SUBSET of the columns by
71
+ * construction, so they can never be a correct source for the row type — `defaults` is, and it
72
+ * is the one member required to name every column.
73
+ */
74
+ readonly associations?: AssociationMap<NoInfer<TRow>>;
67
75
  }
68
76
 
69
77
  export interface Factory<TRow, TTrait extends string = string> {
@@ -117,8 +125,13 @@ export function defineFactory<TRow extends object, TTraits extends TraitMap<TRow
117
125
  number: () => Math.floor(random() * 1_000_000),
118
126
  };
119
127
 
128
+ // `Object.hasOwn`, not `traits[name] === undefined`: the plain read walked the prototype chain,
129
+ // so `f.with('toString')` was ACCEPTED and applied `Object.prototype.toString` — `overridesOf`
130
+ // called it with no receiver, got `"[object Undefined]"` and spread a string, producing a row
131
+ // with 18 numeric columns that `create()` then handed to `persistRow`. `f.with('constructor')`
132
+ // was accepted the same way and applied nothing at all.
120
133
  const assertTrait = (name: string): string => {
121
- if (traits[name] === undefined) {
134
+ if (!Object.hasOwn(traits, name)) {
122
135
  throw new FactoryTraitUnknownError({ table: entity.table, trait: name, declared });
123
136
  }
124
137
  return name;
@@ -21,6 +21,14 @@ export interface TestDeploy {
21
21
  /**
22
22
  * What `query.live(input, { actor })` resolves to, named structurally so `@ultimat3/testing` does
23
23
  * not take a dependency on `@ultimat3/query` for one type. The real `LiveQuery` satisfies it.
24
+ *
25
+ * NOT what `query.as(actor, input)` resolves to, which is a ROW ARRAY — and the difference is
26
+ * invisible until a driver exists. `examples/dummy`'s five `subscribe` tests all call
27
+ * `subscribe(liveFeed.as(actor, input))`, which is `TS2345` against `Subscribe` below and has
28
+ * never been read, because that app's `typecheck` is pinned red in `scripts/lib/gated-apps.ts`.
29
+ * A driver built to satisfy those call sites would have to accept a row array — and a `subscribe`
30
+ * loose enough to do that proves nothing, which is strictly worse than the fixture being
31
+ * unavailable, because it then reads as coverage.
24
32
  */
25
33
  export interface LiveTarget {
26
34
  readonly name: string;
@@ -62,7 +70,9 @@ export const DRIVER_FIXTURE_NEEDS: Readonly<Record<DriverFixtureName, string>> =
62
70
  deploy: 'a second build to switch the running app to',
63
71
  page: 'a browser driving the built app',
64
72
  signIn: 'a browser session against the app’s own sign-in route',
65
- subscribe: 'an in-process replicator feeding the live-query registry',
73
+ subscribe:
74
+ 'an in-process replicator feeding the live-query registry, and a caller that hands it ' +
75
+ 'query.live(input, { actor }) — query.as() resolves to ROWS, which is not a LiveTarget',
66
76
  };
67
77
 
68
78
  /**
package/src/fixtures.ts CHANGED
@@ -160,6 +160,21 @@ export function requestedFixtures(body: (...args: never[]) => unknown): readonly
160
160
 
161
161
  export type FixtureBody = (fixtures: Fixtures) => void | Promise<void>;
162
162
 
163
+ /**
164
+ * What the RUNNER hands a body, which is wider than `Fixtures` and always was: `defineFixtures`
165
+ * accepts a key `Fixtures` does not name — "the app's", per its own docstring — so a body
166
+ * destructuring an app-registered fixture is the primary case, not an edge. `runWithFixtures`
167
+ * built exactly this shape and then asserted it back to `Fixtures` to call the body.
168
+ *
169
+ * `fixtureTest` still takes `FixtureBody`, so nothing an app writes gets looser: the framework
170
+ * keys stay exactly typed, and an app types its OWN keys by augmenting `Fixtures`, which remains
171
+ * the one documented way to do it.
172
+ */
173
+ export type FixtureBag = Fixtures & Readonly<Record<string, unknown>>;
174
+
175
+ /** The runner's body. Looser than `FixtureBody` for the reason `FixtureBag` states. */
176
+ export type FixtureRunBody = (bag: FixtureBag) => unknown;
177
+
163
178
  /**
164
179
  * A fixture that installs process-global state — the ambient job driver, the ambient mail
165
180
  * driver — implements one of the standard disposal symbols to put it back. Bun shares one
@@ -187,8 +202,8 @@ const disposerOf = (value: unknown): (() => PromiseLike<void> | void) | undefine
187
202
  * testing, and it cannot be observed through a registration. Not in the package's public API:
188
203
  * `fixtureTest` stays the one way to write a test with fixtures.
189
204
  */
190
- export async function runWithFixtures(body: FixtureBody): Promise<void> {
191
- const wanted = requestedFixtures(body as (...args: never[]) => unknown);
205
+ export async function runWithFixtures(body: FixtureRunBody): Promise<void> {
206
+ const wanted = requestedFixtures(body);
192
207
  // Partial by construction — only what the body destructured is built. Handed over as the
193
208
  // full `Fixtures` because the keys came from that same body: a key it did not name is a key
194
209
  // it cannot read, so the missing ones are unobservable.
@@ -205,7 +220,7 @@ export async function runWithFixtures(body: FixtureBody): Promise<void> {
205
220
  built.push(value);
206
221
  bag[key] = value;
207
222
  }
208
- await body(bag as Fixtures);
223
+ await body(bag as FixtureBag);
209
224
  } catch (error) {
210
225
  failure = { error };
211
226
  }
package/src/harness.ts CHANGED
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, test } from 'bun:test';
6
6
  import { captureDeterminism, installDeterminism, restoreCapturedDeterminism } from './determinism';
7
7
  import {
8
8
  allowHost,
9
+ captureNetwork,
9
10
  isNetworkSealed,
10
- resetNetwork,
11
+ restoreCapturedNetwork,
11
12
  sealNetwork,
12
13
  unsealNetwork,
13
14
  } from './sealed-network';
@@ -73,6 +74,10 @@ export async function bootApp(
73
74
  // `bun test` is one process. Restore only what this boot actually changed.
74
75
  const determinism = captureDeterminism();
75
76
  const sealedBefore = isNetworkSealed();
77
+ // The gate's own contents, not just whether it was installed. `resetNetwork()` here CLEARED the
78
+ // allow-list, the mocks and the offline state — the one line in this teardown that uninstalled
79
+ // instead of restoring, while everything around it put back what it captured.
80
+ const networkBefore = captureNetwork();
76
81
 
77
82
  // Only when this boot has something of its own to say. A run configured with ULTIMATE_TEST_NOW /
78
83
  // ULTIMATE_TEST_SEED (`preload.ts`) is otherwise reset to the defaults by the first describeApp.
@@ -88,7 +93,7 @@ export async function bootApp(
88
93
  // What `close` puts back, named once: the boot has to run it too, and two copies of this list is
89
94
  // how one of them ends up missing the allow-list.
90
95
  const restoreProcessState = (): void => {
91
- resetNetwork();
96
+ restoreCapturedNetwork(networkBefore);
92
97
  if (!sealedBefore) unsealNetwork();
93
98
  restoreCapturedDeterminism(determinism);
94
99
  };
package/src/index.ts CHANGED
@@ -110,18 +110,22 @@ export { matchersInstalled, recordSteps } from './matchers';
110
110
  // so it gets its own entry point instead.
111
111
  export type { RegistryLeak, RegistrySample } from './registry-leak-guard';
112
112
  export { installRegistryLeakGuard, leakBetween, sampleRegistries } from './registry-leak-guard';
113
- export type { MockRoute, NetworkState } from './sealed-network';
113
+ export type { ProcessRegistrySnapshot } from './registry-snapshot';
114
+ export { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
115
+ export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
114
116
  // `setNetworkState` is deliberately not here: it is the offline gate's one writer, and a test that
115
117
  // called it directly would bypass the `network` fixture's disposal and leave the whole process
116
118
  // offline for every file after it. The fixture is the way to go offline — there is no second one.
117
119
  export {
118
120
  allowHost,
121
+ captureNetwork,
119
122
  isNetworkSealed,
120
123
  mockFetch,
121
124
  mockJson,
122
125
  networkState,
123
126
  requestedUrls,
124
127
  resetNetwork,
128
+ restoreCapturedNetwork,
125
129
  sealNetwork,
126
130
  unsealNetwork,
127
131
  } from './sealed-network';
@@ -0,0 +1,32 @@
1
+ // The custom matchers' declared surface, in ONE place: the `bun:test` augmentation, and the
2
+ // interface `matchers.ts` implements against. Split out of `matchers.ts` because every test
3
+ // program in the repo has to READ this declaration — `tsconfig.tests.json` names this file — and a
4
+ // tier-0 test cannot reach it by importing tier-5 `@ultimat3/testing`.
5
+
6
+ import type { OpenApiLike } from './test-types';
7
+
8
+ /**
9
+ * One entry per `expect.extend` implementation in `matchers.ts`, and `T` is what the matcher
10
+ * answers with. Adding a matcher is one edit here: `matchers.ts` will not compile until it has an
11
+ * implementation whose arguments match, because its `expect.extend` argument is typed from this.
12
+ *
13
+ * A `.ts` and not a `.d.ts`, deliberately: `.gitignore` treats every `.d.ts` under a package's
14
+ * `src` as stale `tsc` emit and drops it, and the one hand-written exception
15
+ * (`packages/ui/src/scss.d.ts`) had to be un-ignored by name. A declaration needs no `.d.ts` to be
16
+ * ambient — `declare module` in any file the program reads is the augmentation.
17
+ */
18
+ export interface UltimateMatchers<T> {
19
+ toBeUltimateError(code?: string): T;
20
+ toDenyPolicy(context: Readonly<Record<string, unknown>>): Promise<T>;
21
+ toEmitSteps(steps: readonly string[]): Promise<T>;
22
+ toMatchOpenApi(committed: OpenApiLike): T;
23
+ toBeWithinBudget(limit: number): T;
24
+ toRejectInput(input: unknown): Promise<T>;
25
+ toAcceptInput(input: unknown): Promise<T>;
26
+ }
27
+
28
+ declare module 'bun:test' {
29
+ // `extends`, not a restatement: bun's own docs give this shape, and it keeps the member list on
30
+ // `UltimateMatchers` where the implementation is checked against it.
31
+ interface Matchers<T> extends UltimateMatchers<T> {}
32
+ }
package/src/matchers.ts CHANGED
@@ -2,10 +2,19 @@
2
2
  // hand, differently, in every test — and a hand-written version of "did this policy deny?" is how
3
3
  // a test ends up asserting on the wrong branch.
4
4
 
5
+ import type { ExpectExtendMatchers } from 'bun:test';
5
6
  import { expect } from 'bun:test';
7
+ import { describeValue, isUltimateError, stringField } from '@ultimat3/core';
6
8
  import { TestJobExpectedError, TestSchemaExpectedError } from './errors';
9
+ import type { UltimateMatchers } from './matcher-surface';
7
10
  import type { OpenApiLike } from './test-types';
8
11
 
12
+ // Re-exported so the EMITTED `matchers.d.ts` still names `./matcher-surface`. A type-only import
13
+ // used by a non-exported const is elided from the declaration output, and with it goes the
14
+ // `bun:test` augmentation for anyone consuming this package through `dist/` — which is every
15
+ // package that reaches it across a project reference.
16
+ export type { UltimateMatchers } from './matcher-surface';
17
+
9
18
  export interface MatcherResult {
10
19
  readonly pass: boolean;
11
20
  message(): string;
@@ -14,10 +23,27 @@ export interface MatcherResult {
14
23
  const isRecord = (value: unknown): value is Record<string, unknown> =>
15
24
  typeof value === 'object' && value !== null;
16
25
 
26
+ /**
27
+ * The Ultimate error code carried by `value`, or `undefined` for anything that is not one.
28
+ *
29
+ * Three parts, not one. "An object with a string `code`" passed a Node `ENOENT` — so a suite
30
+ * pinning "never throw a bare Error" stayed green through exactly the regression it guards. The
31
+ * contract is `X_SCREAMING_SNAKE` + a cause + an executable fix, which is the same two-part
32
+ * discriminator `packages/cli/src/output.ts` uses to decide what the terminal shows.
33
+ *
34
+ * `stringField` and not a bare index: a matcher is asked about a value a test caught, and a
35
+ * throwing getter or a `Proxy` here would raise INSIDE the assertion — replacing the test's real
36
+ * failure with the matcher's.
37
+ */
17
38
  const codeOf = (value: unknown): string | undefined => {
18
- if (!isRecord(value)) return undefined;
19
- const code = value['code'];
20
- return typeof code === 'string' ? code : undefined;
39
+ const code = stringField(value, 'code');
40
+ if (code === undefined || !code.startsWith('X_')) return undefined;
41
+ // A branded error is one by construction; a plain object has to show all three fields, which is
42
+ // what a `{ code, cause, fix }` literal in a test fixture already does.
43
+ if (isUltimateError(value)) return code;
44
+ const complete =
45
+ stringField(value, 'cause') !== undefined && stringField(value, 'fix') !== undefined;
46
+ return complete ? code : undefined;
21
47
  };
22
48
 
23
49
  /** Standard Schema is the validation contract, so every blessed schema exposes `~standard`. */
@@ -142,11 +168,22 @@ function breakingChanges(before: OpenApiLike, after: OpenApiLike): readonly stri
142
168
  return broke;
143
169
  }
144
170
 
145
- expect.extend({
171
+ /**
172
+ * Typed from the declaration rather than inferred from this literal, which is what makes the two
173
+ * halves one thing: a matcher on `UltimateMatchers` with no entry here fails to compile, an entry
174
+ * here that nothing declares is an excess property, and an argument list that drifts from the
175
+ * declared one is a type error at the key that drifted. Inferred — `expect.extend({ … })` — none
176
+ * of the three is visible, and a declared-but-unimplemented matcher fails at runtime in whichever
177
+ * test calls it first.
178
+ */
179
+ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
146
180
  toBeUltimateError(received: unknown, code?: string) {
147
181
  const actual = codeOf(received);
148
182
  if (actual === undefined) {
149
- return result(false, `expected an UltimateError, received ${typeof received}`);
183
+ return result(
184
+ false,
185
+ `expected an UltimateError (an X_ code with a cause and a fix), received ${describeValue(received)}`,
186
+ );
150
187
  }
151
188
  if (code === undefined) return result(true, `expected not to be an UltimateError`);
152
189
  return result(actual === code, `expected error code ${code}, received ${actual}`);
@@ -204,19 +241,9 @@ expect.extend({
204
241
  const rejected = await hasIssues(received, input);
205
242
  return result(!rejected, `expected the schema to accept ${JSON.stringify(input)}`);
206
243
  },
207
- });
244
+ };
208
245
 
209
- declare module 'bun:test' {
210
- interface Matchers<T> {
211
- toBeUltimateError(code?: string): T;
212
- toDenyPolicy(context: Readonly<Record<string, unknown>>): Promise<T>;
213
- toEmitSteps(steps: readonly string[]): Promise<T>;
214
- toMatchOpenApi(committed: OpenApiLike): T;
215
- toBeWithinBudget(limit: number): T;
216
- toRejectInput(input: unknown): Promise<T>;
217
- toAcceptInput(input: unknown): Promise<T>;
218
- }
219
- }
246
+ expect.extend(implementations);
220
247
 
221
248
  /** Imported for its side effect by the preload; exported so a test can be explicit about it. */
222
249
  export const matchersInstalled = true;
@@ -1,19 +1,26 @@
1
- // Cross-file state pollution, caught at the boundary it crosses. `bun test` runs every file of one
2
- // invocation in ONE process — only `--isolate` gives each file its own module registry — so a file
3
- // that leaves a process-global registry dirty changes what every file after it sees, and the
4
- // failure lands on an innocent suite in another package. This names the file that leaked.
1
+ // Cross-file state pollution, caught at the boundary it crosses and — where a registry can be put
2
+ // back — repaired there. `bun test` runs one invocation in ONE process, so a file that leaves a
3
+ // process-global registry dirty changes what every file after it sees and the failure lands on an
4
+ // innocent suite in another package. What is REPORTED and what is RESTORED are disjoint sets.
5
5
 
6
6
  import { afterAll } from 'bun:test';
7
7
  import { knownTags, registeredTiers } from '@ultimat3/cache';
8
8
  import { RegistryLeakError } from './errors';
9
+ import type { ProcessRegistrySnapshot } from './registry-snapshot';
10
+ import { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
9
11
 
10
12
  /**
11
- * What is guarded, and why only these two. Both are BOOT installs — `declareTags` takes the
13
+ * What is REPORTED, and why only these two. Both are BOOT installs — `declareTags` takes the
12
14
  * manifest's entity names, `registerTier` takes `app.config.ts`'s tiers — so "empty again when the
13
15
  * file ends" is the honest invariant for a test. The entity, job, route and permission registries
14
16
  * are not here: `entity()` and `job()` register at module scope, which is how an app declares
15
17
  * itself, so a file that leaves them filled is idiomatic rather than leaky. A test whose subject is
16
18
  * an EMPTY one of those establishes it itself — `isolateEntityRegistry()`.
19
+ *
20
+ * Neither is RESTORED, and that is the same judgement read the other way: `@ultimat3/cache`
21
+ * publishes no un-declare for a tag, so there is nothing to put a tag registry back WITH. The
22
+ * registries that are restored are `registry-snapshot.ts`'s, and none of them is reported —
23
+ * repairing a state and then failing the run over it would be two answers to one question.
17
24
  */
18
25
  export interface RegistrySample {
19
26
  readonly tags: readonly string[];
@@ -87,13 +94,23 @@ export function installRegistryLeakGuard(): void {
87
94
  installed = true;
88
95
 
89
96
  let pending: string | undefined;
90
- let current: { readonly file: string; readonly before: RegistrySample } | undefined;
97
+ let current:
98
+ | {
99
+ readonly file: string;
100
+ readonly before: RegistrySample;
101
+ readonly snapshot: ProcessRegistrySnapshot;
102
+ }
103
+ | undefined;
91
104
  const leaks: RegistryLeak[] = [];
92
105
 
93
106
  const close = (): void => {
94
107
  if (current === undefined) return;
95
108
  const leak = leakBetween(current.file, current.before, sampleRegistries());
96
109
  if (leak !== undefined) leaks.push(leak);
110
+ // The repair, at the only point it is safe: the file is over and the next one has not
111
+ // evaluated yet, so what goes back is exactly what that file inherited — module-scope
112
+ // declarations included, which is the half a plain `resetX()` in a `beforeEach` destroys.
113
+ restoreProcessRegistries(current.snapshot);
97
114
  current = undefined;
98
115
  };
99
116
 
@@ -102,7 +119,11 @@ export function installRegistryLeakGuard(): void {
102
119
  // how an app declares its tags — and everything after this point is the file's own to undo.
103
120
  hookHost[BASELINE_HOOK] = () => {
104
121
  if (pending === undefined) return;
105
- current = { file: pending, before: sampleRegistries() };
122
+ current = {
123
+ file: pending,
124
+ before: sampleRegistries(),
125
+ snapshot: captureProcessRegistries(),
126
+ };
106
127
  pending = undefined;
107
128
  };
108
129
 
@@ -0,0 +1,61 @@
1
+ // The process registries a test file inherits, captured and handed back at the file boundary;
2
+ // `registry-leak-guard.ts` owns WHEN. A snapshot rather than a reset to defaults, because what a
3
+ // module declares at MODULE scope evaluates once per `bun test` process — a neighbour's clear is
4
+ // permanent and there is no second evaluation left to redo it.
5
+
6
+ import type { Catalog, Locale, LocaleConfig } from '@ultimat3/i18n';
7
+ import {
8
+ catalogFor,
9
+ configureLocales,
10
+ localeConfig,
11
+ registerCatalog,
12
+ registeredLocales,
13
+ resetCatalogs,
14
+ } from '@ultimat3/i18n';
15
+ import type { RoleMap } from '@ultimat3/policy';
16
+ import {
17
+ knownPermissions,
18
+ restorePermissions,
19
+ restoreRoles,
20
+ roleDeclarationSites,
21
+ roleDefinitions,
22
+ } from '@ultimat3/policy';
23
+
24
+ /**
25
+ * Every member is captured by value or by a reference its owner replaces rather than mutates
26
+ * (`configureLocales`, `defineRoles` and `registerCatalog` all build a new object and assign it),
27
+ * so a snapshot describes the process at the instant it was taken and no later write reaches it.
28
+ */
29
+ export interface ProcessRegistrySnapshot {
30
+ readonly locales: LocaleConfig;
31
+ readonly catalogs: readonly (readonly [Locale, Catalog])[];
32
+ readonly permissions: readonly string[];
33
+ readonly roles: RoleMap;
34
+ /** Kept beside the map: restoring through `defineRoles()` would rewrite every site. */
35
+ readonly roleSites: Readonly<Record<string, string>>;
36
+ }
37
+
38
+ export function captureProcessRegistries(): ProcessRegistrySnapshot {
39
+ return {
40
+ locales: localeConfig(),
41
+ catalogs: registeredLocales().map((locale) => [locale, catalogFor(locale)] as const),
42
+ permissions: knownPermissions(),
43
+ roles: roleDefinitions(),
44
+ roleSites: roleDeclarationSites(),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Idempotent, and a REPLACE on every registry rather than a merge: a snapshot is the whole truth
50
+ * about the process at capture time, so anything declared since must go as surely as anything
51
+ * cleared since must come back.
52
+ */
53
+ export function restoreProcessRegistries(snapshot: ProcessRegistrySnapshot): void {
54
+ // A full `LocaleConfig`, so the merge `configureLocales` performs replaces all three fields —
55
+ // a partial call can never widen `supported` back.
56
+ configureLocales(snapshot.locales);
57
+ resetCatalogs();
58
+ for (const [locale, catalog] of snapshot.catalogs) registerCatalog(locale, catalog);
59
+ restorePermissions(snapshot.permissions);
60
+ restoreRoles(snapshot.roles, snapshot.roleSites);
61
+ }
@@ -148,3 +148,35 @@ export function resetNetwork(): void {
148
148
  state.seen.length = 0;
149
149
  state.network = 'online';
150
150
  }
151
+
152
+ /** Everything `resetNetwork` clears, as a value — so a nested scope can put it back instead. */
153
+ export interface NetworkSnapshot {
154
+ readonly allowed: readonly string[];
155
+ readonly mocks: readonly MockRoute[];
156
+ readonly seen: readonly string[];
157
+ readonly network: NetworkState;
158
+ }
159
+
160
+ /**
161
+ * Capture before a nested install, restore after — the pair `captureDeterminism` /
162
+ * `restoreCapturedDeterminism` already ships for the clock, and for the same reason. `bun test` is
163
+ * ONE process: a `bootApp` that ended by CLEARING this gate took the outer scope's allow-list, its
164
+ * mocks and its offline state with it, so an outer fixture that was offline came back online
165
+ * because an inner boot finished. Teardown restores; it never uninstalls.
166
+ */
167
+ export const captureNetwork = (): NetworkSnapshot => ({
168
+ allowed: [...state.allowed],
169
+ mocks: [...state.mocks],
170
+ seen: [...state.seen],
171
+ network: state.network,
172
+ });
173
+
174
+ export function restoreCapturedNetwork(snapshot: NetworkSnapshot): void {
175
+ state.allowed.clear();
176
+ for (const host of snapshot.allowed) state.allowed.add(host);
177
+ state.mocks.length = 0;
178
+ state.mocks.push(...snapshot.mocks);
179
+ state.seen.length = 0;
180
+ state.seen.push(...snapshot.seen);
181
+ state.network = snapshot.network;
182
+ }
@@ -3,6 +3,7 @@
3
3
  // transaction-rollback wrapper (which breaks anything that commits), no shared schema with a
4
4
  // `truncate` between tests (which serialises the suite and still leaks sequences).
5
5
 
6
+ import { renderThrowable } from '@ultimat3/core';
6
7
  import { TestDatabaseUnavailableError } from './errors';
7
8
 
8
9
  export type DbKind = 'postgres' | 'pglite';
@@ -169,8 +170,14 @@ export async function acquireWorkerDatabase(
169
170
  };
170
171
  }
171
172
 
172
- const messageOf = (error: unknown): string =>
173
- error instanceof Error ? error.message : String(error);
173
+ /**
174
+ * Core's renderer, not a local `String(error)`. Both reads this feeds are inside a `catch` that
175
+ * owes its caller an answer, and `String(Object.create(null))` THROWS — a driver that rejects with
176
+ * a null-prototype object would have replaced `X_TEST_DATABASE_UNAVAILABLE` with a `TypeError`
177
+ * raised while formatting it. `bun run error-render` cannot see a value laundered through a local
178
+ * helper, which is exactly how this one survived.
179
+ */
180
+ const messageOf = (error: unknown): string => renderThrowable(error);
174
181
 
175
182
  const alreadyExists = (error: unknown): boolean =>
176
183
  messageOf(error).toLowerCase().includes('already exists');