@ultimat3/testing 19.1.2 → 19.2.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
@@ -74,6 +74,9 @@ is its own entry point and not part of the barrel.
74
74
  | `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 |
75
75
  | 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 |
76
76
  | `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 |
77
+ | The selector grammar is SMALL and REFUSES, `As of 2026-09-05` | `island-selector.ts`: compounds of tag, `#id`, `.class`, `[attr]`, `[attr="value"]`, joined by a space (descendant) or `>` (child), matched right to left as CSS matches. Until then it was one regex — `[attr="value"]` or a bare tag — and a selector outside it MATCHED NOTHING: `find('[data-role="list"] button')` read as a tag with a space in it and answered `null`, so a virtualized list could be addressed one element at a time or not at all. Outside the grammar is `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` with the offset, never an empty answer. No `,`, no `+`/`~`, no pseudo-classes: each is a CSS engine's worth of edge cases, and the rule above about a DOM library holds |
78
+ | The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are plain writable numbers, default 0, and `getBoundingClientRect()` derives from the offset box. This DOM lays nothing out, so any other number is a fact the harness invented — and `undefined`, which they were, turns a windowed list's `Math.ceil(height / row)` into `NaN` rows with no throw near the cause. `size:` on `mountIsland` writes the HOST's box before `mount` (the one element that exists then); `mounted.resize` writes everything the island created, after |
79
+ | `ResizeObserver` records and never fires on its own | `island-observers.ts`, one registry PER DOCUMENT. `observe` fires nothing: a browser's initial notification lands at the next rendering opportunity, after `mount` returned, and here that opportunity is `mounted.resize(el, { width, height })` — which writes `client*` and `offset*`, delivers a spec-shaped entry to every observer of THAT element and no other, and answers whether one ran, for `fire`'s reason. `disconnect` leaves the registry, so `mounted.observing(el)` after an island's cleanup is the assertion that its `onCleanup` disconnected. `IntersectionObserver` is the same class of gap and is not modelled — no shipped island reads one |
77
80
  | 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 |
