@ultimat3/testing 19.3.1 → 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
@@ -71,6 +71,7 @@ is its own entry point and not part of the barrel.
71
71
  | Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
72
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
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 |
74
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 |
75
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 |
76
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.3.1",
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.3.1",
37
- "@ultimat3/core": "19.3.1",
38
- "@ultimat3/db": "19.3.1",
39
- "@ultimat3/entity": "19.3.1",
40
- "@ultimat3/i18n": "19.3.1",
41
- "@ultimat3/jobs": "19.3.1",
42
- "@ultimat3/mail": "19.3.1",
43
- "@ultimat3/policy": "19.3.1",
44
- "@ultimat3/query": "19.3.1",
45
- "@ultimat3/realtime": "19.3.1",
46
- "@ultimat3/time": "19.3.1"
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