@ultimat3/testing 19.2.0 → 19.3.2

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
@@ -27,6 +27,8 @@ is its own entry point and not part of the barrel.
27
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
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
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 |
30
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 |
31
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 |
32
34
  | Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
@@ -69,6 +71,7 @@ is its own entry point and not part of the barrel.
69
71
  | Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
70
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 |
71
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 |
72
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 |
73
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 |
74
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 |
package/README.md CHANGED
@@ -303,6 +303,17 @@ removed.
303
303
  `island[Symbol.dispose]()` in an `afterAll`. Left installed it hands a fake `document` to every
304
304
  later FILE in the run.
305
305
 
306
+ **Dispose also stops the island** (`As of 2026-09-07`): when `mount` returns a function, that is
307
+ the island's disposer and dispose calls it — before the globals go back, so a disposer that
308
+ removes a listener from `document` or clears an interval whose callback reads it finds the same
309
+ `document` `mount` ran under. Solid's `render` already returns exactly that, so an island's whole
310
+ side of the contract is `return render(() => <Counter {...props} />, el)` — and a disposed root
311
+ runs every `onCleanup` inside it. Until this landed, restoring the globals was the whole of the
312
+ teardown: an island whose `mount` polled on an interval kept ticking after the DOM was gone,
313
+ which surfaced as `document is not defined` thrown into whichever later test happened to be
314
+ running, and as one file's fetch stub receiving POSTs from another file's island. A `mount` that
315
+ returns nothing disposes as it always did.
316
+
306
317
  ### Selectors
307
318
 
