@ultimat3/testing 6.0.0 → 8.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
@@ -23,6 +23,7 @@ is its own entry point and not part of the barrel.
23
23
  | No retries | a flake is fixed or deleted the day it flakes; there is no `retry: 3` |
24
24
  | `toBeUltimateError` reads THREE fields | an `X_` code plus a `cause` plus a `fix`, all through core's `stringField`. `typeof value.code === 'string'` alone passed a Node `ENOENT` — so a suite pinning "never throw a bare `Error`" stayed green through exactly the regression it guards (`matchers.test.ts`). Same discriminator `packages/cli/src/output.ts` uses to decide what the terminal shows, and a hostile getter answers `undefined` instead of raising inside the assertion |
25
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 |
26
+ | `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 |
26
27
  | Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
27
28
  | Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
28
29
  | e2e without a driver | `e2eTest` becomes `test.skip`, and the gate reports the step GREEN over it — `bun test` exits 0 on a skip and the exit code is the only channel between the step and the child that registers the driver. `hasE2eDriver()` is what a harness asks instead of reading an all-skipped run as a pass. Zero drivers are registered `As of 2026-08` |
@@ -55,9 +56,27 @@ is its own entry point and not part of the barrel.
55
56
  | One write seam | `usePersister` is the only place `create()` writes. A factory that took a repo argument would put the seam at every call site |
56
57
  | Factory seeds | derived from the table name unless given, so two entities never draw the same uuid stream. `reset()` cascades into associated parents — a half-reset row is worse than none |
57
58
  | Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
59
+ | 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 |
60
+ | The micro-DOM is the fixture's, once | `island-dom.ts`. `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 |
61
+ | `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 |
62
+ | `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 |
63
+ | A `document` listener is the documentElement's | this DOM has no bubbling at all, so `document.addEventListener` registers on `documentElement` and `fire(mounted.documentElement, 'keydown', …)` drives it through the surface `MountedIsland` already exposes. `@ultimat3/ui`'s Menu, Popover and focus trap each close on an Escape registered on `document` and never on their own node — a no-op here made all three mountable and unclosable. One handler per type, last wins: two components listening for the same event is this design's limit, and `listeners`' value type is public surface a fix would break |
64
+ | `querySelector` skips `this` | descendants only, as the DOM's does. Matching the element it is called on made a host `<div>` answer `find('div')` with the container the test built rather than the markup the island rendered |
65
+ | A mount installs process globals | so `MountedIsland` is `Disposable` and a `mount` that THROWS restores before it rethrows. A fake `document` left installed reaches every later FILE in the run, and fails somewhere with no thread back |
66
+ | `fire` answers whether a handler ran | a selector matching nothing and an island that attached no handler are the same silence otherwise — the second is a bug, the first a typo. It reads Solid's delegated `$$click` property or an `addEventListener` listener; a compiled island uses one or the other |
67
+ | The chunk is imported from a temp FILE, `As of 2026-08-21` | `mkdtemp`ed on the FIRST mount and named by the chunk's SHA-256, so an edited island is a different module rather than a cache hit on the same path and no test leaves a `.mjs` behind in the app it just built. It was a `data:` URL until 2026-08-21 and that read better: `bun test --coverage` panics with `range end index N out of range for slice of length 4096` on `import()` of any `data:` module past ~4 kB, and an island chunk is 12-55 kB — so every island test dumped core in the per-package CI job while the root gate stayed green. Measured on Bun 1.4.0; `fixture-island.test.ts` pins the file form as a source rule, because the failure is invisible to a `bun test` without `--coverage` |
68
+ | The scratch directory is lazy and removed, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it — left one directory in `/tmp` forever. Created on the first `modulePathFor` and `rmSync`ed from `process.on('exit')`: the handler has to be synchronous, and it is per PROCESS while `MountedIsland`'s `Disposable` is per mount and is never reached by a mount that threw. `fixture-island-cleanup.test.ts` asserts both halves from a CHILD process, which is the only place either is observable |
69
+ | Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode` — as the DOM does, and as `reconcileArrays` requires: a `<For>` re-order calls `parentNode.insertBefore(child, ref)` on a child ALREADY in that parent (`solid-js/web`'s `web.js:155`), so an attach that only pushed left the node in BOTH positions and a five-row list reconciled to ten. A removal that left `parentNode` set was the same defect read backwards — `indexOf` answers -1, so the orphan's `nextSibling` was its old parent's FIRST child instead of `null`. `textContent = ''` detaches too; it opens every island's `mount` |
70
+ | Globals install all-or-nothing | `installGlobals` saves DESCRIPTORS, not values — a saved value cannot tell "no such global" from "a global holding `undefined`", and the teardown deleted both — and rolls the whole install back if one assignment throws. That rollback is the half with teeth: the install runs BEFORE `mountIsland`'s own `try`, so a getter-only own global among the caller's `globals` used to leave the fake `document` installed for the rest of the process |
58
71
  | Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. `x verify` DOES shard its parallel test steps, over `ULTIMATE_TEST_WORKER` and one database per worker; `live` and `e2e` stay serial because a replication slot is cluster-scoped and `e2e` has one built `dist/`. Say which command a claim is about |
59
72
 
60
73
  Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
61
74
 
62
75
  Entry points: `.` (the API), `./preload` (side effects for bunfig) and `./registry-isolation`
63
76
  (`isolateEntityRegistry()`, kept off `.` because it loads `@ultimat3/entity`).
77
+
78
+ `fixture-island.ts` and `island-dom.ts` are on `.` and import `@ultimat3/core` and nothing else, so
79
+ they cost a tier-0 test nothing. The whole-chain proof — real Babel, real Solid, a real island —
80
+ is `examples/dummy/apps/web/app/settings/settings.island.test.ts`; `fixture-island.test.ts` pins
81
+ this package's own contract against modules written in the idiom `babel-preset-solid` emits, with
82
+ no bundler, because a test here cannot import one.
package/README.md CHANGED
@@ -20,6 +20,8 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
20
20
  | `fixtures.ts` | the registry + `test('…', ({ clock }) => …)` injection |
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
+ | `fixture-island.ts` | `mountIsland()` — build an island, import its chunk, run its `mount`. The BUILDER is a parameter |
24
+ | `island-dom.ts` | the micro-DOM `mountIsland` drives: what compiled Solid touches, and nothing else |
23
25
  | `framework-fixtures.ts` | registers both sets; the app registers only what it owns |
24
26
  | `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
