@ultimat3/testing 3.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 +2 -0
- package/README.md +2 -1
- package/package.json +10 -8
- package/src/factories.ts +15 -2
- package/src/fixture-drivers.ts +11 -1
- package/src/fixtures.ts +18 -3
- package/src/harness.ts +7 -2
- package/src/index.ts +5 -1
- package/src/matcher-surface.ts +32 -0
- package/src/matchers.ts +44 -17
- package/src/registry-leak-guard.ts +28 -7
- package/src/registry-snapshot.ts +61 -0
- package/src/sealed-network.ts +32 -0
- package/src/template-db.ts +9 -2
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": "
|
|
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": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/entity": "
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
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
|
-
|
|
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
|
|
134
|
+
if (!Object.hasOwn(traits, name)) {
|
|
122
135
|
throw new FactoryTraitUnknownError({ table: entity.table, trait: name, declared });
|
|
123
136
|
}
|
|
124
137
|
return name;
|
package/src/fixture-drivers.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
191
|
-
const wanted = requestedFixtures(body
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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:
|
|
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 = {
|
|
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
|
+
}
|
package/src/sealed-network.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/template-db.ts
CHANGED
|
@@ -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
|
-
|
|
173
|
-
|
|
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');
|