308
319
  `find`, `all`, `text`, `fire`, `resize`, `scroll` and `observing` take one grammar, `As of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
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,16 +33,16 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/cache": "19.2.0",
37
- "@ultimat3/core": "19.2.0",
38
- "@ultimat3/db": "19.2.0",
39
- "@ultimat3/entity": "19.2.0",
40
- "@ultimat3/i18n": "19.2.0",
41
- "@ultimat3/jobs": "19.2.0",
42
- "@ultimat3/mail": "19.2.0",
43
- "@ultimat3/policy": "19.2.0",
44
- "@ultimat3/query": "19.2.0",
45
- "@ultimat3/realtime": "19.2.0",
46
- "@ultimat3/time": "19.2.0"
36
+ "@ultimat3/cache": "19.3.2",
37
+ "@ultimat3/core": "19.3.2",
38
+ "@ultimat3/db": "19.3.2",
39
+ "@ultimat3/entity": "19.3.2",
40
+ "@ultimat3/i18n": "19.3.2",
41
+ "@ultimat3/jobs": "19.3.2",
42
+ "@ultimat3/mail": "19.3.2",
43
+ "@ultimat3/policy": "19.3.2",
44
+ "@ultimat3/query": "19.3.2",
45
+ "@ultimat3/realtime": "19.3.2",
46
+ "@ultimat3/time": "19.3.2"
47
47
  }
48
48
  }
package/src/errors.ts CHANGED
@@ -466,7 +466,7 @@ export class IslandMountMissingError extends UltimateError {
466
466
  fix:
467
467
  component === undefined
468
468
  ? `${file} exports nothing a mount could render — x g island <name> --at apps/web/site writes an island whose mount() is already there, and its shape is the one to copy`
469
- : `in ${file} add: import { render } from 'solid-js/web'; export function mount(el: HTMLElement, props: Parameters<typeof ${component}>[0]): void { el.textContent = ''; render(() => <${component} {...props} />, el); }`,
469
+ : `in ${file} add: import { render } from 'solid-js/web'; export function mount(el: HTMLElement, props: Parameters<typeof ${component}>[0]): () => void { el.textContent = ''; return render(() => <${component} {...props} />, el); }`,
470
470
  });
471
471
  }
472
472
  }
@@ -57,6 +57,10 @@ export interface MountIslandOptions {
57
57
  readonly size?: ResizeInput;
58
58
  }
59
59
 
60
+ /**
61
+ * `Disposable`: dispose calls the disposer the island's `mount` returned (Solid's `render` answers
62
+ * one — `return render(…)`), then hands the process its globals back. Both, in that order.
63
+ */
60
64
  export interface MountedIsland extends Disposable {
61
65
  /** The chunk's source, for asserting on what did or did not reach the browser. */
62
66
  readonly code: string;
@@ -101,11 +105,19 @@ export interface MountedIsland extends Disposable {
101
105
  * opens a queue or a socket first is therefore an ordinary island (`like.island.tsx` is `async`),
102
106
  * and a fixture typed `=> void` could only ever call it and walk away. Widening, not narrowing: an
103
107
  * island module is matched structurally off an `unknown` import, so nothing implements this type.
108
+ *
109
+ * What `mount` answers, once awaited, is the island's DISPOSER when it is a function — the one
110
+ * way an island hands back what it started, and Solid's `render` already returns exactly that, so
111
+ * `return render(…)` is the whole of an island's side. The fixture calls it on dispose.
104
112
  */
105
113
  interface IslandEntry {
106
114
  readonly mount: (el: unknown, props: unknown) => unknown;
107
115
  }
108
116
 
117
+ /** The island's disposer, when `mount` answered one — anything else is an island with nothing to stop. */
118
+ const disposerOf = (returned: unknown): (() => void) | undefined =>
119
+ typeof returned === 'function' ? (returned as () => void) : undefined;
120
+
109
121
  /** `unknown` + a check, not a cast: a chunk whose `mount` was renamed is a real authoring mistake
110
122
  * and `entry.mount is not a function` names neither the file nor the export it wanted. */
111
123
  function entryOf(module: unknown, file: string): IslandEntry {
@@ -198,6 +210,13 @@ function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
198
210
  * The island fixture. Everything it installs is process-global, so the result is `Disposable` and
199
211
  * the idiom is `using mounted = await mountIsland(…)` — a mount left installed hands a fake
200
212
  * `document` to every later FILE in the run.
213
+ *
214
+ * Dispose is two things in one order: the island's own disposer first, then the globals back.
215
+ * Restoring the globals was the whole of it until 2026-09-07, so an island whose `mount` started
216
+ * an interval kept ticking after the DOM was gone — measured as `document is not defined` thrown
217
+ * into whichever later test was running, and as one file's fetch stub receiving POSTs from another
218
+ * file's island. The disposer runs while the fake `document` is still installed, because what it
219
+ * clears (a listener on `document`, an interval whose callback reads it) was made against that one.
201
220
  */
202
221
  export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
203
222
  // `only`, because this fixture has always KNOWN which island it wants and asked for all of them
@@ -240,7 +259,21 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
240
259
  // island's `mount` resumed AFTER the `restore()` below had taken the fake `document` back out
241
260
  // — so it failed with `document is not defined` inside whichever later test happened to be
242
261
  // running, with no thread back here.
243
- await entry.mount(el, options.props);
262
+ const unmount = disposerOf(await entry.mount(el, options.props));
263
+ let disposed = false;
264
+ const dispose = (): void => {
265
+ // Once: `using` and an `afterAll` that also disposes by hand are both real, and Solid's own
266
+ // disposer is idempotent while an island's `clearInterval` wrapper need not be.
267
+ if (disposed) return;
268
+ disposed = true;
269
+ try {
270
+ unmount?.();
271
+ } finally {
272
+ // Whatever the disposer did — including throw, which is the test's to see — the process
273
+ // gets its globals back, or the fake `document` reaches every later file in the run.
274
+ restore();
275
+ }
276
+ };
244
277
  const resolve = (target: string | FakeElement | null | undefined): FakeElement | null =>
245
278
  typeof target === 'string' ? el.querySelector(target) : (target ?? null);
246
279
  return {
@@ -271,7 +304,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
271
304
  const node = resolve(target);
272
305
  return node !== null && [...resizeObservers].some((each) => each.targets.has(node));
273
306
  },
274
- [Symbol.dispose]: restore,
307
+ [Symbol.dispose]: dispose,
275
308
  };
276
309
  } catch (error) {
277
310
  // A mount that throws restores the process before it rethrows: the alternative leaves every
package/src/matchers.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ExpectExtendMatchers } from 'bun:test';
6
6
  import { expect } from 'bun:test';
7
- import { describeValue, isUltimateError, stringField } from '@ultimat3/core';
7
+ import { describeValue, isUltimateError, renderCauseValue, stringField } from '@ultimat3/core';
8
8
  import { TestJobExpectedError, TestSchemaExpectedError } from './errors';
9
9
  import type { MatcherResult } from './matcher-result';
10
10
  import type { UltimateMatchers } from './matcher-surface';
@@ -144,10 +144,15 @@ export async function recordSteps(job: unknown, input: unknown = {}): Promise<re
144
144
  return names;
145
145
  }
146
146
 
147
- const result = (pass: boolean, message: string): MatcherResult => ({
148
- pass,
149
- message: () => message,
150
- });
147
+ /**
148
+ * A THUNK, never a built string. `expect.extend` reads `message()` only when the verdict is the
149
+ * wrong one, and building the sentence eagerly made three matchers raise from inside the assertion
150
+ * library on values they were answering correctly: `JSON.stringify` refuses a BigInt and throws on
151
+ * anything cyclic, so `expect(schema).toRejectInput({ n: 1n })` — a schema that DID reject — came
152
+ * back as `Matcher \`toRejectInput\` returned a promise that rejected`, with the real answer gone.
153
+ * Deferred, a value that cannot be rendered costs nothing on the path that passes.
154
+ */
155
+ const result = (pass: boolean, message: () => string): MatcherResult => ({ pass, message });
151
156
 