78
81
  | `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 |
79
82
  | A states file is PURE DATA, and it is enforced | `defineIslandStates` declares the states an island can be photographed in — error, empty, over-quota, read-only — in a sibling file (`settings.island.states.ts`) that may not import the component, JSX or `solid-js`. Three consumers read that file and only one of them has a browser: the command that takes the pictures (which must know the complete expected list BEFORE a browser exists, or "produced nothing and exited 0" reads as success), the harness page, and `island-states-guard.test.ts`. `assertIslandStatesPure` is the static rule — `X_TEST_ISLAND_STATES_NOT_PURE` — because a module that imports Solid still evaluates perfectly well under Bun, so nothing dynamic can catch it |
@@ -122,8 +125,8 @@ Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
122
125
  Entry points: `.` (the API), `./preload` (side effects for bunfig) and `./registry-isolation`
123
126
  (`isolateEntityRegistry()`, kept off `.` because it loads `@ultimat3/entity`).
124
127
 
125
- `fixture-island.ts` and `island-dom.ts` are on `.` and import `@ultimat3/core` and nothing else, so
126
- they cost a tier-0 test nothing. The whole-chain proof — real Babel, real Solid, a real island —
128
+ `fixture-island.ts`, `island-dom.ts`, `island-selector.ts` and `island-observers.ts` are on `.` and
129
+ import `@ultimat3/core` and nothing else, so they cost a tier-0 test nothing. The whole-chain proof — real Babel, real Solid, a real island —
127
130
  is `examples/dummy/apps/web/app/settings/settings.island.test.ts`; `fixture-island.test.ts` pins
128
131
  this package's own contract against modules written in the idiom `babel-preset-solid` emits, with
129
132
  no bundler, because a test here cannot import one.
package/README.md CHANGED
@@ -23,6 +23,8 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
23
23
  | `fixture-drivers.ts` | the five it declares but a driver must build — `page` `budget` `signIn` `deploy` `subscribe` |
24
24
  | `fixture-island.ts` | `mountIsland()` — build an island, import its chunk, run its `mount`. The BUILDER is a parameter |
25
25
  | `island-dom.ts` | the micro-DOM `mountIsland` drives: what compiled Solid touches, and nothing else |
26
+ | `island-selector.ts` | the selector grammar `find`/`all` read — compounds and two combinators — and the refusal for the rest |
27
+ | `island-observers.ts` | `ResizeObserver`, recording; `deliverResize` is the test's hand on the browser's layout |
26
28
  | `island-states.ts` | the vocabulary: what a photographable island STATE is. Types and constants, importing nothing |
27
29
  | `define-island-states.ts` | `defineIslandStates()` — one manifest, validated and frozen, with every default resolved |
28
30
  | `island-states-check.ts` | the rules a declaration must satisfy, as pure functions answering a fault |
@@ -301,6 +303,65 @@ removed.
301
303
  `island[Symbol.dispose]()` in an `afterAll`. Left installed it hands a fake `document` to every
302
304
  later FILE in the run.
303
305
 
306
+ ### Selectors
307
+
308
+ `find`, `all`, `text`, `fire`, `resize`, `scroll` and `observing` take one grammar, `As of
309
+ 2026-09-05`: a **compound** of a tag, `#id`, `.class`, `[attr]` and `[attr="value"]`
310
+ (`li[data-role="row"].odd`), joined by the **descendant** combinator (a space) and the **child**
311
+ combinator (`>`). Matching is CSS's — right to left, `>` means the direct parent, a space means any
312
+ ancestor, and a descendant walk backtracks — so `[data-role="scroller"] > ul > li button` finds
313
+ the buttons in the rows and not the one in the toolbar.
314
+
315
+ Anything else — a pseudo-class, a comma, `+`/`~`, an unquoted attribute value — throws
316
+ `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` naming the offset it stopped at. It used to match nothing,
317
+ which failed the assertion after it on the wrong question.
318
+
319
+ ### Layout: the box, the scroll offset, `ResizeObserver`
320
+
321
+ This DOM lays nothing out, so every box field is **0 until a test writes it**: `clientWidth`,
322
+ `clientHeight`, `offsetWidth`, `offsetHeight`, `scrollWidth`, `scrollHeight`, `scrollTop` and
323
+ `scrollLeft` are plain writable numbers on every element, `getBoundingClientRect()` derives from
324
+ the offset box at the origin, and `scrollTo({ top })` / `scrollTo(left, top)` write the offset and
325
+ run the element's `scroll` listener. A virtualized list — rows sliced by `scrollTop / rowHeight`,
326
+ a window sized by `clientHeight` — is testable end to end:
327
+
328
+ ```ts
329
+ import { buildIslands } from '@ultimat3/cli';
330
+ import { expect, mountIsland } from '@ultimat3/testing';
331
+
332
+ declare const root: string; // the app root, as above
333
+ using island = await mountIsland({
334
+ build: buildIslands,
335
+ root,
336
+ file: 'apps/web/app/fleet/session-list.island.tsx',
337
+ props: { rows: 3 },
338
+ size: { height: 640 },
339
+ });
340
+
341
+ // The host is the one element that exists BEFORE mount; `size:` is its box then.
342
+ // Everything the island creates starts at 0 and is laid out afterwards, by you:
343
+ expect(island.resize('[data-role="scroller"]', { height: 100, width: 300 })).toBe(true);
344
+ expect(island.all('[data-role="scroller"] li')).toHaveLength(3);
345
+
346
+ expect(island.scroll('[data-role="scroller"]', { top: 80 })).toBe(true);
347
+ expect(island.text('[data-role="scroller"] li')).toBe('row 3');
348
+ ```
349
+
350
+ `ResizeObserver` is a constructor for the life of the mount, so an island's
351
+ `typeof ResizeObserver === 'function'` guard takes the browser's branch. It **records** — `observe`,
352
+ `unobserve`, `disconnect` — and fires nothing on its own: the browser's initial notification lands
353
+ after `mount` returns, at the next rendering opportunity, and here that opportunity is your
354
+ `island.resize(target, { width, height })`. It writes the size onto the element (`client*` and
355
+ `offset*` together) and delivers one spec-shaped entry — `target`, `contentRect`, `borderBoxSize`,
356
+ `contentBoxSize`, `devicePixelContentBoxSize` — to every observer watching **that** element, and
357
+ answers whether any did, for `fire`'s reason. `island.observing(target)` is the teardown
358
+ assertion: an `onCleanup` that forgot `disconnect()` still answers `true` after the island's
359
+ cleanup ran. The registry is per document, so nothing an earlier mount observed survives into the
360
+ next.
361
+
362
+ Not modelled: `IntersectionObserver`. Same class of gap for an infinite-scroll sentinel; it is not
363
+ here yet because no island the framework ships reads one.
364
+
304
365
  ## Declaring the states an island can be photographed in
305
366
 
306
367
  A reviewer can click their way to most of a component. They cannot click their way to *the account
@@ -403,7 +464,8 @@ fails on a page that simply has not painted yet.
403
464
 