27
  | `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 |
@@ -235,10 +237,52 @@ core's `markListening()`, so a test may call its own `handle.url()` on a kernel-
235
237
  the seal fully on. Unsealing (`ULTIMATE_TEST_ALLOW_NET=1`) stays reserved for a deliberate live
236
238
  integration — never for a socket test.
237
239
 
240
+ ## Testing an island
241
+
242
+ An island is the only client-side code Ultimate ships, so it is the only code an app cannot test
243
+ by calling a function. `mountIsland` builds one with the same bundler `x build` uses, imports the
244
+ emitted chunk the way the hydration runtime does, and runs its `mount` over a DOM small enough to
245
+ read.
246
+
247
+ ```ts
248
+ import { buildIslands } from '@ultimat3/cli';
249
+ import { expect, mountIsland, test } from '@ultimat3/testing';
250
+
251
+ declare const fakeFetch: typeof fetch; // yours — the island's own network, stubbed
252
+
253
+ test('the counter is reactive', async () => {
254
+ using island = await mountIsland({
255
+ build: buildIslands,
256
+ root: import.meta.dir + '/../../..',
257
+ file: 'apps/web/site/counter.island.tsx',
258
+ props: { label: 'count' },
259
+ shell: '<p>0</p>', // what the server rendered; mount must replace it
260
+ globals: { fetch: fakeFetch }, // anything the micro-DOM does not supply
261
+ });
262
+
263
+ expect(island.text('[data-role="count"]')).toBe('count 0');
264
+ expect(island.fire('button', 'click')).toBe(true);
265
+ expect(island.text('[data-role="count"]')).toBe('count 1');
266
+ });
267
+ ```
268
+
269
+ **`build` is a parameter, not an import.** `buildIslands` lives in `@ultimat3/cli`, which is tier 5
270
+ like this package, and the one declared edge between them runs `cli → testing` — so importing it
271
+ here would be a tier violation `bun run boundaries` fails on. The app supplies it, which is one
272
+ line and makes the direction visible instead of hidden.
273
+
274
+ **`fire` answers whether a handler RAN.** A selector that matches nothing and an island that
275
+ attached no handler are the same silence; the second is a bug and the first is a typo in the test.
276
+
277
+ **A mount installs process-global state**, so `MountedIsland` is `Disposable` — `using`, or
278
+ `island[Symbol.dispose]()` in an `afterAll`. Left installed it hands a fake `document` to every
279
+ later FILE in the run.
280
+
238
281
  ## Errors
239
282
 
240
283
  `X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
241
284
  `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK`
285
+ `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT`
242
286
 
243
287
  ## One process, one registry
244
288
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "6.0.0",
3
+ "version": "8.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,16 +33,16 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/cache": "6.0.0",
37
- "@ultimat3/core": "6.0.0",
38
- "@ultimat3/db": "6.0.0",
39
- "@ultimat3/entity": "6.0.0",
40
- "@ultimat3/i18n": "6.0.0",
41
- "@ultimat3/jobs": "6.0.0",
42
- "@ultimat3/mail": "6.0.0",
43
- "@ultimat3/policy": "6.0.0",
44
- "@ultimat3/query": "6.0.0",
45
- "@ultimat3/realtime": "6.0.0",
46
- "@ultimat3/time": "6.0.0"
36
+ "@ultimat3/cache": "8.0.0",
37
+ "@ultimat3/core": "8.0.0",
38
+ "@ultimat3/db": "8.0.0",
39
+ "@ultimat3/entity": "8.0.0",
40
+ "@ultimat3/i18n": "8.0.0",
41
+ "@ultimat3/jobs": "8.0.0",
42
+ "@ultimat3/mail": "8.0.0",
43
+ "@ultimat3/policy": "8.0.0",
44
+ "@ultimat3/query": "8.0.0",
45
+ "@ultimat3/realtime": "8.0.0",
46
+ "@ultimat3/time": "8.0.0"
47
47
  }
48
48
  }
package/src/errors.ts CHANGED
@@ -27,6 +27,8 @@ export const TESTING_ERROR_CODES = [
27
27
  'X_TEST_REGISTRY_LEAK',
28
28
  'X_TEST_LIVE_NODE_EMPTY',
29
29
  'X_TEST_LIVE_NODE_UPGRADE_REFUSED',
30
+ 'X_TEST_ISLAND_NOT_BUILT',
31
+ 'X_TEST_ISLAND_NO_MOUNT',
30
32
  ] as const;
31
33
 
32
34
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -47,6 +49,8 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
47
49
  X_TEST_REGISTRY_LEAK: 'a test file left a process-global registry dirty',
48
50
  X_TEST_LIVE_NODE_EMPTY: 'the in-process sync node has no live query to serve',
49
51
  X_TEST_LIVE_NODE_UPGRADE_REFUSED: 'the in-process sync node refused the connection',
52
+ X_TEST_ISLAND_NOT_BUILT: 'the island build produced no chunk for the file the test named',
53
+ X_TEST_ISLAND_NO_MOUNT: 'an island chunk exports no mount function',
50
54
  };
51
55
 
52
56
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -357,3 +361,83 @@ export class RegistryLeakError extends UltimateError {
357
361
  });
358
362
  }
359
363
  }