152
157
  const isOpenApiLike = (value: unknown): value is OpenApiLike =>
153
158
  isRecord(value) &&
@@ -221,11 +226,12 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
221
226
  if (actual === undefined) {
222
227
  return result(
223
228
  false,
224
- `expected an UltimateError (an X_ code with a cause and a fix), received ${describeValue(received)}`,
229
+ () =>
230
+ `expected an UltimateError (an X_ code with a cause and a fix), received ${describeValue(received)}`,
225
231
  );
226
232
  }
227
- if (code === undefined) return result(true, `expected not to be an UltimateError`);
228
- return result(actual === code, `expected error code ${code}, received ${actual}`);
233
+ if (code === undefined) return result(true, () => 'expected not to be an UltimateError');
234
+ return result(actual === code, () => `expected error code ${code}, received ${actual}`);
229
235
  },
230
236
 
231
237
  async toDenyPolicy(received: unknown, context: Readonly<Record<string, unknown>>) {
@@ -233,10 +239,13 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
233
239
  if (allowed === undefined) {
234
240
  return result(
235
241
  false,
236
- 'expected a policy — an object with run() (@ultimat3/policy) or evaluate()',
242
+ () => 'expected a policy — an object with run() (@ultimat3/policy) or evaluate()',
237
243
  );
238
244
  }
239
- return result(!allowed, `expected the policy to deny ${JSON.stringify(context)}`);
245
+ // `renderCauseValue`, never `JSON.stringify`: the context is a value a test authored, and this
246
+ // matcher is asked about policies whose input holds a BigInt id or a back-reference. It quotes
247
+ // what JSON can quote and names the shape of what it cannot, instead of throwing over either.
248
+ return result(!allowed, () => `expected the policy to deny ${renderCauseValue(context)}`);
240
249
  },
241
250
 
242
251
  // Not `async` — see `assertStandardSchema`. The guard is the synchronous prologue; the wait is
@@ -246,7 +255,7 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
246
255
  return recordSteps(job).then((names) =>
247
256
  result(
248
257
  JSON.stringify(names) === JSON.stringify(expected),
249
- `expected steps ${expected.join(' -> ')}, ran ${names.join(' -> ')}`,
258
+ () => `expected steps ${expected.join(' -> ')}, ran ${names.join(' -> ')}`,
250
259
  ),
251
260
  );
252
261
  },
@@ -255,13 +264,15 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
255
264
  if (!isOpenApiLike(received)) {
256
265
  return result(
257
266
  false,
258
- 'expected an OpenAPI document — an object with operations: [{ operationId, required? }]',
267
+ () =>
268
+ 'expected an OpenAPI document — an object with operations: [{ operationId, required? }]',
259
269
  );
260
270
  }
261
271
  const broke = breakingChanges(committed, received);
262
272
  return result(
263
273
  broke.length === 0,
264
- `contract broke: ${broke.join('; ')} — bump the package version or restore the old shape`,
274
+ () =>
275
+ `contract broke: ${broke.join('; ')} — bump the package version or restore the old shape`,
265
276
  );
266
277
  },
267
278
 
@@ -269,23 +280,26 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
269
280
  if (typeof received !== 'number') {
270
281
  return result(
271
282
  false,
272
- `expected a number to compare against the budget, got ${typeof received}`,
283
+ () => `expected a number to compare against the budget, got ${typeof received}`,
273
284
  );
274
285
  }
275
- return result(received <= limit, `expected ${received} to be within the budget of ${limit}`);
286
+ return result(
287
+ received <= limit,
288
+ () => `expected ${received} to be within the budget of ${limit}`,
289
+ );
276
290
  },
277
291
 
278
292
  toRejectInput(received: unknown, input: unknown) {
279
293
  const schema = assertStandardSchema(received);
280
294
  return hasIssues(schema, input).then((rejected) =>
281
- result(rejected, `expected the schema to reject ${JSON.stringify(input)}`),
295
+ result(rejected, () => `expected the schema to reject ${renderCauseValue(input)}`),
282
296
  );
283
297
  },
284
298
 
285
299
  toAcceptInput(received: unknown, input: unknown) {
286
300
  const schema = assertStandardSchema(received);
287
301
  return hasIssues(schema, input).then((rejected) =>
288
- result(!rejected, `expected the schema to accept ${JSON.stringify(input)}`),
302
+ result(!rejected, () => `expected the schema to accept ${renderCauseValue(input)}`),
289
303
  );
290
304
  },
291
305
  };