@ultimat3/testing
Advanced tools
| // `defineIslandStates` — an island's photographable states, validated once, frozen, and knowable | ||
| // without a browser. The vocabulary it takes and hands back is `island-states.ts`; the rules it | ||
| // applies are `island-states-check.ts`. Every default a declaration leaves out is resolved HERE, so | ||
| // nothing downstream has to decide what an absent viewport or an absent theme list meant. | ||
| import { DEFAULT_NOW } from './determinism'; | ||
| import { | ||
| IslandStateDuplicateError, | ||
| IslandStateIdInvalidError, | ||
| IslandStateInstantInvalidError, | ||
| IslandStateJsonInvalidError, | ||
| IslandStateStubInvalidError, | ||
| IslandStatesEmptyError, | ||
| IslandStateZoneInvalidError, | ||
| } from './island-state-errors'; | ||
| import type { | ||
| IslandState, | ||
| IslandStateDecl, | ||
| IslandStatesDecl, | ||
| IslandStatesManifest, | ||
| IslandTheme, | ||
| IslandViewport, | ||
| } from './island-states'; | ||
| import { | ||
| DEFAULT_ISLAND_VIEWPORT, | ||
| ISLAND_SHOT_TIME_ZONE, | ||
| ISLAND_STATES, | ||
| ISLAND_THEMES, | ||
| islandStatesName, | ||
| } from './island-states'; | ||
| import { | ||
| isPinnedInstant, | ||
| isStateId, | ||
| isStubMatch, | ||
| isTimeZone, | ||
| jsonFault, | ||
| slugifyStateId, | ||
| } from './island-states-check'; | ||
| /** A dimension a browser can be sized to. Anything else inherits, rather than photographing 0px. */ | ||
| const usableViewport = (viewport: IslandViewport | undefined): IslandViewport | undefined => | ||
| viewport !== undefined && | ||
| Number.isInteger(viewport.width) && | ||
| Number.isInteger(viewport.height) && | ||
| viewport.width > 0 && | ||
| viewport.height > 0 | ||
| ? { width: viewport.width, height: viewport.height } | ||
| : undefined; | ||
| /** | ||
| * Declared themes, deduplicated, in declaration order — both when the list is absent, and both | ||
| * again when nothing in it is a theme this framework knows. Falling back rather than throwing is | ||
| * the same rule `parseIslandAddress` follows: an unreadable theme must still show the component. | ||
| */ | ||
| const usableThemes = (themes: readonly IslandTheme[] | undefined): readonly IslandTheme[] => { | ||
| const known = (themes ?? []).filter((theme) => ISLAND_THEMES.includes(theme)); | ||
| const unique = [...new Set(known)]; | ||
| return unique.length > 0 ? unique : ISLAND_THEMES; | ||
| }; | ||
| /** Frozen all the way down: a harness that mutated props would poison every later picture. */ | ||
| function freezeJson<T>(value: T): T { | ||
| if (typeof value !== 'object' || value === null) return value; | ||
| for (const entry of Object.values(value)) freezeJson(entry); | ||
| return Object.freeze(value); | ||
| } | ||
| function checkClock(decl: IslandStatesDecl): void { | ||
| if (decl.timeZone !== undefined && !isTimeZone(decl.timeZone)) { | ||
| throw new IslandStateZoneInvalidError({ island: decl.island, value: decl.timeZone }); | ||
| } | ||
| if (decl.now !== undefined && !isPinnedInstant(decl.now)) { | ||
| throw new IslandStateInstantInvalidError({ island: decl.island, value: decl.now }); | ||
| } | ||
| } | ||
| function normalizeState( | ||
| decl: IslandStatesDecl, | ||
| state: IslandStateDecl, | ||
| seen: Set<string>, | ||
| inherited: IslandViewport, | ||
| ): IslandState { | ||
| if (!isStateId(state.id)) { | ||
| throw new IslandStateIdInvalidError({ | ||
| island: decl.island, | ||
| id: state.id, | ||
| slug: slugifyStateId(state.id), | ||
| }); | ||
| } | ||
| if (seen.has(state.id)) | ||
| throw new IslandStateDuplicateError({ island: decl.island, id: state.id }); | ||
| seen.add(state.id); | ||
| const propsFault = jsonFault(state.props, `state "${state.id}" props`); | ||
| if (propsFault !== undefined) { | ||
| throw new IslandStateJsonInvalidError({ island: decl.island, ...propsFault }); | ||
| } | ||
| const routes = state.routes ?? []; | ||
| for (const [index, stub] of routes.entries()) { | ||
| if (!isStubMatch(stub.match)) { | ||
| throw new IslandStateStubInvalidError({ island: decl.island, match: stub.match }); | ||
| } | ||
| if (stub.respond.kind !== 'json') continue; | ||
| const bodyFault = jsonFault( | ||
| stub.respond.body, | ||
| `state "${state.id}" routes[${index}].respond.body`, | ||
| ); | ||
| if (bodyFault !== undefined) { | ||
| throw new IslandStateJsonInvalidError({ island: decl.island, ...bodyFault }); | ||
| } | ||
| } | ||
| return { | ||
| id: state.id, | ||
| title: state.title, | ||
| ...(state.note === undefined ? {} : { note: state.note }), | ||
| props: state.props, | ||
| routes, | ||
| viewport: usableViewport(state.viewport) ?? inherited, | ||
| themes: usableThemes(state.themes), | ||
| }; | ||
| } | ||
| /** | ||
| * Declare the states one island can be photographed in. Validated here rather than by the command | ||
| * that takes the pictures, because a manifest that is only checked at capture time is a manifest | ||
| * whose defects are found by a browser — long after the file that has to change was in view. | ||
| */ | ||
| export function defineIslandStates(decl: IslandStatesDecl): IslandStatesManifest { | ||
| if (decl.states.length === 0) throw new IslandStatesEmptyError({ island: decl.island }); | ||
| checkClock(decl); | ||
| const viewport = usableViewport(decl.viewport) ?? DEFAULT_ISLAND_VIEWPORT; | ||
| const seen = new Set<string>(); | ||
| const states = decl.states.map((state) => normalizeState(decl, state, seen, viewport)); | ||
| return freezeJson({ | ||
| [ISLAND_STATES]: true, | ||
| name: islandStatesName(decl.island), | ||
| island: decl.island, | ||
| states, | ||
| viewport, | ||
| ...(decl.target === undefined ? {} : { target: decl.target }), | ||
| timeZone: decl.timeZone ?? ISLAND_SHOT_TIME_ZONE, | ||
| now: decl.now ?? DEFAULT_NOW, | ||
| }); | ||
| } |
| // The expansion: one manifest in, one record per PICTURE out. Pure, and deliberately total — the | ||
| // command that photographs them knows the complete expected file list before a browser exists, so | ||
| // "produced nothing and exited 0" is a state it can refuse rather than a state it can report. | ||
| import type { IslandStatesManifest, IslandTheme, IslandViewport } from './island-states'; | ||
| import { DEFAULT_ISLAND_THEME, ISLAND_THEMES } from './island-states'; | ||
| export interface IslandShotTarget { | ||
| /** App-root-relative path of the `.island.tsx`, as the manifest declared it. */ | ||
| readonly island: string; | ||
| /** The manifest's own name — the shot directory, and what a reader types to ask for it. */ | ||
| readonly name: string; | ||
| readonly state: string; | ||
| readonly theme: IslandTheme; | ||
| readonly viewport: IslandViewport; | ||
| /** CSS selector to crop to. Absent means the island's host element. */ | ||
| readonly target?: string; | ||
| readonly timeZone: string; | ||
| readonly now: string; | ||
| /** `<name>/<state>-<theme>.png` — flat and mechanical, because the reader GUESSES this path. */ | ||
| readonly file: string; | ||
| /** The harness address that renders exactly this picture. `parseIslandAddress` is its inverse. */ | ||
| readonly query: string; | ||
| } | ||
| export const islandShotFile = (name: string, state: string, theme: IslandTheme): string => | ||
| `${name}/${state}-${theme}.png`; | ||
| export interface IslandAddress { | ||
| readonly island: string; | ||
| readonly state: string; | ||
| readonly theme: IslandTheme; | ||
| } | ||
| /** | ||
| * The address as a query string, `?` included, so it appends to any harness URL. Ordered | ||
| * island → state → theme and never sorted: an address is read by people as often as by code. | ||
| */ | ||
| export function islandAddress(address: IslandAddress): string { | ||
| const params = new URLSearchParams([ | ||
| ['island', address.island], | ||
| ['state', address.state], | ||
| ['theme', address.theme], | ||
| ]); | ||
| return `?${params.toString()}`; | ||
| } | ||
| /** | ||
| * The inverse, made TOTAL. Every failure falls back rather than throwing: a mistyped theme, a | ||
| * missing key or an empty string all still answer an address, because the harness that reads this | ||
| * is a page — and a page that renders an error instead of the component turns a typo into a | ||
| * screenshot of the framework. The one refusal in this design is `findIslandStates`, which names | ||
| * every valid island when it cannot resolve one. | ||
| */ | ||
| export function parseIslandAddress(query: string): IslandAddress { | ||
| const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query); | ||
| const theme = params.get('theme'); | ||
| return { | ||
| island: params.get('island') ?? '', | ||
| state: params.get('state') ?? '', | ||
| theme: isIslandTheme(theme) ? theme : DEFAULT_ISLAND_THEME, | ||
| }; | ||
| } | ||
| export function isIslandTheme(value: unknown): value is IslandTheme { | ||
| return typeof value === 'string' && ISLAND_THEMES.includes(value as IslandTheme); | ||
| } | ||
| /** | ||
| * Every picture this manifest asks for, state by state and theme by theme in declaration order. | ||
| * `selector` is the caller's override for the manifest's own `target` — a flag beats a file — and | ||
| * absent means "whatever the manifest said", never "crop nothing". | ||
| */ | ||
| export function islandShotTargets( | ||
| manifest: IslandStatesManifest, | ||
| selector?: string, | ||
| ): readonly IslandShotTarget[] { | ||
| const target = selector ?? manifest.target; | ||
| return manifest.states.flatMap((state) => | ||
| state.themes.map((theme) => ({ | ||
| island: manifest.island, | ||
| name: manifest.name, | ||
| state: state.id, | ||
| theme, | ||
| viewport: state.viewport, | ||
| ...(target === undefined ? {} : { target }), | ||
| timeZone: manifest.timeZone, | ||
| now: manifest.now, | ||
| file: islandShotFile(manifest.name, state.id, theme), | ||
| query: islandAddress({ island: manifest.island, state: state.id, theme }), | ||
| })), | ||
| ); | ||
| } | ||
| /** The same expansion over a whole set, in the order the manifests arrived. */ | ||
| export function islandShotPlan( | ||
| manifests: readonly IslandStatesManifest[], | ||
| selector?: string, | ||
| ): readonly IslandShotTarget[] { | ||
| return manifests.flatMap((manifest) => islandShotTargets(manifest, selector)); | ||
| } |
| // The ten X_TEST_ISLAND_STATE* codes, apart from ./errors only because one file has one job and the | ||
| // catalogue is already at its ceiling. The codes themselves, their titles and the single | ||
| // `registerErrorCodes` call stay in ./errors — one owner, one registration, one place a duplicate | ||
| // can surface. | ||
| import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core'; | ||
| // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`. | ||
| /** Neither path is controlled by the framework: both arrive from an app's own declaration. */ | ||
| const ISLAND_PLACEHOLDER = '<the island path the cause names>'; | ||
| /** The same, for the three refusals whose subject is the states file rather than the island. */ | ||
| const STATES_PLACEHOLDER = '<the states file the cause names>'; | ||
| /** | ||
| * The file a `defineIslandStates` call for this island lives in, by convention: the island's own | ||
| * name with `.states.ts` where `.tsx` was. Spelled here, in the leaf module, because every error | ||
| * below has to name the file the reader must edit and an error may never import the module that | ||
| * throws it. Only `.tsx` is read — the island EXTENSION is `@ultimat3/render`'s constant and is | ||
| * deliberately not restated in this package. | ||
| */ | ||
| export function islandStatesFile(island: string): string { | ||
| return island.endsWith('.tsx') | ||
| ? `${island.slice(0, -'.tsx'.length)}.states.ts` | ||
| : `${island}.states.ts`; | ||
| } | ||
| const at = (island: string): string => | ||
| renderFixLiteral(islandStatesFile(island), ISLAND_PLACEHOLDER); | ||
| /** | ||
| * A manifest that declares no states. It parses, it registers and it expands to nothing — so the | ||
| * command that photographs it produces no file and exits 0, which is the one outcome a reader | ||
| * cannot tell from success. Refused at the declaration, where the file to edit is known. | ||
| */ | ||
| export class IslandStatesEmptyError extends UltimateError { | ||
| constructor(input: { readonly island: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_EMPTY', | ||
| cause: `defineIslandStates(${renderCauseValue(input.island)}) declares no states, so it can never produce a picture`, | ||
| fix: `in ${at(input.island)} add: states: [{ id: 'empty', title: 'no rows yet', props: {} }]`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A state id that is not a slug. The id becomes a FILENAME stem, so a space, a slash or a capital | ||
| * is either an unguessable path or a path outside the shot directory. The suggestion is the id | ||
| * slugified, so the edit is a paste rather than a decision. | ||
| */ | ||
| export class IslandStateIdInvalidError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly id: string; readonly slug: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATE_ID_INVALID', | ||
| cause: `island state id ${renderCauseValue(input.id)} is not a slug — it becomes the screenshot filename stem`, | ||
| fix: | ||
| input.slug.length === 0 | ||
| ? `in ${at(input.island)} give the state an id of lowercase letters, digits and single dashes: id: 'over-quota'` | ||
| : `in ${at(input.island)} write: id: '${input.slug}'`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * Two states with one id. The second picture overwrites the first at the same path, so the run | ||
| * reports two states and leaves one file — a loss with nothing in the output pointing at it. | ||
| */ | ||
| export class IslandStateDuplicateError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly id: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATE_DUPLICATE', | ||
| cause: `two island states share the id ${renderCauseValue(input.id)}; the second picture would overwrite the first`, | ||
| fix: `in ${at(input.island)} rename one of them — id: '${input.id}-2', or the state it really is: id: 'over-quota'`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A value that `JSON.stringify` does not carry. Island props ride the `data-x-props` script tag, | ||
| * which is JSON — so a `Date`, a function, a `Map` or an `undefined` is not "slightly wrong" in the | ||
| * picture, it is a prop the component never receives. Refused where the path is still known. | ||
| */ | ||
| export class IslandStateJsonInvalidError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly path: string; readonly reason: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATE_JSON_INVALID', | ||
| cause: `${input.path} is ${input.reason}, and island props travel as JSON in data-x-props`, | ||
| fix: `in ${at(input.island)} write ${input.path} as JSON — a string, a number, a boolean, null, an array or a plain object`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * The zone a picture is rendered in. A harness that freezes the INSTANT and leaves the zone ambient | ||
| * renders every date in the host machine's, so the same state photographs differently on two | ||
| * machines and the review diff reports a component change that never happened. | ||
| * | ||
| * Its own class beside the one below, sharing one code: two conditions, two instructions, and a | ||
| * `fix:` assembled by a ternary is a `fix:` the gate's own scanner reads only half of. | ||
| */ | ||
| export class IslandStateZoneInvalidError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly value: unknown }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATE_CLOCK_INVALID', | ||
| cause: `timeZone ${renderCauseValue(input.value)} is not an IANA zone, so a rendered date would fall back to the host's`, | ||
| fix: `in ${at(input.island)} write: timeZone: 'UTC' — or one of Intl.supportedValuesOf('timeZone')`, | ||
| }); | ||
| } | ||
| } | ||
| /** The frozen instant, with no offset on it — a different moment on every machine that reads it. */ | ||
| export class IslandStateInstantInvalidError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly value: unknown }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATE_CLOCK_INVALID', | ||
| cause: `now ${renderCauseValue(input.value)} carries no explicit offset, so it means a different moment on every machine`, | ||
| fix: `in ${at(input.island)} write: now: '2026-01-01T00:00:00.000Z' — an ISO instant ending in Z or an offset`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A route stub whose `match` cannot match. It is a `"<METHOD> <pathname>"` prefix, so a bare path | ||
| * or a lowercase verb silently stubs nothing: the component fetches for real, the request is | ||
| * refused by the sealed network, and the picture shows an error state nobody declared. | ||
| */ | ||
| export class IslandStateStubInvalidError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly match: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATE_STUB_INVALID', | ||
| cause: `route stub ${renderCauseValue(input.match)} is not "<METHOD> <pathname>", so it would match no request`, | ||
| fix: `in ${at(input.island)} write: { match: 'GET /api/settings', respond: { kind: 'json', body: {} } }`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A states file that imports the component it describes. The whole design rests on this file being | ||
| * readable from Bun with no browser and no bundle — the command must know the complete expected | ||
| * screenshot list BEFORE a browser exists, or "produced nothing and exited 0" is indistinguishable | ||
| * from success. One JSX import makes the file unreadable by every consumer but the browser. | ||
| */ | ||
| export class IslandStatesNotPureError extends UltimateError { | ||
| constructor(input: { readonly file: string; readonly specifier: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_NOT_PURE', | ||
| cause: `${renderCauseValue(input.file)} imports ${renderCauseValue(input.specifier)} — a states file is pure data and is read without a browser`, | ||
| fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} delete the import of ${renderCauseValue(input.specifier)} and declare the props as JSON instead`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A states file that value-imports a SIBLING module. Its own class beside the one above, sharing | ||
| * one code, because the edit is a different one: `./settings.island` resolves to | ||
| * `./settings.island.tsx` under Bun, so the import the reader must repair is usually a missing | ||
| * `type` keyword rather than an import to delete — and `./helpers` may reach the component one hop | ||
| * further on, which a rule reading ONE file's text can never follow. | ||
| */ | ||
| export class IslandStatesSiblingImportError extends UltimateError { | ||
| constructor(input: { readonly file: string; readonly specifier: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_NOT_PURE', | ||
| cause: `${renderCauseValue(input.file)} imports ${renderCauseValue(input.specifier)} at runtime, and a sibling module can reach the component the states file may not`, | ||
| fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} write: import type { Props } from ${renderFixLiteral(input.specifier, '<the specifier the cause names>')} — a type-only import is erased, and a value one must be inlined as JSON`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A specifier this scanner cannot read: `import(`./${name}.island`)`, `require(SPEC)`. Its own | ||
| * class for the same reason as the one above — the edit is to write the specifier as a literal, so | ||
| * a static reader can judge it. Answering PURE over an unreadable import is the optimism the | ||
| * extensionless case already shipped once. | ||
| */ | ||
| export class IslandStatesOpaqueImportError extends UltimateError { | ||
| constructor(input: { readonly file: string; readonly expression: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_NOT_PURE', | ||
| cause: `${renderCauseValue(input.file)} computes the import ${renderCauseValue(input.expression)}, so no reader can say where it goes without running it`, | ||
| fix: `in ${renderFixLiteral(input.file, STATES_PLACEHOLDER)} write the specifier as a string literal, or delete the import and inline what it exports as JSON`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A declared island that is not on disk. Always a real defect and never a warning: the state list | ||
| * is the expected screenshot set, so a manifest pointing at a moved file expands to pictures that | ||
| * can never be taken. | ||
| */ | ||
| export class IslandStatesMissingFileError extends UltimateError { | ||
| constructor(input: { readonly island: string; readonly root: string }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_MISSING_FILE', | ||
| cause: `no file at ${renderCauseValue(input.island)} under ${renderCauseValue(input.root)}, so its declared states can never be photographed`, | ||
| fix: `in ${at(input.island)} set island to the path that exists — it is relative to the app root, not to the states file`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A name nothing answers to. Listing every valid name is the whole value: a typo and an island | ||
| * whose states were never declared are one symptom and two different edits, and only the list tells | ||
| * them apart without opening a directory. | ||
| */ | ||
| export class IslandStatesUnknownError extends UltimateError { | ||
| constructor(input: { readonly name: string; readonly known: readonly string[] }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_UNKNOWN', | ||
| cause: | ||
| input.known.length === 0 | ||
| ? `no island states are declared in this process, so ${renderCauseValue(input.name)} resolves to nothing` | ||
| : `no island states answer to ${renderCauseValue(input.name)}; declared: ${input.known.join(', ')}`, | ||
| fix: | ||
| input.known.length === 0 | ||
| ? "declare one beside the island: export const states = defineIslandStates({ island: 'apps/web/app/settings/settings.island.tsx', states: [...] })" | ||
| : `name one of them instead: ${input.known[0] ?? ''}`, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * Two manifests answering to one name. Resolution is loose on purpose — `Settings`, `settings` and | ||
| * `settings.island.tsx` are one name — and that looseness is exactly what makes two islands with | ||
| * the same basename ambiguous. Refused when the set is loaded, not when a picture is missing. | ||
| */ | ||
| export class IslandStatesAmbiguousError extends UltimateError { | ||
| constructor(input: { readonly name: string; readonly islands: readonly string[] }) { | ||
| super({ | ||
| code: 'X_TEST_ISLAND_STATES_AMBIGUOUS', | ||
| cause: `${input.islands.length} islands answer to ${renderCauseValue(input.name)}: ${input.islands.join(', ')}`, | ||
| fix: `rename one island file so the two basenames differ — the basename is the shot directory, so today they would share one`, | ||
| }); | ||
| } | ||
| } |
| // The rules a declared island state must satisfy, as pure functions over values. Separate from | ||
| // `island-states.ts` so the vocabulary can be read without the rules and the rules can be tested | ||
| // without building a manifest; each answers a FAULT rather than throwing, so the caller is the one | ||
| // place that decides which code the failure carries. | ||
| /** A slug: lowercase letters and digits, single dashes between them. It becomes a filename stem. */ | ||
| const STATE_ID = /^[a-z\d]+(?:-[a-z\d]+)*$/; | ||
| export const isStateId = (id: string): boolean => STATE_ID.test(id); | ||
| /** The id the author probably meant — `''` when nothing survives, which the error reads as "no suggestion". */ | ||
| export function slugifyStateId(id: string): string { | ||
| return id | ||
| .toLowerCase() | ||
| .replace(/[^a-z\d]+/g, '-') | ||
| .replace(/^-+|-+$/g, ''); | ||
| } | ||
| /** | ||
| * An instant with an EXPLICIT offset. `2026-01-01T00:00` parses on every runtime and means a | ||
| * different moment in every zone, which is the defect this vocabulary exists to close: a harness | ||
| * that pins the instant and leaves the zone ambient photographs two different pictures on two | ||
| * machines and neither is wrong. | ||
| */ | ||
| const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; | ||
| export function isPinnedInstant(value: string): boolean { | ||
| return INSTANT.test(value) && Number.isFinite(Date.parse(value)); | ||
| } | ||
| /** | ||
| * An IANA zone, asked of the runtime rather than of a list: `Intl` is the thing that will format | ||
| * the date, so its answer is the only one that matters. It throws a `RangeError` on a name it does | ||
| * not know — the one case where a `catch` is the check. | ||
| */ | ||
| export function isTimeZone(zone: string): boolean { | ||
| try { | ||
| new Intl.DateTimeFormat('en-US', { timeZone: zone }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** `"<METHOD> <pathname>"`, matched as a prefix by the harness. A lowercase verb matches nothing. */ | ||
| const STUB_MATCH = /^[A-Z]+ \/\S*$/; | ||
| export const isStubMatch = (match: string): boolean => STUB_MATCH.test(match); | ||
| export interface JsonFault { | ||
| /** Where in the declaration the value sits — `props.user.createdAt`, `routes[0].respond.body`. */ | ||
| readonly path: string; | ||
| /** What it is, phrased to finish "…is <reason>". */ | ||
| readonly reason: string; | ||
| } | ||
| /** `[object Date]` → `Date`. Read this way because a `constructor.name` getter can throw. */ | ||
| const tagOf = (value: object): string => | ||
| Object.prototype.toString.call(value).slice('[object '.length, -1); | ||
| /** | ||
| * The first value in `value` that `JSON.stringify` would not carry, or `undefined` when the whole | ||
| * structure survives the trip. Island props ride `data-x-props`, which is JSON by construction | ||
| * (`@ultimat3/render`'s `emitIslandProps`), so anything else is not "approximately right" in the | ||
| * picture — it is a prop the component never receives, silently. | ||
| * | ||
| * Stricter than `JSON.stringify` on purpose, in the three places it degrades instead of failing: | ||
| * `undefined` disappears, a non-finite number becomes `null`, and a `Date` becomes a string that no | ||
| * longer answers `.getTime()`. Each is a state that photographs as a crash with nothing pointing | ||
| * back at the declaration. | ||
| */ | ||
| export function jsonFault( | ||
| value: unknown, | ||
| path: string, | ||
| seen: readonly object[] = [], | ||
| ): JsonFault | undefined { | ||
| if (value === null) return undefined; | ||
| switch (typeof value) { | ||
| case 'string': | ||
| case 'boolean': | ||
| return undefined; | ||
| case 'number': | ||
| return Number.isFinite(value) ? undefined : { path, reason: 'not a finite number' }; | ||
| case 'undefined': | ||
| return { path, reason: 'undefined, which JSON drops without a trace' }; | ||
| case 'bigint': | ||
| return { path, reason: 'a bigint, which JSON cannot carry' }; | ||
| case 'symbol': | ||
| return { path, reason: 'a symbol' }; | ||
| case 'function': | ||
| return { path, reason: 'a function — a picture takes no callbacks' }; | ||
| default: | ||
| break; | ||
| } | ||
| const object = value as object; | ||
| if (seen.includes(object)) return { path, reason: 'a cycle' }; | ||
| const next = [...seen, object]; | ||
| if (Array.isArray(object)) { | ||
| for (const [index, item] of object.entries()) { | ||
| const fault = jsonFault(item, `${path}[${index}]`, next); | ||
| if (fault !== undefined) return fault; | ||
| } | ||
| return undefined; | ||
| } | ||
| const proto: unknown = Object.getPrototypeOf(object); | ||
| if (proto !== Object.prototype && proto !== null) { | ||
| return { path, reason: `a ${tagOf(object)}, and only a plain object survives JSON` }; | ||
| } | ||
| for (const [key, entry] of Object.entries(object)) { | ||
| const fault = jsonFault(entry, `${path}.${key}`, next); | ||
| if (fault !== undefined) return fault; | ||
| } | ||
| return undefined; | ||
| } |
| // The guard the whole design rests on: a `*.island.states.ts` file is PURE DATA, so the command | ||
| // that photographs the states, the harness page and a test can all read it — and one import of a | ||
| // sibling module leaves it readable by a bundler alone. Static, because a module importing Solid | ||
| // evaluates fine under Bun, so no amount of loading it can notice. | ||
| import { | ||
| IslandStatesNotPureError, | ||
| IslandStatesOpaqueImportError, | ||
| IslandStatesSiblingImportError, | ||
| } from './island-state-errors'; | ||
| /** Line and block comments blanked, so a specifier written in prose is never read as an import. */ | ||
| const stripComments = (source: string): string => | ||
| source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); | ||
| /** | ||
| * One edge of the file's module graph, carrying the single distinction that decides whether the | ||
| * edge EXISTS at runtime: `verbatimModuleSyntax` erases a statement that begins `import type` / | ||
| * `export type` and keeps every other one — including `import { type X } from './y'`, which it | ||
| * emits as `import {} from './y'` and which therefore evaluates `./y`. | ||
| */ | ||
| export interface ModuleEdge { | ||
| readonly specifier: string; | ||
| readonly typeOnly: boolean; | ||
| } | ||
| /** | ||
| * `from '<spec>'`, `import '<spec>'`, `import('<spec>')`, `require('<spec>')` — every static and | ||
| * dynamic edge a Bun module graph can have, read off the text rather than by loading it. The | ||
| * leading `type` is captured with the statement rather than guessed at afterwards; the lookahead | ||
| * is what keeps `import type from './y'` (a default import NAMED type) a value import. | ||
| */ | ||
| const EDGE = | ||
| /\b(?:import|export)\s+(?<only>type\s+(?!from\s*['"]))?[^'"();]*?\bfrom\s*['"](?<from>[^'"]+)['"]|\bimport\s*\(\s*['"](?<dynamic>[^'"]+)['"]|\brequire\s*\(\s*['"](?<required>[^'"]+)['"]|\bimport\s+['"](?<bare>[^'"]+)['"]/g; | ||
| /** Every module edge the text declares, in source order, type-only ones included. */ | ||
| export function moduleEdges(source: string): readonly ModuleEdge[] { | ||
| const edges: ModuleEdge[] = []; | ||
| for (const match of stripComments(source).matchAll(EDGE)) { | ||
| const groups = match.groups; | ||
| if (groups === undefined) continue; | ||
| const from = groups['from']; | ||
| const specifier = from ?? groups['dynamic'] ?? groups['required'] ?? groups['bare']; | ||
| if (specifier === undefined) continue; | ||
| edges.push({ specifier, typeOnly: from !== undefined && groups['only'] !== undefined }); | ||
| } | ||
| return edges; | ||
| } | ||
| /** Backwards-compatible view: the specifiers alone, which cannot say whether one is erased. */ | ||
| export function importSpecifiers(source: string): readonly string[] { | ||
| return moduleEdges(source).map((edge) => edge.specifier); | ||
| } | ||
| /** | ||
| * An `import(…)` / `require(…)` whose argument is not a string literal. Captured up to the closing | ||
| * paren or the end of the line, so the refusal can quote the expression the reader must rewrite. | ||
| */ | ||
| const COMPUTED = /\b(?:import|require)\s*\(\s*(?!['")])(?<expression>[^)\n]{1,60})/g; | ||
| /** A specifier resolved against this file's own directory, `.json` excluded — see below. */ | ||
| const RELATIVE = /^\.\.?\//; | ||
| /** | ||
| * A specifier that is PROVABLY a browser module: JSX, or the renderer one import earlier. Its own | ||
| * predicate because it is what the older refusal — delete this import — is the right edit for. | ||
| */ | ||
| export function browserSpecifier(specifier: string): boolean { | ||
| return ( | ||
| specifier === 'solid-js' || | ||
| specifier.startsWith('solid-js/') || | ||
| specifier.endsWith('.tsx') || | ||
| specifier.endsWith('.jsx') | ||
| ); | ||
| } | ||
| /** | ||
| * What a states file may not reach for at RUNTIME, which is a wider set than the one above. | ||
| * | ||
| * Any RELATIVE specifier counts, and that is the rule with teeth: it names a module whose own | ||
| * imports this scanner never sees. `./settings.island` resolves to `./settings.island.tsx` under | ||
| * Bun and read as pure until 2026-08-23 — an extension test answers about the spelling and not | ||
| * about the graph — and `./helpers` is the same edge one hop longer. The relativeness is the rule | ||
| * rather than the `.island` stem, which is also why nothing here restates `@ultimat3/render`'s | ||
| * `ISLAND_EXTENSION`. | ||
| * | ||
| * `.json` is the one exemption, and it is the only one that can be safe: a JSON module has no | ||
| * imports, so it is the single relative target whose graph needs no following. | ||
| * | ||
| * A BARE specifier other than `solid-js` is not followed either and is deliberately not refused — | ||
| * a package list here would be the drift a list always is. `@ultimat3/ui` in a states file is | ||
| * therefore an open hole, named in this package's CLAUDE.md rather than left silent. | ||
| */ | ||
| export function impureSpecifier(specifier: string): boolean { | ||
| return browserSpecifier(specifier) || (RELATIVE.test(specifier) && !specifier.endsWith('.json')); | ||
| } | ||
| /** Which refusal the fault carries — the reader's edit differs by kind, so the class does too. */ | ||
| export type IslandFaultKind = 'browser' | 'sibling' | 'opaque'; | ||
| export interface IslandStatesFault { | ||
| readonly kind: IslandFaultKind; | ||
| /** The specifier, or — for `opaque` — the expression text that stands where one should be. */ | ||
| readonly specifier: string; | ||
| } | ||
| /** | ||
| * The first thing that breaks the rule, or `undefined`. NOT total: a specifier this cannot read is | ||
| * reported as `opaque` rather than passed, because "unreadable, therefore pure" is exactly the | ||
| * optimism that let an extensionless import of the component through. | ||
| */ | ||
| export function islandStatesFault(source: string): IslandStatesFault | undefined { | ||
| for (const edge of moduleEdges(source)) { | ||
| // A type-only edge is erased before the module is ever evaluated — proved against Bun in | ||
| // `island-states-pure.test.ts`, because the whole exemption rests on it. It is also the one | ||
| // way a states file may name its component, and the reference app types its props that way. | ||
| if (edge.typeOnly) continue; | ||
| if (!impureSpecifier(edge.specifier)) continue; | ||
| // A specifier that is PROVABLY a browser module keeps the older refusal and its older edit — | ||
| // deleting `import './x.island.tsx'` is the instruction, not writing it as a type import. | ||
| return { | ||
| kind: browserSpecifier(edge.specifier) ? 'browser' : 'sibling', | ||
| specifier: edge.specifier, | ||
| }; | ||
| } | ||
| const computed = stripComments(source).match(COMPUTED); | ||
| const expression = computed?.[0]?.replace(/^\s*(?:import|require)\s*\(\s*/, '').trim(); | ||
| return expression === undefined ? undefined : { kind: 'opaque', specifier: expression }; | ||
| } | ||
| /** The first specifier that breaks the rule, or `undefined`. */ | ||
| export function islandStatesImportFault(source: string): string | undefined { | ||
| return islandStatesFault(source)?.specifier; | ||
| } | ||
| /** The same rule, as a refusal. `file` is only ever named back to the reader, never resolved here. */ | ||
| export function assertIslandStatesPure(file: string, source: string): void { | ||
| const fault = islandStatesFault(source); | ||
| if (fault === undefined) return; | ||
| if (fault.kind === 'opaque') { | ||
| throw new IslandStatesOpaqueImportError({ file, expression: fault.specifier }); | ||
| } | ||
| const input = { file, specifier: fault.specifier }; | ||
| throw fault.kind === 'sibling' | ||
| ? new IslandStatesSiblingImportError(input) | ||
| : new IslandStatesNotPureError(input); | ||
| } |
| // Resolution, in the two directions a caller needs it: a NAME a reader typed → the manifest it | ||
| // meant, and a manifest → the island file it claims on disk. Loose on the way in and strict on the | ||
| // way out — a typo must never be a silent miss, so an unresolved name lists every valid one. | ||
| import { join } from 'node:path'; // why: no Bun native joins a path; `Bun.file` takes one already joined. | ||
| import { | ||
| IslandStatesAmbiguousError, | ||
| IslandStatesMissingFileError, | ||
| IslandStatesUnknownError, | ||
| } from './island-state-errors'; | ||
| import type { IslandStatesManifest } from './island-states'; | ||
| /** | ||
| * `Settings`, `settings`, `settings.island.tsx` and `apps/web/app/settings/settings.island.tsx` are | ||
| * one name. Case and separators are dropped because a reader types the component the way it is | ||
| * spelled in JSX and the file the way it is spelled on disk, and neither is wrong. | ||
| */ | ||
| export function normalizeIslandName(value: string): string { | ||
| const base = value.split('/').pop() ?? value; | ||
| const stem = base.split('.')[0] ?? base; | ||
| return stem.toLowerCase().replace(/[^a-z\d]+/g, ''); | ||
| } | ||
| /** Every name a reader may type, in manifest order — what an unresolved lookup reports back. */ | ||
| export const islandStatesNames = (all: readonly IslandStatesManifest[]): readonly string[] => | ||
| all.map((manifest) => manifest.name); | ||
| /** Manifests answering to `name`. Zero and two are both failures, and different ones. */ | ||
| export function islandStatesMatching( | ||
| all: readonly IslandStatesManifest[], | ||
| name: string, | ||
| ): readonly IslandStatesManifest[] { | ||
| const wanted = normalizeIslandName(name); | ||
| return all.filter((manifest) => normalizeIslandName(manifest.name) === wanted); | ||
| } | ||
| /** | ||
| * The one refusal in this vocabulary. Everything downstream falls back — a mistyped THEME still | ||
| * shows the component — but a name nothing answers to has no defensible fallback: photographing | ||
| * some other island would be a picture that reads as an answer. | ||
| */ | ||
| export function findIslandStates( | ||
| all: readonly IslandStatesManifest[], | ||
| name: string, | ||
| ): IslandStatesManifest { | ||
| const matches = islandStatesMatching(all, name); | ||
| const first = matches[0]; | ||
| if (first === undefined) { | ||
| throw new IslandStatesUnknownError({ name, known: islandStatesNames(all) }); | ||
| } | ||
| if (matches.length > 1) { | ||
| throw new IslandStatesAmbiguousError({ | ||
| name, | ||
| islands: matches.map((manifest) => manifest.island), | ||
| }); | ||
| } | ||
| return first; | ||
| } | ||
| /** | ||
| * Two manifests one name would resolve to. Asked of the whole SET, once, rather than discovered by | ||
| * a lookup that happens to be made: two islands sharing a basename also share a shot directory, so | ||
| * the collision loses pictures whether or not anyone ever types the ambiguous name. | ||
| */ | ||
| export function assertUniqueIslandStates(all: readonly IslandStatesManifest[]): void { | ||
| const byName = new Map<string, IslandStatesManifest[]>(); | ||
| for (const manifest of all) { | ||
| const key = normalizeIslandName(manifest.name); | ||
| byName.set(key, [...(byName.get(key) ?? []), manifest]); | ||
| } | ||
| for (const [name, group] of byName) { | ||
| if (group.length > 1) { | ||
| throw new IslandStatesAmbiguousError({ | ||
| name, | ||
| islands: group.map((manifest) => manifest.island), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Declared islands with no file under `root`. Not part of `defineIslandStates`: a declaration is | ||
| * evaluated wherever the module is imported from, and a rule that reads the filesystem at import | ||
| * time fails on the cwd rather than on the path. The check belongs where a root is known — the | ||
| * guard test, and the command that takes the pictures. | ||
| */ | ||
| export async function missingIslandFiles( | ||
| all: readonly IslandStatesManifest[], | ||
| root: string, | ||
| ): Promise<readonly string[]> { | ||
| const missing: string[] = []; | ||
| for (const manifest of all) { | ||
| if (!(await Bun.file(join(root, manifest.island)).exists())) missing.push(manifest.island); | ||
| } | ||
| return missing; | ||
| } | ||
| /** The same check as a refusal, naming the first island that is not there. */ | ||
| export async function assertIslandFiles( | ||
| all: readonly IslandStatesManifest[], | ||
| root: string, | ||
| ): Promise<void> { | ||
| const missing = await missingIslandFiles(all, root); | ||
| const first = missing[0]; | ||
| if (first !== undefined) throw new IslandStatesMissingFileError({ island: first, root }); | ||
| } |
| // The vocabulary for declaring the STATES one island can be photographed in — error, empty, | ||
| // over-quota, read-only: the ones a reviewer cannot reach by clicking. Types and constants only, so | ||
| // a `settings.island.states.ts` beside the island is readable without a browser or a bundle; | ||
| // `define-island-states.ts` is what validates one, and nothing here imports anything at all. | ||
| export const ISLAND_THEMES = ['light', 'dark'] as const; | ||
| export type IslandTheme = (typeof ISLAND_THEMES)[number]; | ||
| /** What an address falls back to. A mistyped theme shows the component, never an error page. */ | ||
| export const DEFAULT_ISLAND_THEME: IslandTheme = 'light'; | ||
| export interface IslandViewport { | ||
| readonly width: number; | ||
| readonly height: number; | ||
| } | ||
| /** A laptop, not a phone: the state under review is the component's, not the breakpoint's. */ | ||
| export const DEFAULT_ISLAND_VIEWPORT: IslandViewport = { width: 1280, height: 800 }; | ||
| /** | ||
| * The zone every picture is rendered in unless a manifest says otherwise. Pinned in the VOCABULARY | ||
| * rather than left to the harness: a run that freezes the instant and leaves the zone ambient | ||
| * renders `12:00` on one machine and `14:00` on the next, and the diff between two reviews then | ||
| * says the component changed when only the reviewer moved. | ||
| */ | ||
| export const ISLAND_SHOT_TIME_ZONE = 'UTC'; | ||
| export type IslandStubResponse = | ||
| | { readonly kind: 'json'; readonly status?: number; readonly body: unknown } | ||
| | { readonly kind: 'pending' } | ||
| | { readonly kind: 'offline' }; | ||
| export interface IslandRouteStub { | ||
| /** `"<METHOD> <pathname>"`, matched as a PREFIX: `'GET /api/quota'` catches its query string. */ | ||
| readonly match: string; | ||
| readonly respond: IslandStubResponse; | ||
| } | ||
| export interface IslandStateDecl { | ||
| /** A slug. It becomes the screenshot filename stem, so it is guessable or it is nothing. */ | ||
| readonly id: string; | ||
| /** One line: what this state IS. */ | ||
| readonly title: string; | ||
| /** WHY it deserves a picture — usually "you cannot reach this by clicking, because …". */ | ||
| readonly note?: string; | ||
| /** JSON, by construction: these ride the same `data-x-props` seam hydration already uses. */ | ||
| readonly props: Readonly<Record<string, unknown>>; | ||
| /** Fixtures for whatever the component fetches on its own. */ | ||
| readonly routes?: readonly IslandRouteStub[]; | ||
| readonly viewport?: IslandViewport; | ||
| /** Both, unless the state is only meaningful in one of them. */ | ||
| readonly themes?: readonly IslandTheme[]; | ||
| } | ||
| export interface IslandStatesDecl { | ||
| /** App-root-relative path of the `.island.tsx` these states belong to. */ | ||
| readonly island: string; | ||
| readonly states: readonly IslandStateDecl[]; | ||
| readonly viewport?: IslandViewport; | ||
| /** CSS selector to crop to. The island's host element when absent. */ | ||
| readonly target?: string; | ||
| /** IANA zone. `UTC` unless the state under review is about a zone. */ | ||
| readonly timeZone?: string; | ||
| /** The frozen instant, with an explicit offset. The suite's own `DEFAULT_NOW` when absent. */ | ||
| readonly now?: string; | ||
| } | ||
| /** A declared state with every default resolved — what a target is expanded from. */ | ||
| export interface IslandState { | ||
| readonly id: string; | ||
| readonly title: string; | ||
| readonly note?: string; | ||
| readonly props: Readonly<Record<string, unknown>>; | ||
| readonly routes: readonly IslandRouteStub[]; | ||
| readonly viewport: IslandViewport; | ||
| readonly themes: readonly IslandTheme[]; | ||
| } | ||
| /** | ||
| * Registered globally so two copies of this module agree on what a manifest is — the same reason | ||
| * `@ultimat3/render`'s island node carries one. It is what lets a loader read an app's states file | ||
| * and tell a manifest from every other export in it without trusting a name. | ||
| */ | ||
| export const ISLAND_STATES: unique symbol = Symbol.for('ultimate.testing.island-states') as never; | ||
| export interface IslandStatesManifest { | ||
| readonly [ISLAND_STATES]: true; | ||
| /** Derived from the island's own filename: the shot directory, and the name a reader types. */ | ||
| readonly name: string; | ||
| readonly island: string; | ||
| readonly states: readonly IslandState[]; | ||
| readonly viewport: IslandViewport; | ||
| readonly target?: string; | ||
| readonly timeZone: string; | ||
| readonly now: string; | ||
| } | ||
| export function isIslandStatesManifest(value: unknown): value is IslandStatesManifest { | ||
| return typeof value === 'object' && value !== null && ISLAND_STATES in value; | ||
| } | ||
| /** | ||
| * `apps/web/app/settings/settings.island.tsx` → `settings`. The basename up to its FIRST dot, so | ||
| * the island extension is never restated in this package — it is `@ultimat3/render`'s constant and | ||
| * a second copy of it here is a second thing to keep in step. | ||
| */ | ||
| export function islandStatesName(island: string): string { | ||
| const base = island.split('/').pop() ?? island; | ||
| return (base.split('.')[0] ?? base).toLowerCase(); | ||
| } |
+11
-1
@@ -63,3 +63,3 @@ # @ultimat3/testing — boundary | ||
| | 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 | | ||
| | 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 | | ||
| | The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. **"Once" is scoped to this job, and said so only from 2026-08-23**: `packages/ui/src/fake-dom.ts` is a second micro-DOM (201 lines, test-only, off that package's barrel) for a different one — focus, `activeElement`, `contains` and a `:not()` selector grammar for keyboard code, none of which parses a `<template>`. It is not a copy to collapse: `ui` is tier 4 and this package is tier 5, so `ui -> testing` is an upward import the boundary check refuses, and the merge would need the shared half moved down to a tier neither grammar belongs in. `bun test` has no DOM and no DOM library may be added; `generate: 'dom'` builds every element from `_$template("<label …>")`, so a stub without a parsed `<template>` cannot run one line of a compiled island. It lived twice, ~200 lines each, in `packages/cli/src/island-bundle.test.ts` and the reference app's island test | | ||
| | `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 | | ||
@@ -71,2 +71,12 @@ | `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 | | ||
| | `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 | | ||
| | 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 | | ||
| | The rule is the RELATIVENESS, not the extension, `As of 2026-08-23` | the scan refused `solid-js` and a specifier ENDING in `.tsx`/`.jsx`, and `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun — so the guard answered PURE for a file that drags Solid into a browser-free process, which is worse than no guard. Every RELATIVE runtime specifier is refused now (`IslandStatesSiblingImportError`), because `./helpers` reaches the component one hop further on and this scanner reads ONE file's text. `.json` is the one exemption: a JSON module has no imports, so it is the single relative target whose graph needs no following. That rule needs no copy of `ISLAND_EXTENSION` either, which is the row below still holding | | ||
| | `import type` is not an import, and it is the one way to name the component | `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type` — proved against Bun in `island-states-pure.test.ts`, which writes the pair to disk and asserts the sibling never evaluated, because the whole exemption rests on it. `examples/dummy`'s states file types its props that way and the rule may never refuse it. The other direction is the one that would leave the hole open: `import { type X } from './y'` is emitted as `import {} from './y'` and DOES evaluate `./y`, so an inline modifier is a runtime edge and is refused. Both directions are pinned | | ||
| | Unreadable is not pure | a computed specifier — ``import(`./${name}.island`)``, `require(SPEC)` — is refused as `IslandStatesOpaqueImportError` rather than passed. "A file it cannot read is pure" was this scanner's stated totality and it is the same optimism the extensionless case shipped with | | ||
| | What the scan does NOT follow, stated rather than silent | a BARE specifier other than `solid-js` (`@ultimat3/ui` re-exports Solid components and is not refused — a package list here would be the drift a list always is), an ABSOLUTE path specifier (`packages/cli`'s own test tree and this package's guard test both import the barrel by absolute path, which is the only way to reach it from a scratch directory with no `node_modules`), and a specifier inside a string LITERAL, which is read as an import. The first two are holes; the third is a false refusal, and the safe direction of the two | | ||
| | Props are JSON or they are refused | they ride `data-x-props`, which `@ultimat3/render`'s `emitIslandProps` `JSON.stringify`s, so anything else is a prop the component never receives. `jsonFault` is stricter than `JSON.stringify` in exactly the three places that DEGRADE rather than throw: `undefined` disappears, a non-finite number becomes `null`, and a `Date` becomes a string that no longer answers `.getTime()` | | ||
| | The clock is pinned in the vocabulary, zone included | `timeZone` defaults to `ISLAND_SHOT_TIME_ZONE` (`UTC`) and `now` to this package's own `DEFAULT_NOW`, and both ride onto every `IslandShotTarget`. A harness that freezes the instant and leaves the ZONE ambient photographs `12:00` on one machine and `14:00` on the next; the review diff then reports a component change that never happened | | ||
| | Loose in, strict out | `findIslandStates` resolves `Settings`, `settings`, `settings.island.tsx` and the full path to one manifest, and refuses a name nothing answers to by listing EVERY valid one — a typo and an island whose states were never declared are one symptom and two edits. It is the only refusal in the vocabulary: `parseIslandAddress` falls back on an unknown theme instead, because a page that renders an error over a typo turns a mistyped address into a screenshot of the framework | | ||
| | The disk check is not in `defineIslandStates` | a declaration evaluates wherever it is imported from, so a rule that reads the filesystem at import time fails on the cwd rather than on the path. `assertIslandFiles(manifests, root)` is separate and belongs to whoever knows a root — the guard test, and the command | | ||
| | The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package. A manifest's `name` is therefore the island basename up to its FIRST dot, which needs no copy of that constant — and the shot directory is that name, so two islands sharing a basename are `X_TEST_ISLAND_STATES_AMBIGUOUS` rather than two sets of pictures in one folder | | ||
| | 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` | | ||
@@ -73,0 +83,0 @@ | 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 | |
+12
-12
| { | ||
| "name": "@ultimat3/testing", | ||
| "version": "11.1.0", | ||
| "version": "11.2.0", | ||
| "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types", | ||
@@ -36,14 +36,14 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/cache": "11.1.0", | ||
| "@ultimat3/core": "11.1.0", | ||
| "@ultimat3/db": "11.1.0", | ||
| "@ultimat3/entity": "11.1.0", | ||
| "@ultimat3/i18n": "11.1.0", | ||
| "@ultimat3/jobs": "11.1.0", | ||
| "@ultimat3/mail": "11.1.0", | ||
| "@ultimat3/policy": "11.1.0", | ||
| "@ultimat3/query": "11.1.0", | ||
| "@ultimat3/realtime": "11.1.0", | ||
| "@ultimat3/time": "11.1.0" | ||
| "@ultimat3/cache": "11.2.0", | ||
| "@ultimat3/core": "11.2.0", | ||
| "@ultimat3/db": "11.2.0", | ||
| "@ultimat3/entity": "11.2.0", | ||
| "@ultimat3/i18n": "11.2.0", | ||
| "@ultimat3/jobs": "11.2.0", | ||
| "@ultimat3/mail": "11.2.0", | ||
| "@ultimat3/policy": "11.2.0", | ||
| "@ultimat3/query": "11.2.0", | ||
| "@ultimat3/realtime": "11.2.0", | ||
| "@ultimat3/time": "11.2.0" | ||
| } | ||
| } |
+83
-1
@@ -25,2 +25,8 @@ # @ultimat3/testing | ||
| | `island-dom.ts` | the micro-DOM `mountIsland` drives: what compiled Solid touches, and nothing else | | ||
| | `island-states.ts` | the vocabulary: what a photographable island STATE is. Types and constants, importing nothing | | ||
| | `define-island-states.ts` | `defineIslandStates()` — one manifest, validated and frozen, with every default resolved | | ||
| | `island-states-check.ts` | the rules a declaration must satisfy, as pure functions answering a fault | | ||
| | `island-states-pure.ts` | the guard the design rests on: a `*.island.states.ts` file reaches no browser and no bundler | | ||
| | `island-shot-targets.ts` | the expansion — one record per PICTURE — and `islandAddress` / `parseIslandAddress`, inverses | | ||
| | `island-states-resolve.ts` | a name a reader typed → the manifest it meant; a manifest → the island file it claims | | ||
| | `framework-fixtures.ts` | registers both sets; the app registers only what it owns | | ||
@@ -282,2 +288,74 @@ | `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 | | ||
| ## Declaring the states an island can be photographed in | ||
| A reviewer can click their way to most of a component. They cannot click their way to *the account | ||
| is read-only*, *the workspace is over quota* or *the request failed* — so those states are declared, | ||
| beside the island, in a file that is **pure data**: | ||
| ```ts | ||
| // apps/web/app/settings/settings.island.states.ts | ||
| import { defineIslandStates } from '@ultimat3/testing'; | ||
| export const settingsStates = defineIslandStates({ | ||
| island: 'apps/web/app/settings/settings.island.tsx', | ||
| target: '[data-settings]', // what to crop to; the island's host element otherwise | ||
| states: [ | ||
| { | ||
| id: 'over-quota', // a slug: it becomes the screenshot filename stem | ||
| title: 'the workspace is over quota', | ||
| note: 'you cannot reach this by clicking — billing sets the flag, not the UI', | ||
| props: { quota: { used: 120, limit: 100 } }, | ||
| routes: [{ match: 'GET /api/quota', respond: { kind: 'json', body: { used: 120 } } }], | ||
| themes: ['dark'], // both, when the key is absent | ||
| }, | ||
| ], | ||
| }); | ||
| ``` | ||
| `islandShotTargets(manifest)` expands that to one record per picture — | ||
| `{ island, name, state, theme, viewport, target, timeZone, now, file, query }` — where `file` is | ||
| `settings/over-quota-dark.png` and `query` is the harness address that renders exactly it. | ||
| `parseIslandAddress` is that address's inverse, and it is **total**: an unknown theme falls back to | ||
| `light` rather than photographing an error page. | ||
| **Pure data is the constraint, not a preference.** The command that takes the pictures has to know | ||
| the complete expected list BEFORE a browser exists, or "produced nothing and exited 0" is | ||
| indistinguishable from success — and the harness page and this package's own guard test read the | ||
| same file. One `import './settings.island.tsx'` makes it readable by a bundler alone, so | ||
| `assertIslandStatesPure` refuses it (`X_TEST_ISLAND_STATES_NOT_PURE`). | ||
| **And no RUNTIME import of a sibling, `As of 2026-08-23`.** The rule is the relativeness, not the | ||
| extension: `./settings.island` resolves to `./settings.island.tsx` under Bun, and `./helpers` may | ||
| reach the component one hop further on — a scanner reading ONE file's text can follow neither. A | ||
| computed specifier — ``import(`./${name}.island`)`` — is refused for the same reason, because a | ||
| specifier nothing can read is not a specifier anything may call pure. | ||
| **`import type` is the one way to reach the component, and it is not an import.** | ||
| `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type`, so | ||
| `import type { SettingsProps } from './settings.island'` costs the file nothing and types its props | ||
| against the component. An inline modifier does not: `import { type X } from './y'` is emitted as | ||
| `import {} from './y'`, which evaluates `./y`, and is refused. | ||
| | A states file writes | Verdict | | ||
| |---|---| | ||
| | `import { defineIslandStates } from '@ultimat3/testing'` | allowed — a bare specifier | | ||
| | `import type { Props } from './x.island'` | allowed — erased before anything evaluates | | ||
| | `import props from './props.json' with { type: 'json' }` | allowed — a JSON module imports nothing | | ||
| | `import { X } from './x.island'` · `./helpers` · `../shared/props` | refused — a graph this cannot follow | | ||
| | `import { type X } from './x.island'` | refused — the statement survives erasure | | ||
| | `import './x.island.tsx'` · `solid-js` | refused — JSX and a renderer | | ||
| | ``await import(`./${name}.island`)`` | refused — unreadable, and unreadable is not pure | | ||
| **Props are JSON or they are refused.** They ride the same `data-x-props` script tag hydration | ||
| already uses, so a `Date`, a function or an `undefined` is not "approximately right" in the picture | ||
| — it is a prop the component never receives. `X_TEST_ISLAND_STATE_JSON_INVALID` names the path. | ||
| **The clock is pinned in the vocabulary, zone included.** `timeZone` defaults to `UTC` and `now` to | ||
| this package's own `DEFAULT_NOW`. A harness that freezes the instant and leaves the zone ambient | ||
| renders `12:00` on one machine and `14:00` on the next, and the review diff then says the component | ||
| changed when only the reviewer moved. | ||
| **The command that takes the pictures is not here yet.** `As of 2026-08-23` this package ships the | ||
| vocabulary, the expansion and the refusals; the browser half is `x shot`'s. | ||
| ## Errors | ||
@@ -287,3 +365,7 @@ | ||
| `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK` | ||
| `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` | ||
| `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` `X_TEST_ISLAND_STATES_EMPTY` | ||
| `X_TEST_ISLAND_STATES_NOT_PURE` `X_TEST_ISLAND_STATES_MISSING_FILE` `X_TEST_ISLAND_STATES_UNKNOWN` | ||
| `X_TEST_ISLAND_STATES_AMBIGUOUS` `X_TEST_ISLAND_STATE_ID_INVALID` `X_TEST_ISLAND_STATE_DUPLICATE` | ||
| `X_TEST_ISLAND_STATE_JSON_INVALID` `X_TEST_ISLAND_STATE_CLOCK_INVALID` | ||
| `X_TEST_ISLAND_STATE_STUB_INVALID` | ||
@@ -290,0 +372,0 @@ ## One process, one registry |
+23
-0
@@ -32,2 +32,14 @@ // The X_* codes owned by @ultimat3/testing. A test failure has to be as actionable as a runtime | ||
| 'X_TEST_ISLAND_NO_MOUNT', | ||
| // Declared here and thrown from `island-state-errors.ts`: one file has one job and this | ||
| // catalogue is at its ceiling, so the classes moved and the registration did not. | ||
| 'X_TEST_ISLAND_STATES_EMPTY', | ||
| 'X_TEST_ISLAND_STATES_NOT_PURE', | ||
| 'X_TEST_ISLAND_STATES_MISSING_FILE', | ||
| 'X_TEST_ISLAND_STATES_UNKNOWN', | ||
| 'X_TEST_ISLAND_STATES_AMBIGUOUS', | ||
| 'X_TEST_ISLAND_STATE_ID_INVALID', | ||
| 'X_TEST_ISLAND_STATE_DUPLICATE', | ||
| 'X_TEST_ISLAND_STATE_JSON_INVALID', | ||
| 'X_TEST_ISLAND_STATE_CLOCK_INVALID', | ||
| 'X_TEST_ISLAND_STATE_STUB_INVALID', | ||
| ] as const; | ||
@@ -55,2 +67,13 @@ | ||
| X_TEST_ISLAND_NO_MOUNT: 'an island chunk exports no mount function', | ||
| X_TEST_ISLAND_STATES_EMPTY: 'an island state manifest declares no states', | ||
| X_TEST_ISLAND_STATES_NOT_PURE: | ||
| 'an island states file imports the component, a renderer or a sibling module', | ||
| X_TEST_ISLAND_STATES_MISSING_FILE: 'an island state manifest names an island that is not on disk', | ||
| X_TEST_ISLAND_STATES_UNKNOWN: 'no island state manifest answers to that name', | ||
| X_TEST_ISLAND_STATES_AMBIGUOUS: 'two island state manifests answer to one name', | ||
| X_TEST_ISLAND_STATE_ID_INVALID: 'an island state id is not slug-shaped', | ||
| X_TEST_ISLAND_STATE_DUPLICATE: 'two island states share one id', | ||
| X_TEST_ISLAND_STATE_JSON_INVALID: 'an island state carries a value JSON does not', | ||
| X_TEST_ISLAND_STATE_CLOCK_INVALID: 'an island state manifest pins a clock that is not pinnable', | ||
| X_TEST_ISLAND_STATE_STUB_INVALID: 'an island route stub is not "<METHOD> <pathname>"', | ||
| }; | ||
@@ -57,0 +80,0 @@ |
+66
-0
@@ -26,2 +26,6 @@ export type { | ||
| export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bun:test'; | ||
| // The island-state vocabulary. Pure data by design: a `*.island.states.ts` file is read by the | ||
| // command that photographs the states, by the harness page and by a guard test — none of which has | ||
| // a bundler, and only one of which has a browser. | ||
| export { defineIslandStates } from './define-island-states'; | ||
| export type { DeterminismOptions, DeterminismSnapshot } from './determinism'; | ||
@@ -119,2 +123,64 @@ // `captureDeterminism` + `restoreCapturedDeterminism` are the pair a NESTED install needs; | ||
| export type { FakeElement, FakeNode, FakeText } from './island-dom'; | ||
| export type { IslandAddress, IslandShotTarget } from './island-shot-targets'; | ||
| export { | ||
| isIslandTheme, | ||
| islandAddress, | ||
| islandShotFile, | ||
| islandShotPlan, | ||
| islandShotTargets, | ||
| parseIslandAddress, | ||
| } from './island-shot-targets'; | ||
| // The file an error tells the reader to edit — the island's own name with `.states.ts` where | ||
| // `.tsx` was. Exported so the command that takes the pictures names the same file the refusal does. | ||
| export { islandStatesFile } from './island-state-errors'; | ||
| export type { | ||
| IslandRouteStub, | ||
| IslandState, | ||
| IslandStateDecl, | ||
| IslandStatesDecl, | ||
| IslandStatesManifest, | ||
| IslandStubResponse, | ||
| IslandTheme, | ||
| IslandViewport, | ||
| } from './island-states'; | ||
| export { | ||
| DEFAULT_ISLAND_THEME, | ||
| DEFAULT_ISLAND_VIEWPORT, | ||
| ISLAND_SHOT_TIME_ZONE, | ||
| ISLAND_STATES, | ||
| ISLAND_THEMES, | ||
| isIslandStatesManifest, | ||
| islandStatesName, | ||
| } from './island-states'; | ||
| export type { JsonFault } from './island-states-check'; | ||
| export { | ||
| isPinnedInstant, | ||
| isStateId, | ||
| isStubMatch, | ||
| isTimeZone, | ||
| jsonFault, | ||
| slugifyStateId, | ||
| } from './island-states-check'; | ||
| export type { | ||
| IslandFaultKind, | ||
| IslandStatesFault, | ||
| ModuleEdge, | ||
| } from './island-states-pure'; | ||
| export { | ||
| assertIslandStatesPure, | ||
| importSpecifiers, | ||
| impureSpecifier, | ||
| islandStatesFault, | ||
| islandStatesImportFault, | ||
| moduleEdges, | ||
| } from './island-states-pure'; | ||
| export { | ||
| assertIslandFiles, | ||
| assertUniqueIslandStates, | ||
| findIslandStates, | ||
| islandStatesMatching, | ||
| islandStatesNames, | ||
| missingIslandFiles, | ||
| normalizeIslandName, | ||
| } from './island-states-resolve'; | ||
| export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node'; | ||
@@ -121,0 +187,0 @@ export { createLiveNode } from './live-node'; |
296205
23.72%41
20.59%5138
23.07%402
25.62%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated
Updated