404
465
  `X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
405
466
  `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK`
406
- `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` `X_TEST_ISLAND_STATES_EMPTY`
467
+ `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` `X_TEST_ISLAND_SELECTOR_UNSUPPORTED`
468
+ `X_TEST_ISLAND_STATES_EMPTY`
407
469
  `X_TEST_ISLAND_STATES_NOT_PURE` `X_TEST_ISLAND_STATES_MISSING_FILE` `X_TEST_ISLAND_STATES_UNKNOWN`
408
470
  `X_TEST_ISLAND_STATES_AMBIGUOUS` `X_TEST_ISLAND_STATE_ID_INVALID` `X_TEST_ISLAND_STATE_DUPLICATE`
409
471
  `X_TEST_ISLAND_STATE_JSON_INVALID` `X_TEST_ISLAND_STATE_CLOCK_INVALID`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "19.1.2",
3
+ "version": "19.2.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": "19.1.2",
37
- "@ultimat3/core": "19.1.2",
38
- "@ultimat3/db": "19.1.2",
39
- "@ultimat3/entity": "19.1.2",
40
- "@ultimat3/i18n": "19.1.2",
41
- "@ultimat3/jobs": "19.1.2",
42
- "@ultimat3/mail": "19.1.2",
43
- "@ultimat3/policy": "19.1.2",
44
- "@ultimat3/query": "19.1.2",
45
- "@ultimat3/realtime": "19.1.2",
46
- "@ultimat3/time": "19.1.2"
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"
47
47
  }
48
48
  }
package/src/errors.ts CHANGED
@@ -30,6 +30,8 @@ export const TESTING_ERROR_CODES = [
30
30
  'X_TEST_LIVE_NODE_UPGRADE_REFUSED',
31
31
  'X_TEST_ISLAND_NOT_BUILT',
32
32
  'X_TEST_ISLAND_NO_MOUNT',
33
+ // Declared here and thrown from `island-selector.ts`, for the reason the states codes below are.
34
+ 'X_TEST_ISLAND_SELECTOR_UNSUPPORTED',
33
35
  // Declared here and thrown from `island-state-errors.ts`: one file has one job and this
34
36
  // catalogue is at its ceiling, so the classes moved and the registration did not.
35
37
  'X_TEST_ISLAND_STATES_EMPTY',
@@ -65,6 +67,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
65
67
  X_TEST_LIVE_NODE_UPGRADE_REFUSED: 'the in-process sync node refused the connection',
66
68
  X_TEST_ISLAND_NOT_BUILT: 'the island build produced no chunk for the file the test named',
67
69
  X_TEST_ISLAND_NO_MOUNT: 'an island chunk exports no mount function',
70
+ X_TEST_ISLAND_SELECTOR_UNSUPPORTED: "a selector is outside the island DOM's grammar",
68
71
  X_TEST_ISLAND_STATES_EMPTY: 'an island state manifest declares no states',
69
72
  X_TEST_ISLAND_STATES_NOT_PURE:
70
73
  'an island states file imports the component, a renderer or a sibling module',
@@ -8,6 +8,8 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { islandMountMissing, islandNotBuilt } from './errors';
10
10
  import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom';
11
+ import type { ResizeInput } from './island-observers';
12
+ import { deliverResize } from './island-observers';
11
13
 
12
14
  /** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS
13
15
  * artifact, a source map, a dev/production flag — is invisible here on purpose. */
@@ -47,6 +49,12 @@ export interface MountIslandOptions {
47
49
  readonly shell?: string;
48
50
  /** Anything the micro-DOM does not supply — `fetch` above all. Merged over the DOM globals. */
49
51
  readonly globals?: Readonly<Record<string, unknown>>;
52
+ /**
53
+ * The host element's box BEFORE `mount` runs — the one element that exists then, laid out by
54
+ * the server-rendered page around it. Everything the island creates starts at 0 and is sized
55
+ * after mount through `resize`, which is the order a browser's `ResizeObserver` delivers in.
56
+ */
57
+ readonly size?: ResizeInput;
50
58
  }
51
59
 
52
60
  export interface MountedIsland extends Disposable {
@@ -70,6 +78,20 @@ export interface MountedIsland extends Disposable {
70
78
  type: string,
71
79
  event?: Readonly<Record<string, unknown>>,
72
80
  ): boolean;
81
+ /**
82
+ * The browser's layout, in the test's hand: write the box onto the element and deliver an entry
83
+ * to every `ResizeObserver` the island has watching THAT element. Answers whether one ran, for
84
+ * `fire`'s reason — an element nothing observes and an island that never observed are otherwise
85
+ * the same silence, and the second is the bug.
86
+ */
87
+ resize(target: string | FakeElement | null | undefined, size: ResizeInput): boolean;
88
+ /** Move the element's scroll offset and run its `scroll` listener. Answers whether one ran. */
89
+ scroll(
90
+ target: string | FakeElement | null | undefined,
91
+ to: { top?: number; left?: number },
92
+ ): boolean;
93
+ /** Whether any live `ResizeObserver` is watching the element — `false` once it disconnected. */
94
+ observing(target: string | FakeElement | null | undefined): boolean;
73
95
  }
74
96
 
75
97
  /**
@@ -201,13 +223,16 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
201
223
  );
202
224
  }
203
225
 
204
- const { documentElement, globals } = createIslandDocument();
226
+ const { documentElement, globals, resizeObservers } = createIslandDocument();
205
227
  const restore = installGlobals({ ...globals, ...options.globals });
206
228
  try {
207
229
  const path = modulePathFor(chunk.code);
208
230
  await Bun.write(path, chunk.code);
209
231
  const entry = entryOf(await import(path), chunk.file);
210
232
  const el = new FakeElement('div');
233
+ // Before the shell and before `mount`: nothing observes the host yet, so this is a write and
234
+ // never a notification.
235
+ if (options.size !== undefined) deliverResize(resizeObservers, el, options.size);
211
236
  if (options.shell !== undefined) {
212
237
  for (const child of [...parseHtml(options.shell).children]) el.appendChild(child);
213
238
  }
@@ -216,6 +241,8 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
216
241
  // — so it failed with `document is not defined` inside whichever later test happened to be
217
242
  // running, with no thread back here.
218
243
  await entry.mount(el, options.props);
244
+ const resolve = (target: string | FakeElement | null | undefined): FakeElement | null =>
245
+ typeof target === 'string' ? el.querySelector(target) : (target ?? null);
219
246
  return {
220
247
  code: chunk.code,
221
248
  el,
@@ -224,12 +251,26 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
224
251
  all: (selector) => el.querySelectorAll(selector),
225
252
  text: (selector) => el.querySelector(selector)?.textContent ?? '',
226
253
  fire: (target, type, event) => {
227
- const node = typeof target === 'string' ? el.querySelector(target) : target;
228
- const handler = node == null ? undefined : handlerFor(node, type);
254
+ const node = resolve(target);
255
+ const handler = node === null ? undefined : handlerFor(node, type);
229
256
  if (handler === undefined) return false;
230
257
  handler({ currentTarget: node, target: node, ...event });
231
258
  return true;
232
259
  },
260
+ resize: (target, size) => {
261
+ const node = resolve(target);
262
+ return node === null ? false : deliverResize(resizeObservers, node, size);
263
+ },
264
+ scroll: (target, to) => {
265
+ const node = resolve(target);
266
+ if (node === null || handlerFor(node, 'scroll') === undefined) return false;
267
+ node.scrollTo(to);
268
+ return true;
269
+ },
270
+ observing: (target) => {
271
+ const node = resolve(target);
272
+ return node !== null && [...resizeObservers].some((each) => each.targets.has(node));
273
+ },
233
274
  [Symbol.dispose]: restore,
234
275
  };
235
276
  } catch (error) {
package/src/index.ts CHANGED
@@ -119,6 +119,7 @@ export type { AppHandle, AppOptions, BootedApp } from './harness';
119
119
  export { describeApp, testApp } from './harness';
120
120
  // Type-only: the micro-DOM is the fixture's to build, and a test only ever names what it handed back.
121
121
  export type { FakeElement, FakeNode, FakeText } from './island-dom';
122
+ export type { FakeResizeObserverEntry, ResizeInput, ResizeRect } from './island-observers';
122
123
  export type { IslandAddress, IslandShotTarget } from './island-shot-targets';
123
124
  export {
124
125
  isIslandTheme,
package/src/island-dom.ts CHANGED
@@ -2,9 +2,13 @@
2
2
  // `bun test` ships no DOM and no DOM library may be added, so an island — the only client-side
3
3
  // code Ultimate ships — was untestable without one. `generate: 'dom'` builds every element from
4
4
  // `_$template("<button …>")`, so a stub without a parsed `<template>` cannot run one line of it.
5
+ //
6
+ // Two neighbours at the 500-line ceiling: `island-selector.ts` is the selector grammar `find`
7
+ // and `all` read, and `island-observers.ts` is the `ResizeObserver` an island measures with.
5
8
 
6
- /** `[data-role="preview"]` — the one selector shape a mounted island is queried by. */
7
- const ATTRIBUTE_SELECTOR = /^\[([\w-]+)="([^"]*)"\]$/;
9
+ import type { FakeResizeObserver } from './island-observers';
10
+ import { createResizeObservers, rectOf } from './island-observers';
11
+ import { matchesSelector, parseSelector } from './island-selector';
8
12
 
9
13
  /**
10
14
  * A node belongs to ONE parent, and the DOM enforces that by moving it: every attach detaches the
@@ -256,19 +260,57 @@ export class FakeElement extends FakeNode {
256
260
  return copy;
257
261
  }
258
262
  /**
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.
263
+ * The box, as a test set it. Every field is 0 until something writes it: this DOM lays nothing
264
+ * out, and a number invented here would be a fact the harness made up. An island reads them the
265
+ * way a virtualized list does — `clientHeight` for the window, `scrollTop` in a scroll handler,
266
+ * `scrollHeight` to know the whole — and a stub without them answered `undefined`, which
267
+ * `Math.floor` turns into `NaN` rows with no throw to point at it. Plain writable fields, so a
268
+ * test sets `list.clientHeight = 400` directly, or through `mounted.resize`, which also notifies
269
+ * the island's `ResizeObserver`.
270
+ */
271
+ clientWidth = 0;
272
+ clientHeight = 0;
273
+ offsetWidth = 0;
274
+ offsetHeight = 0;
275
+ scrollWidth = 0;
276
+ scrollHeight = 0;
277
+ scrollTop = 0;
278
+ scrollLeft = 0;
279
+ /** Derived from the offset box: no layout, so an element is always at the origin. */
280
+ getBoundingClientRect(): ReturnType<typeof rectOf> {
281
+ return rectOf(this.offsetWidth, this.offsetHeight);
282
+ }
283
+ /**
284
+ * Both spellings the DOM takes — `scrollTo({ top })` and `scrollTo(left, top)` — writing the
285
+ * offsets and running the island's `scroll` listener, which is how a virtualized list learns its
286
+ * window moved. `scroll` is not in Solid's delegated set, so `onScroll` compiles to
287
+ * `addEventListener('scroll', …)` and only that listener is looked for.
288
+ */
289
+ scrollTo(options: { top?: number; left?: number } | number, top?: number): void {
290
+ if (typeof options === 'number') {
291
+ this.scrollLeft = options;
292
+ if (top !== undefined) this.scrollTop = top;
293
+ } else {
294
+ if (options.left !== undefined) this.scrollLeft = options.left;
295
+ if (options.top !== undefined) this.scrollTop = options.top;
296
+ }
297
+ this.listeners.get('scroll')?.({ currentTarget: this, target: this });
298
+ }
299
+ /**
300
+ * A compound of tag, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by the descendant
301
+ * and child combinators — `island-selector.ts` — anywhere in the subtree. Anything richer throws
302
+ * `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` rather than matching nothing.
261
303
  *
262
304
  * DESCENDANTS only, never `this`: the DOM's own `querySelector` does not match the element it is
263
305
  * called on, and a host `<div>` answering `find('div')` reports the container the test built
264
306
  * instead of the markup the island rendered into it.
265
307
  */
266
308
  querySelectorAll(selector: string): readonly FakeElement[] {
267
- const attribute = ATTRIBUTE_SELECTOR.exec(selector);
309
+ const parsed = parseSelector(selector);
268
310
  const found: FakeElement[] = [];
269
311
  const walk = (node: FakeNode): void => {
270
312
  for (const child of node.children) {
271
- if (child instanceof FakeElement && matches(child, selector, attribute)) found.push(child);
313
+ if (child instanceof FakeElement && matchesSelector(child, parsed)) found.push(child);
272
314
  walk(child);
273
315
  }
274
316
  };
@@ -280,15 +322,6 @@ export class FakeElement extends FakeNode {
280
322
  }
281
323
  }
282
324
 
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
325
  export class FakeTemplate extends FakeElement {
293
326
  content: FakeNode = new FakeNode();
294
327
  constructor() {
@@ -331,6 +364,8 @@ export function parseHtml(html: string): FakeNode {
331
364
  export interface IslandDocument {
332
365
  readonly documentElement: FakeElement;
333
366
  readonly globals: Readonly<Record<string, unknown>>;
367
+ /** Every `ResizeObserver` this document's island constructed and has not disconnected. */
368
+ readonly resizeObservers: Set<FakeResizeObserver<FakeElement>>;
334
369
  }
335
370
 
336
371
  /**
@@ -340,6 +375,9 @@ export interface IslandDocument {
340
375
  */
341
376
  export function createIslandDocument(): IslandDocument {
342
377
  const documentElement = new FakeElement('html');
378
+ // Per document, for the reason the document itself is: an observer the last mount's island
379
+ // never disconnected must not hear the next mount's `resize`.
380
+ const { registry: resizeObservers, ResizeObserver } = createResizeObservers<FakeElement>();
343
381
  const document = {
344
382
  documentElement,
345
383
  createElement: (tag: string): FakeElement =>
@@ -359,6 +397,7 @@ export function createIslandDocument(): IslandDocument {
359
397
  };
360
398
  return {
361
399
  documentElement,
400
+ resizeObservers,
362
401
  globals: {
363
402
  // Solid's event delegation reads `window` before it reads anything else.
364
403
  window: globalThis,
@@ -367,6 +406,9 @@ export function createIslandDocument(): IslandDocument {
367
406
  Node: FakeNode,
368
407
  Text: FakeText,
369
408
  document,
409
+ // A constructor, so an island's `typeof ResizeObserver === 'function'` guard takes the
410
+ // branch a browser takes. `window.ResizeObserver` resolves through `globalThis` above.
411
+ ResizeObserver,
370
412
  },
371
413
  };
372
414
  }
@@ -0,0 +1,158 @@
1
+ // `ResizeObserver`, as much of it as a compiled island calls and a test can drive. Split out of
2
+ // `island-dom.ts` at the 500-line ceiling.
3
+ //
4
+ // `bun test` has no `ResizeObserver` at all, so an island that measures its box — a virtualized
5
+ // list sizing its window, a terminal computing rows and columns — either guarded the constructor
6
+ // and took a fallback path no browser takes, or threw `ResizeObserver is not defined` inside
7
+ // `mount`. Neither is the island a browser runs. The stub RECORDS: `observe`, `unobserve` and
8
+ // `disconnect` keep a set per observer, and `deliverResize` is the test's hand on the browser's
9
+ // layout — it calls every observer watching THAT element, and no other, with an entry shaped the
10
+ // way the spec shapes one.
11
+ //
12
+ // `observe()` fires nothing. A browser delivers an initial notification at the next rendering
13
+ // opportunity, which is after `mount` has returned; here that opportunity is the test's own
14
+ // `mounted.resize(...)`, made when the test decides what the layout is. A stub that fired inside
15
+ // `observe` would re-enter the island mid-`mount` from a place no browser does.
16
+
17
+ /** The size a test hands an element — either axis alone is fine; the other keeps its value. */
18
+ export interface ResizeInput {
19
+ readonly width?: number;
20
+ readonly height?: number;
21
+ }
22
+
23
+ /** The size fields `deliverResize` writes, so an island's own `clientHeight` read agrees with
24
+ * the entry it was handed. Structural, so this module imports no DOM class. */
25
+ export interface SizedElement {
26
+ clientWidth: number;
27
+ clientHeight: number;
28
+ offsetWidth: number;
29
+ offsetHeight: number;
30
+ }
31
+
32
+ /** `DOMRectReadOnly`, as the spec's `contentRect` — every member, because an island may read any. */
33
+ export interface ResizeRect {
34
+ readonly x: number;
35
+ readonly y: number;
36
+ readonly top: number;
37
+ readonly left: number;
38
+ readonly width: number;
39
+ readonly height: number;
40
+ readonly right: number;
41
+ readonly bottom: number;
42
+ }
43
+
44
+ export const rectOf = (width: number, height: number): ResizeRect => ({
45
+ x: 0,
46
+ y: 0,
47
+ top: 0,
48
+ left: 0,
49
+ width,
50
+ height,
51
+ right: width,
52
+ bottom: height,
53
+ });
54
+
55
+ /** One entry of the list a `ResizeObserver` callback receives. All three box lists are the same
56
+ * box: this DOM has no padding or border to tell them apart by. */
57
+ export interface FakeResizeObserverEntry<TElement extends object> {
58
+ readonly target: TElement;
59
+ readonly contentRect: ResizeRect;
60
+ readonly borderBoxSize: readonly { readonly inlineSize: number; readonly blockSize: number }[];
61
+ readonly contentBoxSize: readonly { readonly inlineSize: number; readonly blockSize: number }[];
62
+ readonly devicePixelContentBoxSize: readonly {
63
+ readonly inlineSize: number;
64
+ readonly blockSize: number;
65
+ }[];
66
+ }
67
+
68
+ export type ResizeCallback<TElement extends object> = (
69
+ entries: readonly FakeResizeObserverEntry<TElement>[],
70
+ observer: FakeResizeObserver<TElement>,
71
+ ) => void;
72
+
73
+ export class FakeResizeObserver<TElement extends object = object> {
74
+ readonly targets = new Set<TElement>();
75
+ constructor(
76
+ private readonly registry: Set<FakeResizeObserver<TElement>>,
77
+ private readonly callback: ResizeCallback<TElement>,
78
+ ) {
79
+ registry.add(this);
80
+ }
81
+ observe(target: TElement): void {
82
+ this.targets.add(target);
83
+ }
84
+ unobserve(target: TElement): void {
85
+ this.targets.delete(target);
86
+ }
87
+ /** Out of the registry too: an observer an island disconnected in `onCleanup` is gone, and a
88
+ * later `resize` must not reach it — that is the assertion a teardown test makes. */
89
+ disconnect(): void {
90
+ this.targets.clear();
91
+ this.registry.delete(this);
92
+ }
93
+ deliver(target: TElement, rect: ResizeRect): void {
94
+ const box = [{ inlineSize: rect.width, blockSize: rect.height }];
95
+ this.callback(
96
+ [
97
+ {
98
+ target,
99
+ contentRect: rect,
100
+ borderBoxSize: box,
101
+ contentBoxSize: box,
102
+ devicePixelContentBoxSize: box,
103
+ },
104
+ ],
105
+ this,
106
+ );
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The per-document registry and the constructor an island sees as `ResizeObserver`. Per DOCUMENT,
112
+ * never module scope: an observer one mount left behind must not receive the next mount's resize,
113
+ * and `createIslandDocument` is built fresh per mount for exactly that class of state.
114
+ */
115
+ export function createResizeObservers<TElement extends object>(): {
116
+ readonly registry: Set<FakeResizeObserver<TElement>>;
117
+ readonly ResizeObserver: new (callback: ResizeCallback<TElement>) => FakeResizeObserver<TElement>;
118
+ } {
119
+ const registry = new Set<FakeResizeObserver<TElement>>();
120
+ const ResizeObserver = class extends FakeResizeObserver<TElement> {
121
+ constructor(callback: ResizeCallback<TElement>) {
122
+ super(registry, callback);
123
+ }
124
+ };
125
+ return { registry, ResizeObserver };
126
+ }
127
+
128
+ /**
129
+ * Write the size onto the element and notify every observer watching it. Answers whether ANY
130
+ * observer ran, for `fire`'s reason: an element nothing observes and an island that never called
131
+ * `observe` are the same silence otherwise, and the second is the bug.
132
+ *
133
+ * Both `client*` and `offset*` move together. This DOM lays nothing out, so the two are one number
134
+ * — an island reading either after a resize sees the size the entry carried.
135
+ */
136
+ export function deliverResize<TElement extends SizedElement>(
137
+ registry: ReadonlySet<FakeResizeObserver<TElement>>,
138
+ target: TElement,
139
+ size: ResizeInput,
140
+ ): boolean {
141
+ if (size.width !== undefined) {
142
+ target.clientWidth = size.width;
143
+ target.offsetWidth = size.width;
144
+ }
145
+ if (size.height !== undefined) {
146
+ target.clientHeight = size.height;
147
+ target.offsetHeight = size.height;
148
+ }
149
+ const rect = rectOf(target.clientWidth, target.clientHeight);
150
+ let delivered = false;
151
+ // A copy: a callback that disconnects its observer mutates the registry under the walk.
152
+ for (const observer of [...registry]) {
153
+ if (!observer.targets.has(target)) continue;
154
+ observer.deliver(target, rect);
155
+ delivered = true;
156
+ }
157
+ return delivered;
158
+ }
@@ -0,0 +1,174 @@
1
+ // The selector grammar the island micro-DOM answers, and the refusal for everything outside it.
2
+ //
3
+ // Split out of `island-dom.ts` at the 500-line ceiling. Until `As of 2026-09-05` the whole grammar
4
+ // was one regex — `[data-role="preview"]` or a bare tag — and a selector outside it was not
5
+ // refused, it MATCHED NOTHING: `find('[data-role="list"] button')` read as a tag named
6
+ // `[data-role="list"] button`, answered `null`, and the assertion after it failed on the wrong
7
+ // question. A virtualized list — rows inside a scroller inside a panel — cannot be addressed one
8
+ // element at a time, which is what the app that measured this was left doing.
9
+ //
10
+ // Small on purpose: compounds of tag, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by the
11
+ // descendant (whitespace) and child (`>`) combinators. No `,`, no sibling combinators, no
12
+ // pseudo-classes — each is a CSS engine's worth of edge cases, and a DOM library may not be added.
13
+ // Anything else throws `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` naming the fragment, because a
14
+ // selector silently matching nothing is the hole this file replaces.
15
+
16
+ import { UltimateError } from '@ultimat3/core';
17
+
18
+ /** The element surface the matcher reads. Structural, so this module imports no DOM class. */
19
+ export interface SelectableElement {
20
+ readonly tagName: string;
21
+ /** Whatever the tree's parent type is — a fragment root without a tag is not an element. */
22
+ readonly parentNode: object | null;
23
+ getAttribute(name: string): string | null;
24
+ }
25
+
26
+ const isSelectable = (node: object): node is SelectableElement =>
27
+ typeof (node as { tagName?: unknown }).tagName === 'string' &&
28
+ typeof (node as { getAttribute?: unknown }).getAttribute === 'function';
29
+
30
+ /** `tag`, `#id`, `.class`, `[attr]`, `[attr="v"]`, `[attr='v']` — one simple selector each. */
31
+ const SIMPLE = /\*|[a-zA-Z][\w-]*|#[\w-]+|\.[\w-]+|\[([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'))?\]/y;
32
+
33
+ interface Compound {
34
+ readonly tag: string | undefined;
35
+ readonly id: string | undefined;
36
+ readonly classes: readonly string[];
37
+ readonly attributes: readonly { readonly name: string; readonly value: string | undefined }[];
38
+ }
39
+
40
+ /** `descendant` is whitespace, `child` is `>`. The first compound has no combinator before it. */
41
+ type Combinator = 'descendant' | 'child';
42
+
43
+ interface Step {
44
+ readonly combinator: Combinator;
45
+ readonly compound: Compound;
46
+ }
47
+
48
+ export interface ParsedSelector {
49
+ readonly source: string;
50
+ readonly steps: readonly Step[];
51
+ }
52
+
53
+ /** The grammar, in the words the fix line prints. One string, so the two can never disagree. */
54
+ export const SELECTOR_GRAMMAR =
55
+ 'a tag, #id, .class, [attr] or [attr="value"], compounded (li[data-role="row"]) and joined by a ' +
56
+ 'space (descendant) or > (child)';
57
+
58
+ export class IslandSelectorUnsupportedError extends UltimateError {
59
+ constructor(input: { selector: string; at: number }) {
60
+ const fragment = input.selector.slice(input.at, input.at + 12);
61
+ super({
62
+ code: 'X_TEST_ISLAND_SELECTOR_UNSUPPORTED',
63
+ cause: `${JSON.stringify(input.selector)} is not in the island DOM's selector grammar — the part it cannot read starts at ${JSON.stringify(fragment)} (offset ${input.at})`,
64
+ fix: `spell the selector as ${SELECTOR_GRAMMAR}; a pseudo-class, a comma or a sibling combinator needs a browser test`,
65
+ });
66
+ }
67
+ }
68
+
69
+ const parseCompound = (selector: string, from: number): { compound: Compound; end: number } => {
70
+ let tag: string | undefined;
71
+ let id: string | undefined;
72
+ const classes: string[] = [];
73
+ const attributes: { name: string; value: string | undefined }[] = [];
74
+ let at = from;
75
+ for (;;) {
76
+ SIMPLE.lastIndex = at;
77
+ const match = SIMPLE.exec(selector);
78
+ if (match === null) break;
79
+ const token = match[0];
80
+ if (token === '*') {
81
+ /* universal: matches every element */
82
+ } else if (token.startsWith('#')) id = token.slice(1);
83
+ else if (token.startsWith('.')) classes.push(token.slice(1));
84
+ else if (token.startsWith('[')) {
85
+ attributes.push({ name: match[1] as string, value: match[2] ?? match[3] });
86
+ } else if (tag === undefined && at === from) tag = token;
87
+ // A tag AFTER an id, class or attribute is two compounds with no combinator between them,
88
+ // which is not a selector: `[a]li` is refused rather than read as `[a] li`.
89
+ else break;
90
+ at += token.length;
91
+ }
92
+ if (at === from) throw new IslandSelectorUnsupportedError({ selector, at: from });
93
+ return { compound: { tag, id, classes, attributes }, end: at };
94
+ };
95
+
96
+ /**
97
+ * Left to right into steps; matched right to left by `matchesSelector`. Throws at the first
98
+ * character the grammar does not cover, with its offset — a refusal a reader can act on, where
99
+ * "matched nothing" is one they have to guess at.
100
+ */
101
+ export function parseSelector(selector: string): ParsedSelector {
102
+ const steps: Step[] = [];
103
+ let at = 0;
104
+ let combinator: Combinator = 'descendant';
105
+ const skipSpaces = (): void => {
106
+ while (selector[at] === ' ' || selector[at] === '\t' || selector[at] === '\n') at += 1;
107
+ };
108
+ skipSpaces();
109
+ if (at === selector.length) throw new IslandSelectorUnsupportedError({ selector, at: 0 });
110
+ for (;;) {
111
+ const { compound, end } = parseCompound(selector, at);
112
+ steps.push({ combinator, compound });
113
+ at = end;
114
+ const beforeSpaces = at;
115
+ skipSpaces();
116
+ if (at === selector.length) return { source: selector, steps };
117
+ if (selector[at] === '>') {
118
+ combinator = 'child';
119
+ at += 1;
120
+ skipSpaces();
121
+ } else if (at > beforeSpaces) combinator = 'descendant';
122
+ // No whitespace and no `>` between two tokens the compound parser stopped on: `[a]li`,
123
+ // `li:first-child`, `a, b`, `a + b` — every one of them a shape this grammar does not read.
124
+ else throw new IslandSelectorUnsupportedError({ selector, at });
125
+ }
126
+ }
127
+
128
+ const classesOf = (element: SelectableElement): readonly string[] =>
129
+ (element.getAttribute('class') ?? '').split(/\s+/).filter((name) => name.length > 0);
130
+
131
+ const matchesCompound = (element: SelectableElement, compound: Compound): boolean => {
132
+ if (compound.tag !== undefined && compound.tag !== element.tagName) return false;
133
+ if (compound.id !== undefined && element.getAttribute('id') !== compound.id) return false;
134
+ if (compound.classes.length > 0) {
135
+ const own = classesOf(element);
136
+ if (!compound.classes.every((name) => own.includes(name))) return false;
137
+ }
138
+ for (const { name, value } of compound.attributes) {
139
+ const actual = element.getAttribute(name);
140
+ if (value === undefined ? actual === null : actual !== value) return false;
141
+ }
142
+ return true;
143
+ };
144
+
145
+ /** The element's parent when it is an element — a document or fragment root has no `tagName`. */
146
+ const parentElement = (element: SelectableElement): SelectableElement | null => {
147
+ const parent = element.parentNode;
148
+ return parent !== null && isSelectable(parent) ? parent : null;
149
+ };
150
+
151
+ /**
152
+ * Right to left, as CSS matches: the LAST compound is the element itself, and each earlier one
153
+ * names its parent (`>`) or some ancestor (a space) — with backtracking over ancestors, so
154
+ * `a b c` matches a `c` under a `b` under an `a` at any depth, and `a > b` does NOT match `b`'s
155
+ * grandchildren. Ancestors ABOVE the element `querySelectorAll` was called on count, which is also
156
+ * the DOM's rule: `host.querySelectorAll('div p')` finds a `p` whose `div` is the host itself.
157
+ */
158
+ export function matchesSelector(element: SelectableElement, parsed: ParsedSelector): boolean {
159
+ const matchesAt = (candidate: SelectableElement, index: number): boolean => {
160
+ const step = parsed.steps[index] as Step;
161
+ if (!matchesCompound(candidate, step.compound)) return false;
162
+ if (index === 0) return true;
163
+ const next = index - 1;
164
+ if (step.combinator === 'child') {
165
+ const parent = parentElement(candidate);
166
+ return parent !== null && matchesAt(parent, next);
167
+ }
168
+ for (let up = parentElement(candidate); up !== null; up = parentElement(up)) {
169
+ if (matchesAt(up, next)) return true;
170
+ }
171
+ return false;
172
+ };
173
+ return matchesAt(element, parsed.steps.length - 1);
174
+ }