364
+
365
+ /** Neither path is controlled by the framework: both arrive from the test's own call. */
366
+ const ISLAND_PLACEHOLDER = '<the island path the cause names>';
367
+
368
+ /**
369
+ * The builder produced no chunk for the file the test named. Listing what it DID build is the
370
+ * whole value: a path relative to the test file instead of to the app root, a typo, and an island
371
+ * outside the discovery glob are one symptom and three different edits, and only the list tells
372
+ * them apart. An empty list is its own answer — the glob found nothing at all under this root.
373
+ */
374
+ export class IslandNotBuiltError extends UltimateError {
375
+ constructor(input: { file: string; root: string; built: readonly string[] }) {
376
+ const file = renderFixLiteral(input.file, ISLAND_PLACEHOLDER);
377
+ super({
378
+ code: 'X_TEST_ISLAND_NOT_BUILT',
379
+ cause:
380
+ input.built.length === 0
381
+ ? `no island was built under ${renderCauseValue(input.root)}, so ${renderCauseValue(input.file)} cannot be mounted`
382
+ : `${renderCauseValue(input.file)} is not in the bundle built from ${renderCauseValue(input.root)}; it built ${input.built.join(', ')}`,
383
+ fix:
384
+ input.built.length === 0
385
+ ? 'x g island <name> --at apps/web/site — then set root to the directory holding apps/'
386
+ : `mountIsland({ build, root, file: ${renderFixLiteral(input.built[0], ISLAND_PLACEHOLDER)} }) — the path is app-root-relative, not relative to the test, and ${file} is not one of them`,
387
+ docs: docsFor('X_TEST_ISLAND_NOT_BUILT'),
388
+ });
389
+ }
390
+ }
391
+
392
+ export const islandNotBuilt = (
393
+ file: string,
394
+ root: string,
395
+ built: readonly string[],
396
+ ): UltimateError => new IslandNotBuiltError({ file, root, built });
397
+
398
+ /** A name that can be written as a JSX tag. `export default` and `export { x as 'a b' }` are both
399
+ * legal ES and neither can — a paste emitting one is a syntax error in the reader's own file. */
400
+ const JSX_NAME = /^[A-Za-z_$][\w$]*$/;
401
+
402
+ /**
403
+ * The export the paste renders. A component first — an initial capital that is not the whole name,
404
+ * which is what separates `Counter` from a `PROPS` constant — and any other usable identifier
405
+ * after it, because a lowercase component still compiles and beats a name the reader must invent.
406
+ */
407
+ const componentOf = (exported: readonly string[]): string | undefined => {
408
+ const usable = exported.filter((name) => JSX_NAME.test(name) && name !== 'default');
409
+ return usable.find((name) => /^[A-Z]/.test(name) && name !== name.toUpperCase()) ?? usable[0];
410
+ };
411
+
412
+ /**
413
+ * The chunk built and imported, and exports no `mount`. Distinct from a build failure: the island
414
+ * compiles, ships and is served — the hydration runtime calls `m.mount(el, props)` and the browser
415
+ * throws on a page that passed every gate. Naming the exports it DOES have is what separates a
416
+ * renamed export from a file that exports only its component.
417
+ *
418
+ * The paste is built from THOSE exports rather than from a sketch. It read
419
+ * `render(() => <Island {...props} />, el)` with `props: Props` until 2026-08-21, and none of
420
+ * `Island`, `Props` or `render` exists in the file the reader was told to paste it into — three
421
+ * compile errors handed out as the remedy for one. Two of them the error already knew the answer
422
+ * to, from its own cause; the third is an import, so the import is in the line.
423
+ */
424
+ export class IslandMountMissingError extends UltimateError {
425
+ constructor(input: { file: string; exported: readonly string[] }) {
426
+ const file = renderFixLiteral(input.file, ISLAND_PLACEHOLDER);
427
+ const component = componentOf(input.exported);
428
+ super({
429
+ code: 'X_TEST_ISLAND_NO_MOUNT',
430
+ cause: `the chunk built from ${renderCauseValue(input.file)} exports ${
431
+ input.exported.length === 0 ? 'nothing' : input.exported.join(', ')
432
+ } — the hydration runtime calls m.mount(el, props)`,
433
+ fix:
434
+ component === undefined
435
+ ? `${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`
436
+ : `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); }`,
437
+ docs: docsFor('X_TEST_ISLAND_NO_MOUNT'),
438
+ });
439
+ }
440
+ }
441
+
442
+ export const islandMountMissing = (file: string, exported: readonly string[]): UltimateError =>
443
+ new IslandMountMissingError({ file, exported });
@@ -0,0 +1,206 @@
1
+ // Build one island, import the chunk the way the hydration runtime does, and run its `mount` over
2
+ // the micro-DOM. The BUILDER arrives as a parameter — `@ultimat3/cli` owns `buildIslands` and both
3
+ // packages are tier 5, so importing it here would be the reverse of the one declared `cli → testing`
4
+ // edge. A structural seam keeps the direction honest and survives the bundler changing underneath.
5
+
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { islandMountMissing, islandNotBuilt } from './errors';
10
+ import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom';
11
+
12
+ /** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS
13
+ * artifact, a source map, a dev/production flag — is invisible here on purpose. */
14
+ export interface IslandChunkLike {
15
+ /** App-root-relative POSIX path of the client entry the chunk was built from. */
16
+ readonly file: string;
17
+ /** The built JavaScript, self-contained: an island chunk imports nothing at runtime. */
18
+ readonly code: string;
19
+ }
20
+
21
+ export interface IslandBundleLike {
22
+ readonly chunks: readonly IslandChunkLike[];
23
+ }
24
+
25
+ /** `buildIslands` from `@ultimat3/cli` satisfies this structurally — no import, no new tier edge. */
26
+ export type IslandBuilder = (root: string) => Promise<IslandBundleLike>;
27
+
28
+ export interface MountIslandOptions {
29
+ readonly build: IslandBuilder;
30
+ /** The app root the builder globs from. */
31
+ readonly root: string;
32
+ /** Which island, app-root-relative. Always named: an app with two islands has no default. */
33
+ readonly file: string;
34
+ /** The JSON the document would carry in `data-x-props`. */
35
+ readonly props?: unknown;
36
+ /** The markup the server rendered inside the island's wrapper, which `mount` must replace. */
37
+ readonly shell?: string;
38
+ /** Anything the micro-DOM does not supply — `fetch` above all. Merged over the DOM globals. */
39
+ readonly globals?: Readonly<Record<string, unknown>>;
40
+ }
41
+
42
+ export interface MountedIsland extends Disposable {
43
+ /** The chunk's source, for asserting on what did or did not reach the browser. */
44
+ readonly code: string;
45
+ /** The host element `mount` was given. */
46
+ readonly el: FakeElement;
47
+ /** The `<html>` this mount wrote to — `documentElement.dataset` is a real island's only door out. */
48
+ readonly documentElement: FakeElement;
49
+ find(selector: string): FakeElement | null;
50
+ all(selector: string): readonly FakeElement[];
51
+ /** `textContent` of the first match, `''` when there is none — an assertion reads a string. */
52
+ text(selector: string): string;
53
+ /**
54
+ * Drive a handler the compiled island attached, delegated (`$$change`) or not. Answers whether
55
+ * one RAN: a selector that matches nothing and an island that attached no handler are the same
56
+ * silence otherwise, and the second is a bug while the first is a typo in the test.
57
+ */
58
+ fire(
59
+ target: string | FakeElement | null | undefined,
60
+ type: string,
61
+ event?: Readonly<Record<string, unknown>>,
62
+ ): boolean;
63
+ }
64
+
65
+ interface IslandEntry {
66
+ readonly mount: (el: unknown, props: unknown) => void;
67
+ }
68
+
69
+ /** `unknown` + a check, not a cast: a chunk whose `mount` was renamed is a real authoring mistake
70
+ * and `entry.mount is not a function` names neither the file nor the export it wanted. */
71
+ function entryOf(module: unknown, file: string): IslandEntry {
72
+ const mount = (module as Record<string, unknown> | null)?.['mount'];
73
+ if (typeof mount !== 'function') {
74
+ throw islandMountMissing(file, Object.keys((module as object | null) ?? {}));
75
+ }
76
+ return { mount: mount as IslandEntry['mount'] };
77
+ }
78
+
79
+ let moduleDir: string | undefined;
80
+
81
+ /**
82
+ * One directory per process, outside the app under test — so no test leaves a `.mjs` behind. Two
83
+ * properties the module-scope `mkdtempSync` it replaces had neither of:
84
+ *
85
+ * LAZY. This module is on the `.` barrel, so importing `@ultimat3/testing` for `expect` alone
86
+ * created a directory — in every test process in the repo, whether or not it ever mounts anything.
87
+ *
88
+ * REMOVED. `exit` and `rmSync`, because an exit handler runs synchronously and nothing else covers
89
+ * every path: `mountIsland` restores and rethrows on a failed mount, so that run never reaches the
90
+ * `Disposable`, and the directory is per PROCESS while the disposable is per mount.
91
+ */
92
+ function moduleDirPath(): string {
93
+ const existing = moduleDir;
94
+ if (existing !== undefined) return existing;
95
+ const created = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
96
+ process.on('exit', () => {
97
+ rmSync(created, { recursive: true, force: true });
98
+ });
99
+ moduleDir = created;
100
+ return created;
101
+ }
102
+
103
+ /**
104
+ * The scratch root, or `undefined` when nothing has been mounted. Read by the leak test, which
105
+ * asserts both halves of the above from a CHILD process — the only place where "this process
106
+ * created no directory" and "the directory is gone afterwards" are both observable.
107
+ */
108
+ export const islandModuleDir = (): string | undefined => moduleDir;
109
+
110
+ /**
111
+ * A temp file named by the chunk's own hash, NOT a `data:` URL. The URL form read better and
112
+ * crashed the coverage reporter: `bun test --coverage` panics with `range end index N out of range
113
+ * for slice of length 4096` on `import()` of any `data:` module whose URL exceeds ~4 kB, and an
114
+ * island chunk is 12-55 kB. Measured on Bun 1.4.0, threshold at a URL length of 4032; without
115
+ * `--coverage` the same import is fine, which is why only the per-package CI job ever saw it.
116
+ *
117
+ * The property the URL form was chosen for survives: the name is derived from the bytes, so an
118
+ * edited island is a different module rather than a cache hit on the same path.
119
+ */
120
+ const modulePathFor = (code: string): string =>
121
+ join(moduleDirPath(), `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
122
+
123
+ /**
124
+ * DESCRIPTORS, not values, and all-or-nothing.
125
+ *
126
+ * A saved value cannot tell "no such global" from "a global holding `undefined`", so a teardown
127
+ * reading one deletes both — and `key in globalThis` flips behind whoever owned it. It also cannot
128
+ * put an accessor back as an accessor.
129
+ *
130
+ * The rollback is the half with teeth. This runs BEFORE `mountIsland`'s own `try`, so an
131
+ * assignment that throws — a getter-only own global among the caller's `globals` — would leave the
132
+ * fake `document` already installed ahead of it for the whole rest of the process, which is the
133
+ * exact leak the `catch` below exists to prevent.
134
+ */
135
+ function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
136
+ const host = globalThis as unknown as Record<string, unknown>;
137
+ const saved = Object.keys(values).map(
138
+ (key) => [key, Object.getOwnPropertyDescriptor(host, key)] as const,
139
+ );
140
+ const restore = (): void => {
141
+ for (const [key, descriptor] of saved) {
142
+ if (descriptor === undefined) delete host[key];
143
+ else Object.defineProperty(host, key, descriptor);
144
+ }
145
+ };
146
+ try {
147
+ // Assignment, not `defineProperty`: a global with a setter is meant to see the write, and
148
+ // `Object.assign` over the lot would report the same failure with nothing rolled back.
149
+ for (const [key, value] of Object.entries(values)) host[key] = value;
150
+ } catch (error) {
151
+ restore();
152
+ throw error;
153
+ }
154
+ return restore;
155
+ }
156
+
157
+ /**
158
+ * The island fixture. Everything it installs is process-global, so the result is `Disposable` and
159
+ * the idiom is `using mounted = await mountIsland(…)` — a mount left installed hands a fake
160
+ * `document` to every later FILE in the run.
161
+ */
162
+ export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
163
+ const bundle = await options.build(options.root);
164
+ const chunk = bundle.chunks.find((each) => each.file === options.file);
165
+ if (chunk === undefined) {
166
+ throw islandNotBuilt(
167
+ options.file,
168
+ options.root,
169
+ bundle.chunks.map((each) => each.file),
170
+ );
171
+ }
172
+
173
+ const { documentElement, globals } = createIslandDocument();
174
+ const restore = installGlobals({ ...globals, ...options.globals });
175
+ try {
176
+ const path = modulePathFor(chunk.code);
177
+ await Bun.write(path, chunk.code);
178
+ const entry = entryOf(await import(path), chunk.file);
179
+ const el = new FakeElement('div');
180
+ if (options.shell !== undefined) {
181
+ for (const child of [...parseHtml(options.shell).children]) el.appendChild(child);
182
+ }
183
+ entry.mount(el, options.props);
184
+ return {
185
+ code: chunk.code,
186
+ el,
187
+ documentElement,
188
+ find: (selector) => el.querySelector(selector),
189
+ all: (selector) => el.querySelectorAll(selector),
190
+ text: (selector) => el.querySelector(selector)?.textContent ?? '',
191
+ fire: (target, type, event) => {
192
+ const node = typeof target === 'string' ? el.querySelector(target) : target;
193
+ const handler = node == null ? undefined : handlerFor(node, type);
194
+ if (handler === undefined) return false;
195
+ handler({ currentTarget: node, target: node, ...event });
196
+ return true;
197
+ },
198
+ [Symbol.dispose]: restore,
199
+ };
200
+ } catch (error) {
201
+ // A mount that throws restores the process before it rethrows: the alternative leaves every
202
+ // later file in the run holding a fake `document`, which fails somewhere with no thread back.
203
+ restore();
204
+ throw error;
205
+ }
206
+ }
package/src/index.ts CHANGED
@@ -83,6 +83,17 @@ export type {
83
83
  TestDeploy,
84
84
  } from './fixture-drivers';
85
85
  export { DRIVER_FIXTURE_NEEDS, driverFixtures, unavailableFixture } from './fixture-drivers';
86
+ // The island fixture. `build` is a PARAMETER — `buildIslands` lives in `@ultimat3/cli`, which is
87
+ // tier 5 like this package and whose one declared edge points the other way, so an app supplies it:
88
+ // `mountIsland({ build: buildIslands, root, file })`.
89
+ export type {
90
+ IslandBuilder,
91
+ IslandBundleLike,
92
+ IslandChunkLike,
93
+ MountedIsland,
94
+ MountIslandOptions,
95
+ } from './fixture-island';
96
+ export { mountIsland } from './fixture-island';
86
97
  export type { JobRunTrace, RunJobs, StepTally } from './fixture-jobs';
87
98
  export { createRunJobs } from './fixture-jobs';
88
99
  export type { MailRef, TestMail } from './fixture-mail';
@@ -102,6 +113,8 @@ export {
102
113
  } from './framework-fixtures';
103
114
  export type { AppHandle, AppOptions, BootedApp } from './harness';
104
115
  export { describeApp, testApp } from './harness';
116
+ // Type-only: the micro-DOM is the fixture's to build, and a test only ever names what it handed back.
117
+ export type { FakeElement, FakeNode, FakeText } from './island-dom';
105
118
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
106
119
  export { createLiveNode } from './live-node';
107
120
  export type { LiveReplicator, LiveReplicatorOptions } from './live-replicator';
@@ -0,0 +1,383 @@
1
+ // A DOM small enough to read, implementing exactly what compiled Solid touches and nothing else.
2
+ // `bun test` ships no DOM and no DOM library may be added, so an island — the only client-side
3
+ // code Ultimate ships — was untestable without one. `generate: 'dom'` builds every element from
4
+ // `_$template("<button …>")`, so a stub without a parsed `<template>` cannot run one line of it.
5
+
6
+ /** `[data-role="preview"]` — the one selector shape a mounted island is queried by. */
7
+ const ATTRIBUTE_SELECTOR = /^\[([\w-]+)="([^"]*)"\]$/;
8
+
9
+ /**
10
+ * A node belongs to ONE parent, and the DOM enforces that by moving it: every attach detaches the
11
+ * node from wherever it was first. Not a nicety — `reconcileArrays` re-orders a `<For>` by calling
12
+ * `parentNode.insertBefore(child, ref)` on a child already in that parent (`solid-js/web`'s
13
+ * `web.js:155`, `:163`, `:184`), so an attach that only pushes leaves the node in BOTH positions
14
+ * and a five-row list becomes ten in an order no assertion can read back.
15
+ */
16
+ function detach(node: FakeNode): void {
17
+ const parent = node.parentNode;
18
+ if (parent === null) return;
19
+ const at = parent.children.indexOf(node);
20
+ if (at >= 0) parent.children.splice(at, 1);
21
+ node.parentNode = null;
22
+ }
23
+
24
+ export class FakeNode {
25
+ readonly nodeType: number = 1;
26
+ children: FakeNode[] = [];
27
+ parentNode: FakeNode | null = null;
28
+ appendChild(child: FakeNode): FakeNode {
29
+ detach(child);
30
+ child.parentNode = this;
31
+ this.children.push(child);
32
+ return child;
33
+ }
34
+ /** `ref === child` is the spec's own no-op, spelled the way the spec spells it — the reference
35
+ * becomes the node's next sibling, so a node asked to precede itself lands back where it was. */
36
+ insertBefore(child: FakeNode, ref: FakeNode | null): FakeNode {
37
+ const target = ref === child ? child.nextSibling : ref;
38
+ detach(child);
39
+ const at = target === null ? -1 : this.children.indexOf(target);
40
+ child.parentNode = this;
41
+ this.children.splice(at < 0 ? this.children.length : at, 0, child);
42
+ return child;
43
+ }
44
+ /** The node that leaves keeps no parent: `reconcileArrays` calls `.remove()` on nodes it has
45
+ * already replaced, and a stale `parentNode` would take a live row out of the list instead. */
46
+ replaceChild(next: FakeNode, prev: FakeNode): FakeNode {
47
+ // The guard comes BEFORE the detach: a call that replaces nothing must move nothing either,
48
+ // and detaching `next` first would take it out of the tree it was in and put it nowhere.
49
+ if (next === prev || this.children.indexOf(prev) < 0) return prev;
50
+ detach(next);
51
+ // Read the index again — detaching `next` out of THIS parent shifts everything after it.
52
+ this.children[this.children.indexOf(prev)] = next;
53
+ next.parentNode = this;
54
+ prev.parentNode = null;
55
+ return prev;
56
+ }
57
+ /** A node that is not a child is left whole — the DOM throws `NotFoundError`, and clearing the
58
+ * parent of a node belonging to someone else would be the worse of the two answers. */
59
+ removeChild(child: FakeNode): FakeNode {
60
+ const at = this.children.indexOf(child);
61
+ if (at < 0) return child;
62
+ this.children.splice(at, 1);
63
+ child.parentNode = null;
64
+ return child;
65
+ }
66
+ remove(): void {
67
+ this.parentNode?.removeChild(this);
68
+ }
69
+ cloneNode(deep?: boolean): FakeNode {
70
+ const copy = new FakeNode();
71
+ if (deep === true) for (const child of this.children) copy.appendChild(child.cloneNode(true));
72
+ return copy;
73
+ }
74
+ get childNodes(): readonly FakeNode[] {
75
+ return this.children;
76
+ }
77
+ get firstChild(): FakeNode | null {
78
+ return this.children[0] ?? null;
79
+ }
80
+ get nextSibling(): FakeNode | null {
81
+ const siblings = this.parentNode?.children ?? [];
82
+ return siblings[siblings.indexOf(this) + 1] ?? null;
83
+ }
84
+ get textContent(): string {
85
+ return this.children.map((child) => child.textContent).join('');
86
+ }
87
+ /** The dropped children are DETACHED, not merely forgotten: `el.textContent = ''` opens every
88
+ * island's `mount` and closes Solid's `render` disposer (`web.js:201`), and a shell node left
89
+ * holding its old parent is a node two trees claim. */
90
+ set textContent(text: string) {
91
+ for (const child of this.children) child.parentNode = null;
92
+ this.children = [];
93
+ if (text !== '') this.appendChild(new FakeText(text));
94
+ }
95
+ }
96
+
97
+ /** `data`, not a private field: Solid updates a text node in place through `node.data = value`,
98
+ * and a stub without it reports a mount that renders once and never re-renders. */
99
+ export class FakeText extends FakeNode {
100
+ override readonly nodeType = 3;
101
+ constructor(public data: string) {
102
+ super();
103
+ }
104
+ override get textContent(): string {
105
+ return this.data;
106
+ }
107
+ override set textContent(text: string) {
108
+ this.data = text;
109
+ }
110
+ override cloneNode(): FakeText {
111
+ return new FakeText(this.data);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The three writes compiled Solid makes to `style`, over ONE declaration map: `setProperty` and
117
+ * `removeProperty` (`setStyleProperty`, `solid-js/web`'s `web.js:302`, which is what the compiler
118
+ * emits per dynamic entry of a `style={{ … }}` prop) and `cssText` (its `style()` runtime, for a
119
+ * whole-object or string prop). It RECORDS: `<Form>`, `<Stack>`, `<Grid>` and `<Container>` each
120
+ * set a custom property, so `style = {}` killed every one of them inside `mount`, and a no-op that
121
+ * only stopped the crash would leave "the component set `--form-gap`" untestable instead — the
122
+ * same shape of hole one layer down.
123
+ */
124
+ export class FakeStyle {
125
+ /** Declaration order, as `CSSStyleDeclaration` enumerates. */
126
+ readonly properties = new Map<string, string>();
127
+
128
+ setProperty(name: string, value: string): void {
129
+ this.properties.set(name, String(value));
130
+ }
131
+
132
+ /** The old value, as the DOM's does — Solid ignores it, a test asserting a removal need not. */
133
+ removeProperty(name: string): string {
134
+ const previous = this.properties.get(name) ?? '';
135
+ this.properties.delete(name);
136
+ return previous;
137
+ }
138
+
139
+ getPropertyValue(name: string): string {
140
+ return this.properties.get(name) ?? '';
141
+ }
142
+
143
+ get cssText(): string {
144
+ return [...this.properties].map(([name, value]) => `${name}: ${value};`).join(' ');
145
+ }
146
+
147
+ /** A declaration with no `:` is dropped rather than stored, which is also how Solid's own reset
148
+ * lands: `style()` assigns `undefined` here to clear, and the DOM parses that to nothing. */
149
+ set cssText(text: string) {
150
+ this.properties.clear();
151
+ for (const declaration of String(text).split(';')) {
152
+ const at = declaration.indexOf(':');
153
+ if (at > 0) {
154
+ this.properties.set(declaration.slice(0, at).trim(), declaration.slice(at + 1).trim());
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Backed by the element's `class` ATTRIBUTE rather than a list of its own: `classList.toggle` is
162
+ * emitted inline by the compiler for `classList={{ … }}` while `class` goes through `className`,
163
+ * and two representations would let one element answer a test two ways. `toggle` is the call Solid
164
+ * makes and `add` was the one this stood in for — the double implemented the method nothing calls.
165
+ */
166
+ export class FakeClassList {
167
+ constructor(private readonly element: FakeElement) {}
168
+
169
+ private get names(): string[] {
170
+ return this.element.className.split(/\s+/).filter((name) => name.length > 0);
171
+ }
172
+
173
+ private write(names: readonly string[]): void {
174
+ this.element.className = names.join(' ');
175
+ }
176
+
177
+ add(...names: readonly string[]): void {
178
+ this.write([...this.names, ...names.filter((name) => !this.contains(name))]);
179
+ }
180
+
181
+ remove(...names: readonly string[]): void {
182
+ this.write(this.names.filter((name) => !names.includes(name)));
183
+ }
184
+
185
+ contains(name: string): boolean {
186
+ return this.names.includes(name);
187
+ }
188
+
189
+ toggle(name: string, force?: boolean): boolean {
190
+ const next = force ?? !this.contains(name);
191
+ if (next) this.add(name);
192
+ else this.remove(name);
193
+ return next;
194
+ }
195
+ }
196
+
197
+ export class FakeElement extends FakeNode {
198
+ readonly attributes = new Map<string, string>();
199
+ readonly listeners = new Map<string, (event: unknown) => void>();
200
+ /** Plain object, so `delete el.dataset.theme` behaves as the DOM's `DOMStringMap` does. */
201
+ readonly dataset: Record<string, string> = {};
202
+ readonly classList: FakeClassList = new FakeClassList(this);
203
+ readonly style = new FakeStyle();
204
+ /** Properties, not attributes: Solid assigns both of these straight onto the node. */
205
+ value = '';
206
+ checked = false;
207
+ constructor(readonly tagName: string) {
208
+ super();
209
+ }
210
+ get nodeName(): string {
211
+ return this.tagName.toUpperCase();
212
+ }
213
+ /** `_$className` assigns the PROPERTY, never the attribute — class assertions read this. */
214
+ get className(): string {
215
+ return this.attributes.get('class') ?? '';
216
+ }
217
+ set className(value: string) {
218
+ this.attributes.set('class', String(value));
219
+ }
220
+ /**
221
+ * `style` is the declaration map wearing an attribute's name, in all four calls. Both spellings
222
+ * are Solid's own: the compiler bakes a STATIC style entry into the template's `style=` attribute
223
+ * — which `parseHtml` sets here — and clears a whole-object prop through `removeAttribute`
224
+ * (`setAttribute(node, 'style')` with no value, `web.js:237`), while every dynamic entry goes to
225
+ * `style.setProperty`. Two stores would let one element answer `getAttribute('style')` and
226
+ * `style.getPropertyValue` differently.
227
+ */
228
+ setAttribute(name: string, value: string): void {
229
+ if (name === 'style') this.style.cssText = String(value);
230
+ else this.attributes.set(name, String(value));
231
+ }
232
+ getAttribute(name: string): string | null {
233
+ if (name === 'style') return this.style.properties.size === 0 ? null : this.style.cssText;
234
+ return this.attributes.get(name) ?? null;
235
+ }
236
+ hasAttribute(name: string): boolean {
237
+ return name === 'style' ? this.style.properties.size > 0 : this.attributes.has(name);
238
+ }
239
+ removeAttribute(name: string): void {
240
+ if (name === 'style') this.style.properties.clear();
241
+ else this.attributes.delete(name);
242
+ }
243
+ addEventListener(name: string, fn: (event: unknown) => void): void {
244
+ this.listeners.set(name, fn);
245
+ }
246
+ removeEventListener(name: string): void {
247
+ this.listeners.delete(name);
248
+ }
249
+ override cloneNode(deep?: boolean): FakeElement {
250
+ const copy = new FakeElement(this.tagName);
251
+ for (const [name, value] of this.attributes) copy.attributes.set(name, value);
252
+ // The declarations too, or a template's static `style=` survives the parse and dies at the
253
+ // `importNode` every compiled island mounts through.
254
+ for (const [name, value] of this.style.properties) copy.style.properties.set(name, value);
255
+ if (deep === true) for (const child of this.children) copy.appendChild(child.cloneNode(true));
256
+ return copy;
257
+ }
258
+ /**
259
+ * A tag name or `[attr="value"]`, anywhere in the subtree — the two shapes an island test asks
260
+ * for. Anything richer would be a CSS engine, which is a DOM library by another name.
261
+ *
262
+ * DESCENDANTS only, never `this`: the DOM's own `querySelector` does not match the element it is
263
+ * called on, and a host `<div>` answering `find('div')` reports the container the test built
264
+ * instead of the markup the island rendered into it.
265
+ */
266
+ querySelectorAll(selector: string): readonly FakeElement[] {
267
+ const attribute = ATTRIBUTE_SELECTOR.exec(selector);
268
+ const found: FakeElement[] = [];
269
+ const walk = (node: FakeNode): void => {
270
+ for (const child of node.children) {
271
+ if (child instanceof FakeElement && matches(child, selector, attribute)) found.push(child);
272
+ walk(child);
273
+ }
274
+ };
275
+ walk(this);
276
+ return found;
277
+ }
278
+ querySelector(selector: string): FakeElement | null {
279
+ return this.querySelectorAll(selector)[0] ?? null;
280
+ }
281
+ }
282
+
283
+ const matches = (
284
+ node: FakeElement,
285
+ selector: string,
286
+ attribute: RegExpExecArray | null,
287
+ ): boolean =>
288
+ attribute === null
289
+ ? node.tagName === selector
290
+ : node.getAttribute(attribute[1] as string) === attribute[2];
291
+
292
+ export class FakeTemplate extends FakeElement {
293
+ content: FakeNode = new FakeNode();
294
+ constructor() {
295
+ super('template');
296
+ }
297
+ set innerHTML(html: string) {
298
+ this.content = parseHtml(html);
299
+ }
300
+ }
301
+
302
+ const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
303
+ const TOKEN =
304
+ /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g;
305
+ const ATTRIBUTE = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
306
+
307
+ /** Only what `babel-preset-solid` emits into a template: tags, static attributes and text. */
308
+ export function parseHtml(html: string): FakeNode {
309
+ const root = new FakeNode();
310
+ const open: FakeNode[] = [root];
311
+ for (const match of html.matchAll(TOKEN)) {
312
+ const parent = open[open.length - 1] as FakeNode;
313
+ if (match[5] !== undefined) {
314
+ parent.appendChild(new FakeText(match[5]));
315
+ continue;
316
+ }
317
+ if (match[1] === '/') {
318
+ if (open.length > 1) open.pop();
319
+ continue;
320
+ }
321
+ const element = new FakeElement(match[2] as string);
322
+ for (const attr of (match[3] ?? '').matchAll(ATTRIBUTE)) {
323
+ if (attr[1] !== undefined) element.setAttribute(attr[1], attr[2] ?? attr[3] ?? attr[4] ?? '');
324
+ }
325
+ parent.appendChild(element);
326
+ if (match[4] !== '/' && !VOID_TAGS.has(element.tagName)) open.push(element);
327
+ }
328
+ return root;
329
+ }
330
+
331
+ export interface IslandDocument {
332
+ readonly documentElement: FakeElement;
333
+ readonly globals: Readonly<Record<string, unknown>>;
334
+ }
335
+
336
+ /**
337
+ * A fresh `<html>` and the globals a compiled island reads, built per mount rather than once per
338
+ * module: `document.documentElement.dataset.theme` is state one island writes and the next would
339
+ * otherwise inherit.
340
+ */
341
+ export function createIslandDocument(): IslandDocument {
342
+ const documentElement = new FakeElement('html');
343
+ const document = {
344
+ documentElement,
345
+ createElement: (tag: string): FakeElement =>
346
+ tag === 'template' ? new FakeTemplate() : new FakeElement(tag),
347
+ createElementNS: (_ns: string, tag: string): FakeElement => new FakeElement(tag),
348
+ createTextNode: (text: string): FakeText => new FakeText(text),
349
+ createComment: (): FakeText => new FakeText(''),
350
+ importNode: (node: FakeNode, deep?: boolean): FakeNode => node.cloneNode(deep),
351
+ // The document's listeners are the documentElement's, because this DOM has no bubbling at all
352
+ // and a no-op here made a whole shape of component undrivable: `@ultimat3/ui`'s Menu, Popover
353
+ // and focus trap each close on an Escape registered on `document`, never on their own node, so
354
+ // a test could mount one and never shut it. `fire(mounted.documentElement, 'keydown', …)`
355
+ // reaches them through the surface `MountedIsland` already exposes.
356
+ addEventListener: (name: string, fn: (event: unknown) => void): void =>
357
+ documentElement.addEventListener(name, fn),
358
+ removeEventListener: (name: string): void => documentElement.removeEventListener(name),
359
+ };
360
+ return {
361
+ documentElement,
362
+ globals: {
363
+ // Solid's event delegation reads `window` before it reads anything else.
364
+ window: globalThis,
365
+ Element: FakeElement,
366
+ SVGElement: FakeElement,
367
+ Node: FakeNode,
368
+ Text: FakeText,
369
+ document,
370
+ },
371
+ };
372
+ }
373
+
374
+ /** The delegated handler Solid parks on the node as `$$click`, or a listener it attached with
375
+ * `addEventListener` — a compiled island uses one or the other and a driver must accept both. */
376
+ export function handlerFor(
377
+ element: FakeElement,
378
+ type: string,
379
+ ): ((event: unknown) => void) | undefined {
380
+ const delegated = (element as unknown as Record<string, unknown>)[`$$${type}`];
381
+ if (typeof delegated === 'function') return delegated as (event: unknown) => void;
382
+ return element.listeners.get(type);
383
+ }
package/src/live-node.ts CHANGED
@@ -17,7 +17,7 @@ import type {
17
17
  UpgradeTarget,
18
18
  WsData,
19
19
  WsLike,
20
- } from '@ultimat3/realtime';
20
+ } from '@ultimat3/realtime/server';
21
21
  import { liveNodeUnavailable, upgradeRefused } from './errors';
22
22
 
23
23
  /** Every frame this end received, in order, already parsed. */
@@ -121,7 +121,7 @@ let sequence = 0;
121
121
  export async function createLiveNode(options: LiveNodeOptions = {}): Promise<LiveNodeHandle> {
122
122
  const core = await import('@ultimat3/core');
123
123
  const query = await import('@ultimat3/query');
124
- const realtime = await import('@ultimat3/realtime');
124
+ const realtime = await import('@ultimat3/realtime/server');
125
125
 
126
126
  const buildId = options.buildId ?? 'test-build';
127
127
  const transport = new realtime.InProcessTransport();
@@ -14,7 +14,8 @@
14
14
  // still decides what a real node reads, and this is never in that decision.
15
15
 
16
16
  import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
17
- import type { ChangeEvent, ChangeOp, LiveQueryRegistry, Row } from '@ultimat3/realtime';
17
+ import type { Row } from '@ultimat3/realtime';
18
+ import type { ChangeEvent, ChangeOp, LiveQueryRegistry } from '@ultimat3/realtime/server';
18
19
 
19
20
  /** What a caller does with a change nobody could deliver. */
20
21
  export interface LiveReplicatorOptions {