gesso-testing 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # gesso-testing
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [025321a]
8
+ - Updated dependencies [025321a]
9
+ - Updated dependencies [025321a]
10
+ - gesso-framework@0.3.0
11
+ - gesso-core@0.3.0
12
+
3
13
  ## 0.2.1
4
14
 
5
15
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { LayoutBox, LayoutExplanation, UiKeyModifiers, UiManualFrameClock, UiNode, UiRole, UiSemanticState, UiSemanticsMap, UiSemanticsRecord } from "gesso-core";
2
2
  import { RecordedCall } from "gesso-core/testing";
3
- import { ChannelReplica, ChannelToken, FrameMetrics, FrameworkChild, GessoRuntime, GessoRuntimeOptions, ServedChannel } from "gesso-framework";
3
+ import { ChannelRegistry, ChannelReplica, ChannelToken, FrameMetrics, FrameworkChild, GessoRuntime, GessoRuntimeOptions, ServedChannel } from "gesso-framework";
4
4
  //#region src/fireEvent.d.ts
5
5
  interface PointAt {
6
6
  x?: number;
@@ -189,8 +189,19 @@ interface RenderedBase {
189
189
  settle(options?: {
190
190
  maxFrames?: number;
191
191
  }): Promise<void>;
192
- /** The node's border box in layout-root coordinates. */
192
+ /** The node's border box in layout-root coordinates, before scrolling. */
193
193
  getLayout(node: UiNode): LayoutBox;
194
+ /**
195
+ * Where the node is *seen*: scroll offsets and sticky shifts applied.
196
+ *
197
+ * `getLayout` answers where a node was laid out, which for anything
198
+ * inside a scroll container is not where it is. Nothing about
199
+ * scrolling or sticky could be asserted without this — a sticky
200
+ * header's laid-out box is at the top of the content whether or not
201
+ * sticky works at all, so a spec written against `getLayout` passes
202
+ * for a header that scrolled away.
203
+ */
204
+ getVisibleBox(node: UiNode): LayoutBox;
194
205
  /** Why the node has the size it has, as `LayoutEngine.explain` gives it. */
195
206
  explain(node: UiNode): LayoutExplanation;
196
207
  /** The same explanation as the sentences `formatExplanation` prints. */
@@ -280,6 +291,15 @@ declare function renderedFor(node: UiNode): Rendered | null;
280
291
  interface ServedForTest {
281
292
  /** The replica of one served channel, as `ctx.channel(token)` would return it. */
282
293
  get<V extends object, C extends object>(token: ChannelToken<V, C>): ChannelReplica<V, C>;
294
+ /**
295
+ * The registry, for `renderTest(component, { channels: served.registry })`.
296
+ *
297
+ * Without it there is no supported way to render a component against
298
+ * served channels: `serveForTest` answers "did the wiring work" and
299
+ * `renderTest` answers "what does it look like", and a screen driven
300
+ * by a worker is exactly the case that needs both at once.
301
+ */
302
+ readonly registry: ChannelRegistry;
283
303
  /**
284
304
  * Waits for the patch stream to deliver. With a condition, until it
285
305
  * holds; without one, until two turns of the event loop have passed
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as textProperty, i as nodesUnder, n as renderedFor, r as createQueries, t as registerRendered } from "./registry-BlqGkMSO.js";
1
+ import { a as textProperty, i as nodesUnder, n as renderedFor, r as createQueries, t as registerRendered } from "./registry-CyhS3PwD.js";
2
2
  import { CharacterCountTextMeasurer, UiEventType, UiManualFrameClock, UiPointerEvent, formatExplanation, noKeyModifiers } from "gesso-core";
3
3
  import { FakeCanvasHost, RecordingCanvasContext } from "gesso-core/testing";
4
4
  import { GessoRuntime, createChannelRegistry } from "gesso-framework";
@@ -171,6 +171,7 @@ function renderTest(root, options = {}) {
171
171
  frame,
172
172
  settle,
173
173
  getLayout: (node) => runtime.debugLayoutBox(node),
174
+ getVisibleBox: (node) => runtime.debugVisibleBox(node),
174
175
  explain: (node) => runtime.explain(node),
175
176
  explainText: (node) => formatExplanation(runtime.explain(node)),
176
177
  getSemantics: (node) => {
@@ -220,6 +221,7 @@ function serveForTest(channels) {
220
221
  })), (_name, message) => errors.push(message));
221
222
  return {
222
223
  errors,
224
+ registry: handle.registry,
223
225
  get: (token) => handle.registry.get(token),
224
226
  async settle(until, timeoutMs = 2e3) {
225
227
  const deadline = Date.now() + timeoutMs;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/fireEvent.ts","../src/debug.ts","../src/renderTest.ts","../src/channels.ts"],"sourcesContent":["import { noKeyModifiers, UiEventType, UiPointerEvent, type UiKeyModifiers, type UiNode } from 'gesso-core';\nimport type { GessoRuntime } from 'gesso-framework';\n\nexport interface PointAt {\n x?: number;\n y?: number;\n modifiers?: Partial<UiKeyModifiers>;\n}\n\n/**\n * The events a test sends, bound to one render.\n *\n * Bound rather than global — `@testing-library`'s `fireEvent` is a free\n * function because the DOM it acts on is a global, and a Gesso runtime\n * is not: two can be mounted in one file, in one worker, at once. So\n * this comes off the render (`ui.fireEvent.click(node)`), and\n * `createFireEvent(runtime)` is there for anyone driving a runtime this\n * library did not mount.\n *\n * Everything here goes through the same controllers a real pointer and\n * a real keyboard go through. That is the whole point: a test that\n * poked a component's handler directly would pass while the component\n * was unreachable by mouse, by keyboard, and by a screen reader.\n */\nexport interface FireEvent {\n /**\n * A click on a node, with no hit test.\n *\n * Addressed by node rather than by coordinate because that is what a\n * query returns, and because a component's job is to respond to a\n * click on it — whether the pixel at (x, y) lands on it is the hit\n * tester's business and `LayoutEngine`'s specs already cover it. Use\n * `pointerDown`/`pointerUp` for the coordinate path.\n */\n click(node: UiNode, at?: PointAt): void;\n /** A press on a node without the release, for hold and drag behaviour. */\n pressDown(node: UiNode, at?: PointAt): void;\n pressUp(node: UiNode, at?: PointAt): void;\n /** A press-and-move on a node: what grabbing a divider or a thumb is. */\n pan(node: UiNode, x: number, y: number): void;\n\n /** The coordinate path, through the hit tester. */\n pointerDown(x: number, y: number, options?: { buttons?: number; modifiers?: Partial<UiKeyModifiers> }): void;\n pointerMove(x: number, y: number, options?: { buttons?: number; modifiers?: Partial<UiKeyModifiers> }): void;\n pointerUp(x: number, y: number, options?: { buttons?: number; modifiers?: Partial<UiKeyModifiers> }): void;\n wheel(options: {\n x?: number;\n y?: number;\n deltaX?: number;\n deltaY?: number;\n modifiers?: Partial<UiKeyModifiers>;\n }): void;\n\n /** A key press at whatever has focus. */\n keyDown(key: string, modifiers?: Partial<UiKeyModifiers>): void;\n keyUp(key: string, modifiers?: Partial<UiKeyModifiers>): void;\n /** Down then up, which is what pressing a key is. */\n press(key: string, modifiers?: Partial<UiKeyModifiers>): void;\n\n focus(node: UiNode): boolean;\n blur(): void;\n tab(): boolean;\n shiftTab(): boolean;\n\n /**\n * Types into the focused editable, one insertion, as the editing\n * proxy's `beforeinput` would deliver it.\n */\n type(text: string): void;\n paste(text: string): void;\n}\n\nfunction modifiersOf(partial: Partial<UiKeyModifiers> | undefined): UiKeyModifiers {\n return { ...noKeyModifiers(), ...partial };\n}\n\nexport function createFireEvent(runtime: GessoRuntime): FireEvent {\n const dispatch = (type: UiEventType, node: UiNode, at: PointAt = {}): void => {\n runtime.input.dispatcher.dispatch(\n new UiPointerEvent(type, at.x ?? 0, at.y ?? 0, 1, modifiersOf(at.modifiers)),\n node\n );\n };\n\n return {\n click: (node, at) => dispatch(UiEventType.Click, node, at),\n pressDown: (node, at) => dispatch(UiEventType.PointerDown, node, at),\n pressUp: (node, at) => dispatch(UiEventType.PointerUp, node, at),\n pan: (node, x, y) => dispatch(UiEventType.PanMove, node, { x, y }),\n\n pointerDown: (x, y, options = {}) =>\n void runtime.input.pointer.pointerDown(x, y, options.buttons ?? 1, modifiersOf(options.modifiers)),\n pointerMove: (x, y, options = {}) =>\n void runtime.input.pointer.pointerMove(x, y, options.buttons ?? 0, modifiersOf(options.modifiers)),\n pointerUp: (x, y, options = {}) =>\n void runtime.input.pointer.pointerUp(x, y, options.buttons ?? 0, modifiersOf(options.modifiers)),\n wheel: options =>\n void runtime.input.wheel.wheel(\n options.x ?? 0,\n options.y ?? 0,\n options.deltaX ?? 0,\n options.deltaY ?? 0,\n modifiersOf(options.modifiers)\n ),\n\n keyDown: (key, modifiers) => void runtime.input.keyboard.keyDown(key, modifiersOf(modifiers)),\n keyUp: (key, modifiers) => void runtime.input.keyboard.keyUp(key, modifiersOf(modifiers)),\n press: (key, modifiers) => {\n runtime.input.keyboard.keyDown(key, modifiersOf(modifiers));\n runtime.input.keyboard.keyUp(key, modifiersOf(modifiers));\n },\n\n focus: node => runtime.input.focus.focus(node),\n blur: () => runtime.input.focus.blur(),\n tab: () => runtime.input.focus.focusNext(),\n shiftTab: () => runtime.input.focus.focusPrevious(),\n\n type: text => void runtime.input.editing.insertText(text),\n paste: text => void runtime.input.editing.paste(text)\n };\n}\n","import type { LayoutBox, UiNode } from 'gesso-core';\nimport type { GessoRuntime } from 'gesso-framework';\n\n/** `12,8 64×32`, rounded, because a test reader wants the shape not the sixteenths. */\nfunction formatBox(box: LayoutBox): string {\n const round = (value: number): number => Math.round(value * 100) / 100;\n return `${round(box.x)},${round(box.y)} ${round(box.width)}×${round(box.height)}`;\n}\n\n/**\n * The tree as text, one node per line.\n *\n * Printed with what a query would have matched on, in the order a\n * query would have considered it: the node's type and id, then the\n * role and accessible name its semantics record carries, then its\n * states, then the text it draws, then its box. A missed query prints\n * this, and the answer to \"why did `getByRole('switch')` not find it\"\n * is usually on the line for the node that turned out to be a\n * `checkbox`.\n *\n * Nodes with no semantics record are printed too, dimmer in content if\n * not in colour: a `Row` that exists only for layout is exactly the\n * thing a reader needs to see is *not* in the tree an assistive\n * technology reads.\n */\nexport function formatTree(runtime: GessoRuntime, root: UiNode): string {\n const semantics = runtime.semanticsTree();\n const lines: string[] = [];\n\n const visit = (node: UiNode, depth: number): void => {\n const parts: string[] = [`${node.type}#${node.id}`];\n const record = semantics.get(node.id);\n if (record !== undefined) {\n if (record.role !== undefined) {\n parts.push(`role=${record.role}`);\n }\n if (record.label !== undefined) {\n parts.push(`name=${JSON.stringify(record.label)}`);\n }\n if (record.states !== undefined && record.states.length > 0) {\n parts.push(`states=[${record.states.join(',')}]`);\n }\n if (record.disabled === true) {\n parts.push('disabled');\n }\n if (record.valueNow !== undefined) {\n parts.push(`value=${record.valueNow}`);\n }\n }\n const text = node.properties.get('text');\n if (typeof text === 'string' && text.length > 0 && record?.label !== text) {\n parts.push(`text=${JSON.stringify(text)}`);\n }\n if (node.properties.get('visible') === false) {\n parts.push('hidden');\n }\n let box: string;\n try {\n box = formatBox(runtime.debugLayoutBox(node));\n } catch {\n // A node built this frame and not yet laid out has no box; that\n // is worth saying rather than worth throwing from a debug print.\n box = 'not laid out';\n }\n parts.push(`[${box}]`);\n lines.push(`${' '.repeat(depth)}${parts.join(' ')}`);\n for (let child = node.firstChild; child !== null; child = child.nextSibling) {\n visit(child, depth + 1);\n }\n };\n\n visit(root, 0);\n return lines.join('\\n');\n}\n","import {\n CharacterCountTextMeasurer,\n formatExplanation,\n UiManualFrameClock,\n type CanvasHost,\n type LayoutBox,\n type LayoutExplanation,\n type TextMeasurer,\n type UiNode,\n type UiSemanticsMap,\n type UiSemanticsRecord\n} from 'gesso-core';\nimport { FakeCanvasHost, RecordingCanvasContext, type RecordedCall } from 'gesso-core/testing';\nimport { GessoRuntime, type FrameMetrics, type FrameworkChild, type GessoRuntimeOptions } from 'gesso-framework';\n\nimport { createFireEvent, type FireEvent } from './fireEvent';\nimport { formatTree } from './debug';\nimport { createQueries, type Queries } from './queries';\nimport { registerRendered } from './registry';\n\n/**\n * The pieces of a mounted tree that are not queries.\n *\n * Split out so the queries can be mixed in without either half having\n * to know the other's shape.\n */\nexport interface RenderedBase {\n /** The runtime itself, for anything this library does not wrap. */\n readonly runtime: GessoRuntime;\n /** The clock frames are driven from; `frame()` is the usual way in. */\n readonly clock: UiManualFrameClock;\n /** Metrics for every frame that has run, in order. */\n readonly frames: readonly FrameMetrics[];\n /** Every canvas call since the last `clearDraws()`. An escape hatch. */\n readonly draws: readonly RecordedCall[];\n /** The event senders, bound to this runtime. */\n readonly fireEvent: FireEvent;\n\n /**\n * Runs the pending frame, if there is one.\n *\n * Without a time it advances 16 ms per call, so a test can drive\n * frames without keeping a clock of its own.\n */\n frame(time?: number): void;\n\n /**\n * Runs frames until nothing is pending, then resolves.\n *\n * Awaits the microtask queue between frames, which is what makes it\n * the right thing after anything asynchronous: a channel patch, an\n * image resolving, a promise a component awaited. Throws rather than\n * spinning forever if the tree never goes quiet.\n */\n settle(options?: { maxFrames?: number }): Promise<void>;\n\n /** The node's border box in layout-root coordinates. */\n getLayout(node: UiNode): LayoutBox;\n /** Why the node has the size it has, as `LayoutEngine.explain` gives it. */\n explain(node: UiNode): LayoutExplanation;\n /** The same explanation as the sentences `formatExplanation` prints. */\n explainText(node: UiNode): string;\n\n /**\n * The semantics record for a node: what a screen reader would\n * announce for it. Throws, with the tree, when the node has none —\n * which is nearly always the finding rather than an inconvenience.\n */\n getSemantics(node: UiNode): UiSemanticsRecord;\n /** The same, returning null instead of throwing. */\n querySemantics(node: UiNode): UiSemanticsRecord | null;\n /** The whole semantics tree, as the accessibility mirror has it. */\n semanticsTree(): UiSemanticsMap;\n\n /** The tree as text: type, role, name, states and box, one node per line. */\n debug(node?: UiNode): string;\n\n /** Empties the recorded canvas calls. */\n clearDraws(): void;\n /** Stops the runtime and releases what it holds. */\n unmount(): void;\n}\n\nexport type Rendered = RenderedBase & Queries;\n\nexport interface RenderTestOptions extends Omit<Partial<GessoRuntimeOptions>, 'root' | 'canvas' | 'clock'> {\n /**\n * Whether to run the first frame before returning. Default true.\n *\n * Turn it off to watch the first frame happen — a listener attached\n * in `onCreate` sees it either way, but a spec asserting on what the\n * first frame *did* needs to be holding the result when it runs.\n */\n autoFrame?: boolean;\n /** Runs after the runtime is built and before it starts. */\n onCreate?: (runtime: GessoRuntime) => void;\n}\n\nconst DEFAULT_WIDTH = 800;\nconst DEFAULT_HEIGHT = 600;\nconst DEFAULT_MAX_FRAMES = 100;\n\n/**\n * Mounts a component or element tree with no browser and no DOM, and\n * hands back the queries, the events and the layout answers.\n *\n * ```ts\n * const ui = renderTest(createComponent(Checkbox, { label: 'Wrap lines' }));\n * ui.fireEvent.click(ui.getByRole('checkbox'));\n * ui.frame();\n * expect(ui.getSemantics(ui.getByRole('checkbox')).states).toEqual(['checked']);\n * ```\n *\n * Three things are decided here rather than by the caller, because\n * getting any of them wrong makes a test that passes for the wrong\n * reason:\n *\n * - **The clock is manual.** Frames happen when the test says so, so\n * an assertion never races a scheduler.\n * - **Text is measured by `CharacterCountTextMeasurer`**, not by the\n * canvas double. A double's `measureText` answers the same width\n * whatever the font size, so a heading and its caption would come\n * out the same size; the deterministic measurer is proportional to\n * the font size and identical on every machine.\n * - **A first frame has run** by the time this returns, so the graph\n * is built, laid out and described before the first query.\n */\nexport function renderTest(root: FrameworkChild, options: RenderTestOptions = {}): Rendered {\n const { autoFrame = true, onCreate, ...runtimeOptions } = options;\n const context = new RecordingCanvasContext();\n const canvas: CanvasHost = new FakeCanvasHost(context);\n const width = runtimeOptions.width ?? DEFAULT_WIDTH;\n const height = runtimeOptions.height ?? DEFAULT_HEIGHT;\n canvas.width = width;\n canvas.height = height;\n\n const measurer: TextMeasurer = runtimeOptions.textMeasurer ?? new CharacterCountTextMeasurer();\n const frames: FrameMetrics[] = [];\n let clock!: UiManualFrameClock;\n const runtime = new GessoRuntime({\n ...runtimeOptions,\n width,\n height,\n root,\n canvas,\n textMeasurer: measurer,\n clock: callback => (clock = new UiManualFrameClock(callback))\n });\n runtime.onFrame(metrics => frames.push(metrics));\n onCreate?.(runtime);\n runtime.start();\n\n let now = 0;\n const frame = (time?: number): void => {\n now = time ?? now + 16;\n if (clock.isPending) {\n clock.tick(now);\n }\n };\n\n const settle = async (settleOptions: { maxFrames?: number } = {}): Promise<void> => {\n const max = settleOptions.maxFrames ?? DEFAULT_MAX_FRAMES;\n for (let index = 0; index <= max; index++) {\n // A macrotask, so a resolved fetch/decode chain gets to run too,\n // not just the microtasks a queued promise leaves behind.\n await new Promise(resolve => setTimeout(resolve, 0));\n if (!clock.isPending) {\n return;\n }\n frame();\n }\n throw new Error(\n `settle() ran ${max} frames and the tree was still asking for another. ` +\n `Something is scheduling a frame every frame; drive it with frame() instead.`\n );\n };\n\n const base: RenderedBase = {\n runtime,\n get clock() {\n return clock;\n },\n frames,\n get draws() {\n return context.calls;\n },\n fireEvent: createFireEvent(runtime),\n frame,\n settle,\n getLayout: node => runtime.debugLayoutBox(node),\n explain: node => runtime.explain(node),\n explainText: node => formatExplanation(runtime.explain(node)),\n getSemantics: node => {\n const record = runtime.semanticsTree().get(node.id);\n if (record === undefined) {\n throw new Error(\n `'${node.id}' is not in the semantics tree: nothing gives it a role or a label, ` +\n `so no assistive technology can see it.\\n\\n${formatTree(runtime, runtime.layoutRoot())}`\n );\n }\n return record;\n },\n querySemantics: node => runtime.semanticsTree().get(node.id) ?? null,\n semanticsTree: () => runtime.semanticsTree(),\n debug: node => formatTree(runtime, node ?? runtime.layoutRoot()),\n clearDraws: () => {\n context.calls.length = 0;\n },\n unmount: () => runtime.dispose()\n };\n\n const rendered: Rendered = { ...base, ...createQueries(base) };\n registerRendered(runtime.layoutRoot(), rendered);\n if (autoFrame) {\n frame(0);\n }\n return rendered;\n}\n","import { createChannelRegistry, type ChannelReplica, type ChannelToken, type ServedChannel } from 'gesso-framework';\n\n/** What `serveForTest` hands back: the replicas a screen would bind to, and a way to wait for patches. */\nexport interface ServedForTest {\n /** The replica of one served channel, as `ctx.channel(token)` would return it. */\n get<V extends object, C extends object>(token: ChannelToken<V, C>): ChannelReplica<V, C>;\n /**\n * Waits for the patch stream to deliver. With a condition, until it\n * holds; without one, until two turns of the event loop have passed\n * with the ports drained, which is enough for a command's effect to\n * come back as a patch.\n */\n settle(until?: () => boolean, timeoutMs?: number): Promise<void>;\n /** Every error a served channel reported, in order. */\n readonly errors: readonly string[];\n dispose(): void;\n}\n\n/**\n * Serves channels the way an application worker does, over a real patch\n * stream, with no worker: the same `serveChannels` data goes in, and the\n * replicas a screen would read come out.\n *\n * This is the spec for a worker's *wiring*, which the domain classes'\n * own specs do not cover: that a command reaches its handler, that its\n * effect comes back as a patch to the right key, that a view key is\n * plain data. The domain classes stay as testable as they were; this\n * tests the lines between them and the barrier.\n *\n * const served = serveForTest(transitionsChannels(catalogue, queue));\n * const view = served.get(Queue);\n * view.send.play({ playlistId: '1' });\n * await served.settle(() => view.view.current.value !== null);\n */\nexport function serveForTest(channels: readonly ServedChannel[]): ServedForTest {\n const errors: string[] = [];\n const handle = createChannelRegistry(\n channels.map(served => ({ token: served.token, source: served.source })),\n (_name, message) => errors.push(message)\n );\n return {\n errors,\n get: token => handle.registry.get(token),\n async settle(until, timeoutMs = 2000): Promise<void> {\n const deadline = Date.now() + timeoutMs;\n if (until === undefined) {\n for (let turn = 0; turn < 2; turn++) {\n await new Promise(resolve => setTimeout(resolve, 0));\n }\n return;\n }\n while (!until()) {\n if (Date.now() > deadline) {\n throw new Error(`serveForTest: timed out after ${timeoutMs} ms waiting for the condition to hold.`);\n }\n await new Promise(resolve => setTimeout(resolve, 5));\n }\n },\n dispose: () => handle.dispose()\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/fireEvent.ts","../src/debug.ts","../src/renderTest.ts","../src/channels.ts"],"sourcesContent":["import { noKeyModifiers, UiEventType, UiPointerEvent, type UiKeyModifiers, type UiNode } from 'gesso-core';\nimport type { GessoRuntime } from 'gesso-framework';\n\nexport interface PointAt {\n x?: number;\n y?: number;\n modifiers?: Partial<UiKeyModifiers>;\n}\n\n/**\n * The events a test sends, bound to one render.\n *\n * Bound rather than global — `@testing-library`'s `fireEvent` is a free\n * function because the DOM it acts on is a global, and a Gesso runtime\n * is not: two can be mounted in one file, in one worker, at once. So\n * this comes off the render (`ui.fireEvent.click(node)`), and\n * `createFireEvent(runtime)` is there for anyone driving a runtime this\n * library did not mount.\n *\n * Everything here goes through the same controllers a real pointer and\n * a real keyboard go through. That is the whole point: a test that\n * poked a component's handler directly would pass while the component\n * was unreachable by mouse, by keyboard, and by a screen reader.\n */\nexport interface FireEvent {\n /**\n * A click on a node, with no hit test.\n *\n * Addressed by node rather than by coordinate because that is what a\n * query returns, and because a component's job is to respond to a\n * click on it — whether the pixel at (x, y) lands on it is the hit\n * tester's business and `LayoutEngine`'s specs already cover it. Use\n * `pointerDown`/`pointerUp` for the coordinate path.\n */\n click(node: UiNode, at?: PointAt): void;\n /** A press on a node without the release, for hold and drag behaviour. */\n pressDown(node: UiNode, at?: PointAt): void;\n pressUp(node: UiNode, at?: PointAt): void;\n /** A press-and-move on a node: what grabbing a divider or a thumb is. */\n pan(node: UiNode, x: number, y: number): void;\n\n /** The coordinate path, through the hit tester. */\n pointerDown(x: number, y: number, options?: { buttons?: number; modifiers?: Partial<UiKeyModifiers> }): void;\n pointerMove(x: number, y: number, options?: { buttons?: number; modifiers?: Partial<UiKeyModifiers> }): void;\n pointerUp(x: number, y: number, options?: { buttons?: number; modifiers?: Partial<UiKeyModifiers> }): void;\n wheel(options: {\n x?: number;\n y?: number;\n deltaX?: number;\n deltaY?: number;\n modifiers?: Partial<UiKeyModifiers>;\n }): void;\n\n /** A key press at whatever has focus. */\n keyDown(key: string, modifiers?: Partial<UiKeyModifiers>): void;\n keyUp(key: string, modifiers?: Partial<UiKeyModifiers>): void;\n /** Down then up, which is what pressing a key is. */\n press(key: string, modifiers?: Partial<UiKeyModifiers>): void;\n\n focus(node: UiNode): boolean;\n blur(): void;\n tab(): boolean;\n shiftTab(): boolean;\n\n /**\n * Types into the focused editable, one insertion, as the editing\n * proxy's `beforeinput` would deliver it.\n */\n type(text: string): void;\n paste(text: string): void;\n}\n\nfunction modifiersOf(partial: Partial<UiKeyModifiers> | undefined): UiKeyModifiers {\n return { ...noKeyModifiers(), ...partial };\n}\n\nexport function createFireEvent(runtime: GessoRuntime): FireEvent {\n const dispatch = (type: UiEventType, node: UiNode, at: PointAt = {}): void => {\n runtime.input.dispatcher.dispatch(\n new UiPointerEvent(type, at.x ?? 0, at.y ?? 0, 1, modifiersOf(at.modifiers)),\n node\n );\n };\n\n return {\n click: (node, at) => dispatch(UiEventType.Click, node, at),\n pressDown: (node, at) => dispatch(UiEventType.PointerDown, node, at),\n pressUp: (node, at) => dispatch(UiEventType.PointerUp, node, at),\n pan: (node, x, y) => dispatch(UiEventType.PanMove, node, { x, y }),\n\n pointerDown: (x, y, options = {}) =>\n void runtime.input.pointer.pointerDown(x, y, options.buttons ?? 1, modifiersOf(options.modifiers)),\n pointerMove: (x, y, options = {}) =>\n void runtime.input.pointer.pointerMove(x, y, options.buttons ?? 0, modifiersOf(options.modifiers)),\n pointerUp: (x, y, options = {}) =>\n void runtime.input.pointer.pointerUp(x, y, options.buttons ?? 0, modifiersOf(options.modifiers)),\n wheel: options =>\n void runtime.input.wheel.wheel(\n options.x ?? 0,\n options.y ?? 0,\n options.deltaX ?? 0,\n options.deltaY ?? 0,\n modifiersOf(options.modifiers)\n ),\n\n keyDown: (key, modifiers) => void runtime.input.keyboard.keyDown(key, modifiersOf(modifiers)),\n keyUp: (key, modifiers) => void runtime.input.keyboard.keyUp(key, modifiersOf(modifiers)),\n press: (key, modifiers) => {\n runtime.input.keyboard.keyDown(key, modifiersOf(modifiers));\n runtime.input.keyboard.keyUp(key, modifiersOf(modifiers));\n },\n\n focus: node => runtime.input.focus.focus(node),\n blur: () => runtime.input.focus.blur(),\n tab: () => runtime.input.focus.focusNext(),\n shiftTab: () => runtime.input.focus.focusPrevious(),\n\n type: text => void runtime.input.editing.insertText(text),\n paste: text => void runtime.input.editing.paste(text)\n };\n}\n","import type { LayoutBox, UiNode } from 'gesso-core';\nimport type { GessoRuntime } from 'gesso-framework';\n\n/** `12,8 64×32`, rounded, because a test reader wants the shape not the sixteenths. */\nfunction formatBox(box: LayoutBox): string {\n const round = (value: number): number => Math.round(value * 100) / 100;\n return `${round(box.x)},${round(box.y)} ${round(box.width)}×${round(box.height)}`;\n}\n\n/**\n * The tree as text, one node per line.\n *\n * Printed with what a query would have matched on, in the order a\n * query would have considered it: the node's type and id, then the\n * role and accessible name its semantics record carries, then its\n * states, then the text it draws, then its box. A missed query prints\n * this, and the answer to \"why did `getByRole('switch')` not find it\"\n * is usually on the line for the node that turned out to be a\n * `checkbox`.\n *\n * Nodes with no semantics record are printed too, dimmer in content if\n * not in colour: a `Row` that exists only for layout is exactly the\n * thing a reader needs to see is *not* in the tree an assistive\n * technology reads.\n */\nexport function formatTree(runtime: GessoRuntime, root: UiNode): string {\n const semantics = runtime.semanticsTree();\n const lines: string[] = [];\n\n const visit = (node: UiNode, depth: number): void => {\n const parts: string[] = [`${node.type}#${node.id}`];\n const record = semantics.get(node.id);\n if (record !== undefined) {\n if (record.role !== undefined) {\n parts.push(`role=${record.role}`);\n }\n if (record.label !== undefined) {\n parts.push(`name=${JSON.stringify(record.label)}`);\n }\n if (record.states !== undefined && record.states.length > 0) {\n parts.push(`states=[${record.states.join(',')}]`);\n }\n if (record.disabled === true) {\n parts.push('disabled');\n }\n if (record.valueNow !== undefined) {\n parts.push(`value=${record.valueNow}`);\n }\n }\n const text = node.properties.get('text');\n if (typeof text === 'string' && text.length > 0 && record?.label !== text) {\n parts.push(`text=${JSON.stringify(text)}`);\n }\n if (node.properties.get('visible') === false) {\n parts.push('hidden');\n }\n let box: string;\n try {\n box = formatBox(runtime.debugLayoutBox(node));\n } catch {\n // A node built this frame and not yet laid out has no box; that\n // is worth saying rather than worth throwing from a debug print.\n box = 'not laid out';\n }\n parts.push(`[${box}]`);\n lines.push(`${' '.repeat(depth)}${parts.join(' ')}`);\n for (let child = node.firstChild; child !== null; child = child.nextSibling) {\n visit(child, depth + 1);\n }\n };\n\n visit(root, 0);\n return lines.join('\\n');\n}\n","import {\n CharacterCountTextMeasurer,\n formatExplanation,\n UiManualFrameClock,\n type CanvasHost,\n type LayoutBox,\n type LayoutExplanation,\n type TextMeasurer,\n type UiNode,\n type UiSemanticsMap,\n type UiSemanticsRecord\n} from 'gesso-core';\nimport { FakeCanvasHost, RecordingCanvasContext, type RecordedCall } from 'gesso-core/testing';\nimport { GessoRuntime, type FrameMetrics, type FrameworkChild, type GessoRuntimeOptions } from 'gesso-framework';\n\nimport { createFireEvent, type FireEvent } from './fireEvent';\nimport { formatTree } from './debug';\nimport { createQueries, type Queries } from './queries';\nimport { registerRendered } from './registry';\n\n/**\n * The pieces of a mounted tree that are not queries.\n *\n * Split out so the queries can be mixed in without either half having\n * to know the other's shape.\n */\nexport interface RenderedBase {\n /** The runtime itself, for anything this library does not wrap. */\n readonly runtime: GessoRuntime;\n /** The clock frames are driven from; `frame()` is the usual way in. */\n readonly clock: UiManualFrameClock;\n /** Metrics for every frame that has run, in order. */\n readonly frames: readonly FrameMetrics[];\n /** Every canvas call since the last `clearDraws()`. An escape hatch. */\n readonly draws: readonly RecordedCall[];\n /** The event senders, bound to this runtime. */\n readonly fireEvent: FireEvent;\n\n /**\n * Runs the pending frame, if there is one.\n *\n * Without a time it advances 16 ms per call, so a test can drive\n * frames without keeping a clock of its own.\n */\n frame(time?: number): void;\n\n /**\n * Runs frames until nothing is pending, then resolves.\n *\n * Awaits the microtask queue between frames, which is what makes it\n * the right thing after anything asynchronous: a channel patch, an\n * image resolving, a promise a component awaited. Throws rather than\n * spinning forever if the tree never goes quiet.\n */\n settle(options?: { maxFrames?: number }): Promise<void>;\n\n /** The node's border box in layout-root coordinates, before scrolling. */\n getLayout(node: UiNode): LayoutBox;\n /**\n * Where the node is *seen*: scroll offsets and sticky shifts applied.\n *\n * `getLayout` answers where a node was laid out, which for anything\n * inside a scroll container is not where it is. Nothing about\n * scrolling or sticky could be asserted without this — a sticky\n * header's laid-out box is at the top of the content whether or not\n * sticky works at all, so a spec written against `getLayout` passes\n * for a header that scrolled away.\n */\n getVisibleBox(node: UiNode): LayoutBox;\n /** Why the node has the size it has, as `LayoutEngine.explain` gives it. */\n explain(node: UiNode): LayoutExplanation;\n /** The same explanation as the sentences `formatExplanation` prints. */\n explainText(node: UiNode): string;\n\n /**\n * The semantics record for a node: what a screen reader would\n * announce for it. Throws, with the tree, when the node has none —\n * which is nearly always the finding rather than an inconvenience.\n */\n getSemantics(node: UiNode): UiSemanticsRecord;\n /** The same, returning null instead of throwing. */\n querySemantics(node: UiNode): UiSemanticsRecord | null;\n /** The whole semantics tree, as the accessibility mirror has it. */\n semanticsTree(): UiSemanticsMap;\n\n /** The tree as text: type, role, name, states and box, one node per line. */\n debug(node?: UiNode): string;\n\n /** Empties the recorded canvas calls. */\n clearDraws(): void;\n /** Stops the runtime and releases what it holds. */\n unmount(): void;\n}\n\nexport type Rendered = RenderedBase & Queries;\n\nexport interface RenderTestOptions extends Omit<Partial<GessoRuntimeOptions>, 'root' | 'canvas' | 'clock'> {\n /**\n * Whether to run the first frame before returning. Default true.\n *\n * Turn it off to watch the first frame happen — a listener attached\n * in `onCreate` sees it either way, but a spec asserting on what the\n * first frame *did* needs to be holding the result when it runs.\n */\n autoFrame?: boolean;\n /** Runs after the runtime is built and before it starts. */\n onCreate?: (runtime: GessoRuntime) => void;\n}\n\nconst DEFAULT_WIDTH = 800;\nconst DEFAULT_HEIGHT = 600;\nconst DEFAULT_MAX_FRAMES = 100;\n\n/**\n * Mounts a component or element tree with no browser and no DOM, and\n * hands back the queries, the events and the layout answers.\n *\n * ```ts\n * const ui = renderTest(createComponent(Checkbox, { label: 'Wrap lines' }));\n * ui.fireEvent.click(ui.getByRole('checkbox'));\n * ui.frame();\n * expect(ui.getSemantics(ui.getByRole('checkbox')).states).toEqual(['checked']);\n * ```\n *\n * Three things are decided here rather than by the caller, because\n * getting any of them wrong makes a test that passes for the wrong\n * reason:\n *\n * - **The clock is manual.** Frames happen when the test says so, so\n * an assertion never races a scheduler.\n * - **Text is measured by `CharacterCountTextMeasurer`**, not by the\n * canvas double. A double's `measureText` answers the same width\n * whatever the font size, so a heading and its caption would come\n * out the same size; the deterministic measurer is proportional to\n * the font size and identical on every machine.\n * - **A first frame has run** by the time this returns, so the graph\n * is built, laid out and described before the first query.\n */\nexport function renderTest(root: FrameworkChild, options: RenderTestOptions = {}): Rendered {\n const { autoFrame = true, onCreate, ...runtimeOptions } = options;\n const context = new RecordingCanvasContext();\n const canvas: CanvasHost = new FakeCanvasHost(context);\n const width = runtimeOptions.width ?? DEFAULT_WIDTH;\n const height = runtimeOptions.height ?? DEFAULT_HEIGHT;\n canvas.width = width;\n canvas.height = height;\n\n const measurer: TextMeasurer = runtimeOptions.textMeasurer ?? new CharacterCountTextMeasurer();\n const frames: FrameMetrics[] = [];\n let clock!: UiManualFrameClock;\n const runtime = new GessoRuntime({\n ...runtimeOptions,\n width,\n height,\n root,\n canvas,\n textMeasurer: measurer,\n clock: callback => (clock = new UiManualFrameClock(callback))\n });\n runtime.onFrame(metrics => frames.push(metrics));\n onCreate?.(runtime);\n runtime.start();\n\n let now = 0;\n const frame = (time?: number): void => {\n now = time ?? now + 16;\n if (clock.isPending) {\n clock.tick(now);\n }\n };\n\n const settle = async (settleOptions: { maxFrames?: number } = {}): Promise<void> => {\n const max = settleOptions.maxFrames ?? DEFAULT_MAX_FRAMES;\n for (let index = 0; index <= max; index++) {\n // A macrotask, so a resolved fetch/decode chain gets to run too,\n // not just the microtasks a queued promise leaves behind.\n await new Promise(resolve => setTimeout(resolve, 0));\n if (!clock.isPending) {\n return;\n }\n frame();\n }\n throw new Error(\n `settle() ran ${max} frames and the tree was still asking for another. ` +\n `Something is scheduling a frame every frame; drive it with frame() instead.`\n );\n };\n\n const base: RenderedBase = {\n runtime,\n get clock() {\n return clock;\n },\n frames,\n get draws() {\n return context.calls;\n },\n fireEvent: createFireEvent(runtime),\n frame,\n settle,\n getLayout: node => runtime.debugLayoutBox(node),\n getVisibleBox: node => runtime.debugVisibleBox(node),\n explain: node => runtime.explain(node),\n explainText: node => formatExplanation(runtime.explain(node)),\n getSemantics: node => {\n const record = runtime.semanticsTree().get(node.id);\n if (record === undefined) {\n throw new Error(\n `'${node.id}' is not in the semantics tree: nothing gives it a role or a label, ` +\n `so no assistive technology can see it.\\n\\n${formatTree(runtime, runtime.layoutRoot())}`\n );\n }\n return record;\n },\n querySemantics: node => runtime.semanticsTree().get(node.id) ?? null,\n semanticsTree: () => runtime.semanticsTree(),\n debug: node => formatTree(runtime, node ?? runtime.layoutRoot()),\n clearDraws: () => {\n context.calls.length = 0;\n },\n unmount: () => runtime.dispose()\n };\n\n const rendered: Rendered = { ...base, ...createQueries(base) };\n registerRendered(runtime.layoutRoot(), rendered);\n if (autoFrame) {\n frame(0);\n }\n return rendered;\n}\n","import {\n createChannelRegistry,\n type ChannelRegistry,\n type ChannelReplica,\n type ChannelToken,\n type ServedChannel\n} from 'gesso-framework';\n\n/** What `serveForTest` hands back: the replicas a screen would bind to, and a way to wait for patches. */\nexport interface ServedForTest {\n /** The replica of one served channel, as `ctx.channel(token)` would return it. */\n get<V extends object, C extends object>(token: ChannelToken<V, C>): ChannelReplica<V, C>;\n /**\n * The registry, for `renderTest(component, { channels: served.registry })`.\n *\n * Without it there is no supported way to render a component against\n * served channels: `serveForTest` answers \"did the wiring work\" and\n * `renderTest` answers \"what does it look like\", and a screen driven\n * by a worker is exactly the case that needs both at once.\n */\n readonly registry: ChannelRegistry;\n /**\n * Waits for the patch stream to deliver. With a condition, until it\n * holds; without one, until two turns of the event loop have passed\n * with the ports drained, which is enough for a command's effect to\n * come back as a patch.\n */\n settle(until?: () => boolean, timeoutMs?: number): Promise<void>;\n /** Every error a served channel reported, in order. */\n readonly errors: readonly string[];\n dispose(): void;\n}\n\n/**\n * Serves channels the way an application worker does, over a real patch\n * stream, with no worker: the same `serveChannels` data goes in, and the\n * replicas a screen would read come out.\n *\n * This is the spec for a worker's *wiring*, which the domain classes'\n * own specs do not cover: that a command reaches its handler, that its\n * effect comes back as a patch to the right key, that a view key is\n * plain data. The domain classes stay as testable as they were; this\n * tests the lines between them and the barrier.\n *\n * const served = serveForTest(transitionsChannels(catalogue, queue));\n * const view = served.get(Queue);\n * view.send.play({ playlistId: '1' });\n * await served.settle(() => view.view.current.value !== null);\n */\nexport function serveForTest(channels: readonly ServedChannel[]): ServedForTest {\n const errors: string[] = [];\n const handle = createChannelRegistry(\n channels.map(served => ({ token: served.token, source: served.source })),\n (_name, message) => errors.push(message)\n );\n return {\n errors,\n registry: handle.registry,\n get: token => handle.registry.get(token),\n async settle(until, timeoutMs = 2000): Promise<void> {\n const deadline = Date.now() + timeoutMs;\n if (until === undefined) {\n for (let turn = 0; turn < 2; turn++) {\n await new Promise(resolve => setTimeout(resolve, 0));\n }\n return;\n }\n while (!until()) {\n if (Date.now() > deadline) {\n throw new Error(`serveForTest: timed out after ${timeoutMs} ms waiting for the condition to hold.`);\n }\n await new Promise(resolve => setTimeout(resolve, 5));\n }\n },\n dispose: () => handle.dispose()\n };\n}\n"],"mappings":";;;;;AAwEA,SAAS,YAAY,SAA8D;CACjF,OAAO;EAAE,GAAG,eAAe;EAAG,GAAG;CAAQ;AAC3C;AAEA,SAAgB,gBAAgB,SAAkC;CAChE,MAAM,YAAY,MAAmB,MAAc,KAAc,CAAC,MAAY;EAC5E,QAAQ,MAAM,WAAW,SACvB,IAAI,eAAe,MAAM,GAAG,KAAK,GAAG,GAAG,KAAK,GAAG,GAAG,YAAY,GAAG,SAAS,CAAC,GAC3E,IACF;CACF;CAEA,OAAO;EACL,QAAQ,MAAM,OAAO,SAAS,YAAY,OAAO,MAAM,EAAE;EACzD,YAAY,MAAM,OAAO,SAAS,YAAY,aAAa,MAAM,EAAE;EACnE,UAAU,MAAM,OAAO,SAAS,YAAY,WAAW,MAAM,EAAE;EAC/D,MAAM,MAAM,GAAG,MAAM,SAAS,YAAY,SAAS,MAAM;GAAE;GAAG;EAAE,CAAC;EAEjE,cAAc,GAAG,GAAG,UAAU,CAAC,MAC7B,KAAK,QAAQ,MAAM,QAAQ,YAAY,GAAG,GAAG,QAAQ,WAAW,GAAG,YAAY,QAAQ,SAAS,CAAC;EACnG,cAAc,GAAG,GAAG,UAAU,CAAC,MAC7B,KAAK,QAAQ,MAAM,QAAQ,YAAY,GAAG,GAAG,QAAQ,WAAW,GAAG,YAAY,QAAQ,SAAS,CAAC;EACnG,YAAY,GAAG,GAAG,UAAU,CAAC,MAC3B,KAAK,QAAQ,MAAM,QAAQ,UAAU,GAAG,GAAG,QAAQ,WAAW,GAAG,YAAY,QAAQ,SAAS,CAAC;EACjG,QAAO,YACL,KAAK,QAAQ,MAAM,MAAM,MACvB,QAAQ,KAAK,GACb,QAAQ,KAAK,GACb,QAAQ,UAAU,GAClB,QAAQ,UAAU,GAClB,YAAY,QAAQ,SAAS,CAC/B;EAEF,UAAU,KAAK,cAAc,KAAK,QAAQ,MAAM,SAAS,QAAQ,KAAK,YAAY,SAAS,CAAC;EAC5F,QAAQ,KAAK,cAAc,KAAK,QAAQ,MAAM,SAAS,MAAM,KAAK,YAAY,SAAS,CAAC;EACxF,QAAQ,KAAK,cAAc;GACzB,QAAQ,MAAM,SAAS,QAAQ,KAAK,YAAY,SAAS,CAAC;GAC1D,QAAQ,MAAM,SAAS,MAAM,KAAK,YAAY,SAAS,CAAC;EAC1D;EAEA,QAAO,SAAQ,QAAQ,MAAM,MAAM,MAAM,IAAI;EAC7C,YAAY,QAAQ,MAAM,MAAM,KAAK;EACrC,WAAW,QAAQ,MAAM,MAAM,UAAU;EACzC,gBAAgB,QAAQ,MAAM,MAAM,cAAc;EAElD,OAAM,SAAQ,KAAK,QAAQ,MAAM,QAAQ,WAAW,IAAI;EACxD,QAAO,SAAQ,KAAK,QAAQ,MAAM,QAAQ,MAAM,IAAI;CACtD;AACF;;;;ACpHA,SAAS,UAAU,KAAwB;CACzC,MAAM,SAAS,UAA0B,KAAK,MAAM,QAAQ,GAAG,IAAI;CACnE,OAAO,GAAG,MAAM,IAAI,CAAC,EAAE,GAAG,MAAM,IAAI,CAAC,EAAE,GAAG,MAAM,IAAI,KAAK,EAAE,GAAG,MAAM,IAAI,MAAM;AAChF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,WAAW,SAAuB,MAAsB;CACtE,MAAM,YAAY,QAAQ,cAAc;CACxC,MAAM,QAAkB,CAAC;CAEzB,MAAM,SAAS,MAAc,UAAwB;EACnD,MAAM,QAAkB,CAAC,GAAG,KAAK,KAAK,GAAG,KAAK,IAAI;EAClD,MAAM,SAAS,UAAU,IAAI,KAAK,EAAE;EACpC,IAAI,WAAW,KAAA,GAAW;GACxB,IAAI,OAAO,SAAS,KAAA,GAClB,MAAM,KAAK,QAAQ,OAAO,MAAM;GAElC,IAAI,OAAO,UAAU,KAAA,GACnB,MAAM,KAAK,QAAQ,KAAK,UAAU,OAAO,KAAK,GAAG;GAEnD,IAAI,OAAO,WAAW,KAAA,KAAa,OAAO,OAAO,SAAS,GACxD,MAAM,KAAK,WAAW,OAAO,OAAO,KAAK,GAAG,EAAE,EAAE;GAElD,IAAI,OAAO,aAAa,MACtB,MAAM,KAAK,UAAU;GAEvB,IAAI,OAAO,aAAa,KAAA,GACtB,MAAM,KAAK,SAAS,OAAO,UAAU;EAEzC;EACA,MAAM,OAAO,KAAK,WAAW,IAAI,MAAM;EACvC,IAAI,OAAO,SAAS,YAAY,KAAK,SAAS,KAAK,QAAQ,UAAU,MACnE,MAAM,KAAK,QAAQ,KAAK,UAAU,IAAI,GAAG;EAE3C,IAAI,KAAK,WAAW,IAAI,SAAS,MAAM,OACrC,MAAM,KAAK,QAAQ;EAErB,IAAI;EACJ,IAAI;GACF,MAAM,UAAU,QAAQ,eAAe,IAAI,CAAC;EAC9C,QAAQ;GAGN,MAAM;EACR;EACA,MAAM,KAAK,IAAI,IAAI,EAAE;EACrB,MAAM,KAAK,GAAG,KAAK,OAAO,KAAK,IAAI,MAAM,KAAK,GAAG,GAAG;EACpD,KAAK,IAAI,QAAQ,KAAK,YAAY,UAAU,MAAM,QAAQ,MAAM,aAC9D,MAAM,OAAO,QAAQ,CAAC;CAE1B;CAEA,MAAM,MAAM,CAAC;CACb,OAAO,MAAM,KAAK,IAAI;AACxB;;;ACoCA,MAAM,gBAAgB;AACtB,MAAM,iBAAiB;AACvB,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;AA2B3B,SAAgB,WAAW,MAAsB,UAA6B,CAAC,GAAa;CAC1F,MAAM,EAAE,YAAY,MAAM,UAAU,GAAG,mBAAmB;CAC1D,MAAM,UAAU,IAAI,uBAAuB;CAC3C,MAAM,SAAqB,IAAI,eAAe,OAAO;CACrD,MAAM,QAAQ,eAAe,SAAS;CACtC,MAAM,SAAS,eAAe,UAAU;CACxC,OAAO,QAAQ;CACf,OAAO,SAAS;CAEhB,MAAM,WAAyB,eAAe,gBAAgB,IAAI,2BAA2B;CAC7F,MAAM,SAAyB,CAAC;CAChC,IAAI;CACJ,MAAM,UAAU,IAAI,aAAa;EAC/B,GAAG;EACH;EACA;EACA;EACA;EACA,cAAc;EACd,QAAO,aAAa,QAAQ,IAAI,mBAAmB,QAAQ;CAC7D,CAAC;CACD,QAAQ,SAAQ,YAAW,OAAO,KAAK,OAAO,CAAC;CAC/C,WAAW,OAAO;CAClB,QAAQ,MAAM;CAEd,IAAI,MAAM;CACV,MAAM,SAAS,SAAwB;EACrC,MAAM,QAAQ,MAAM;EACpB,IAAI,MAAM,WACR,MAAM,KAAK,GAAG;CAElB;CAEA,MAAM,SAAS,OAAO,gBAAwC,CAAC,MAAqB;EAClF,MAAM,MAAM,cAAc,aAAa;EACvC,KAAK,IAAI,QAAQ,GAAG,SAAS,KAAK,SAAS;GAGzC,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,CAAC,CAAC;GACnD,IAAI,CAAC,MAAM,WACT;GAEF,MAAM;EACR;EACA,MAAM,IAAI,MACR,gBAAgB,IAAI,+HAEtB;CACF;CAEA,MAAM,OAAqB;EACzB;EACA,IAAI,QAAQ;GACV,OAAO;EACT;EACA;EACA,IAAI,QAAQ;GACV,OAAO,QAAQ;EACjB;EACA,WAAW,gBAAgB,OAAO;EAClC;EACA;EACA,YAAW,SAAQ,QAAQ,eAAe,IAAI;EAC9C,gBAAe,SAAQ,QAAQ,gBAAgB,IAAI;EACnD,UAAS,SAAQ,QAAQ,QAAQ,IAAI;EACrC,cAAa,SAAQ,kBAAkB,QAAQ,QAAQ,IAAI,CAAC;EAC5D,eAAc,SAAQ;GACpB,MAAM,SAAS,QAAQ,cAAc,CAAC,CAAC,IAAI,KAAK,EAAE;GAClD,IAAI,WAAW,KAAA,GACb,MAAM,IAAI,MACR,IAAI,KAAK,GAAG,gHACmC,WAAW,SAAS,QAAQ,WAAW,CAAC,GACzF;GAEF,OAAO;EACT;EACA,iBAAgB,SAAQ,QAAQ,cAAc,CAAC,CAAC,IAAI,KAAK,EAAE,KAAK;EAChE,qBAAqB,QAAQ,cAAc;EAC3C,QAAO,SAAQ,WAAW,SAAS,QAAQ,QAAQ,WAAW,CAAC;EAC/D,kBAAkB;GAChB,QAAQ,MAAM,SAAS;EACzB;EACA,eAAe,QAAQ,QAAQ;CACjC;CAEA,MAAM,WAAqB;EAAE,GAAG;EAAM,GAAG,cAAc,IAAI;CAAE;CAC7D,iBAAiB,QAAQ,WAAW,GAAG,QAAQ;CAC/C,IAAI,WACF,MAAM,CAAC;CAET,OAAO;AACT;;;;;;;;;;;;;;;;;;;ACpLA,SAAgB,aAAa,UAAmD;CAC9E,MAAM,SAAmB,CAAC;CAC1B,MAAM,SAAS,sBACb,SAAS,KAAI,YAAW;EAAE,OAAO,OAAO;EAAO,QAAQ,OAAO;CAAO,EAAE,IACtE,OAAO,YAAY,OAAO,KAAK,OAAO,CACzC;CACA,OAAO;EACL;EACA,UAAU,OAAO;EACjB,MAAK,UAAS,OAAO,SAAS,IAAI,KAAK;EACvC,MAAM,OAAO,OAAO,YAAY,KAAqB;GACnD,MAAM,WAAW,KAAK,IAAI,IAAI;GAC9B,IAAI,UAAU,KAAA,GAAW;IACvB,KAAK,IAAI,OAAO,GAAG,OAAO,GAAG,QAC3B,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,CAAC,CAAC;IAErD;GACF;GACA,OAAO,CAAC,MAAM,GAAG;IACf,IAAI,KAAK,IAAI,IAAI,UACf,MAAM,IAAI,MAAM,iCAAiC,UAAU,uCAAuC;IAEpG,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,CAAC,CAAC;GACrD;EACF;EACA,eAAe,OAAO,QAAQ;CAChC;AACF"}
@@ -47,6 +47,16 @@ declare const matchers: {
47
47
  * grew by a pixel.
48
48
  */
49
49
  toHaveBox(received: UiNode, expected: Partial<LayoutBox>): MatcherResult;
50
+ /**
51
+ * Where the node is *seen*, with scroll offsets and sticky shifts
52
+ * applied.
53
+ *
54
+ * The matcher to reach for when the claim is about scrolling. A
55
+ * sticky header's laid-out box is at the top of its content whether
56
+ * or not sticky works, so `toHaveBox` asserts nothing about it after
57
+ * a scroll; this is the one that does.
58
+ */
59
+ toHaveVisibleBox(received: UiNode, expected: Partial<LayoutBox>): MatcherResult;
50
60
  /** The text the node itself draws — not its descendants, and not its label. */
51
61
  toHaveText(received: UiNode, expected: string): MatcherResult;
52
62
  /**
@@ -62,6 +72,7 @@ declare const matchers: {
62
72
  };
63
73
  interface GessoMatchers<R = unknown> {
64
74
  toHaveBox: (expected: Partial<LayoutBox>) => R;
75
+ toHaveVisibleBox: (expected: Partial<LayoutBox>) => R;
65
76
  toHaveText: (expected: string) => R;
66
77
  toHaveSemantics: (expected: ExpectedSemantics) => R;
67
78
  toHaveFocus: () => R;
package/dist/matchers.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as textProperty, n as renderedFor } from "./registry-BlqGkMSO.js";
1
+ import { a as textProperty, n as renderedFor } from "./registry-CyhS3PwD.js";
2
2
  import { expect } from "vitest";
3
3
  //#region src/matchers.ts
4
4
  /** The explanation for a node, or a note saying why there is none. */
@@ -10,6 +10,9 @@ function explanationOf(node) {
10
10
  function boxOf(node) {
11
11
  return renderedFor(node)?.getLayout(node) ?? null;
12
12
  }
13
+ function visibleBoxOf(node) {
14
+ return renderedFor(node)?.getVisibleBox(node) ?? null;
15
+ }
13
16
  function round(value) {
14
17
  return Math.round(value * 1e3) / 1e3;
15
18
  }
@@ -39,6 +42,32 @@ const matchers = {
39
42
  })}.\n\nWhy:\n${explanationOf(received)}`
40
43
  };
41
44
  },
45
+ /**
46
+ * Where the node is *seen*, with scroll offsets and sticky shifts
47
+ * applied.
48
+ *
49
+ * The matcher to reach for when the claim is about scrolling. A
50
+ * sticky header's laid-out box is at the top of its content whether
51
+ * or not sticky works, so `toHaveBox` asserts nothing about it after
52
+ * a scroll; this is the one that does.
53
+ */
54
+ toHaveVisibleBox(received, expected) {
55
+ const box = visibleBoxOf(received);
56
+ if (box === null) return {
57
+ pass: false,
58
+ message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`
59
+ };
60
+ const wrong = Object.keys(expected).filter((key) => round(box[key]) !== round(expected[key]));
61
+ return {
62
+ pass: wrong.length === 0,
63
+ message: () => wrong.length === 0 ? `Expected '${received.id}' not to be seen at ${JSON.stringify(expected)}.` : `Expected '${received.id}' to be seen at ${wrong.map((key) => `${key} ${expected[key]}`).join(", ")}, but it is seen at ${JSON.stringify({
64
+ x: round(box.x),
65
+ y: round(box.y),
66
+ width: round(box.width),
67
+ height: round(box.height)
68
+ })}.\n\nWhy:\n${explanationOf(received)}`
69
+ };
70
+ },
42
71
  /** The text the node itself draws — not its descendants, and not its label. */
43
72
  toHaveText(received, expected) {
44
73
  const actual = textProperty(received);
@@ -1 +1 @@
1
- {"version":3,"file":"matchers.js","names":[],"sources":["../src/matchers.ts"],"sourcesContent":["import { expect } from 'vitest';\n\nimport type { LayoutBox, UiNode, UiRole, UiSemanticState } from 'gesso-core';\n\nimport { textProperty } from './queries';\nimport { renderedFor } from './registry';\n\n/**\n * The matchers, and the reason the package has any.\n *\n * `expect(ui.getLayout(node)).toEqual({ x: 8, y: 8, width: 64, height: 32 })`\n * already works, so a matcher that only compared numbers would be\n * decoration. What these add is the failure message: a `toHaveBox` that\n * misses prints the node's layout explanation — the constraints it was\n * given, the rule that fixed each axis, and the ancestor it is laid out\n * from — which is L8's `engine.explain` arriving exactly where someone\n * is looking at a wrong number and asking why.\n *\n * That is the F7 exit criterion in one place: *fix a layout bug using\n * only the inspector and the testing library*. The inspector answers it\n * on a canvas; this answers it in a terminal.\n *\n * Importing this module registers them:\n *\n * ```ts\n * import 'gesso-testing/matchers';\n * ```\n *\n * A separate entry rather than the root barrel, because `expect.extend`\n * is a side effect on a global and importing `renderTest` should not\n * perform one.\n */\n\ninterface MatcherResult {\n pass: boolean;\n message: () => string;\n}\n\n/** The explanation for a node, or a note saying why there is none. */\nfunction explanationOf(node: UiNode): string {\n const rendered = renderedFor(node);\n if (rendered === null) {\n return 'This node was not mounted by renderTest(), so there is no layout engine to ask why.';\n }\n return rendered.explainText(node);\n}\n\nfunction boxOf(node: UiNode): LayoutBox | null {\n return renderedFor(node)?.getLayout(node) ?? null;\n}\n\nfunction round(value: number): number {\n return Math.round(value * 1000) / 1000;\n}\n\nexport interface ExpectedSemantics {\n role?: UiRole;\n /** The accessible name. */\n name?: string;\n states?: readonly UiSemanticState[];\n disabled?: boolean;\n value?: number;\n}\n\nexport const matchers = {\n /**\n * The node's border box, in layout-root coordinates.\n *\n * Every field is optional, so `toHaveBox({ width: 64 })` asserts the\n * width and says nothing about the rest — which is what a layout test\n * usually means, and stops a test breaking because a sibling above it\n * grew by a pixel.\n */\n toHaveBox(received: UiNode, expected: Partial<LayoutBox>): MatcherResult {\n const box = boxOf(received);\n if (box === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const wrong = (Object.keys(expected) as (keyof LayoutBox)[]).filter(\n key => round(box[key]) !== round(expected[key] as number)\n );\n return {\n pass: wrong.length === 0,\n message: () =>\n wrong.length === 0\n ? `Expected '${received.id}' not to have box ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to have ${wrong.map(key => `${key} ${expected[key]}`).join(', ')}, ` +\n `but its box is ${JSON.stringify({\n x: round(box.x),\n y: round(box.y),\n width: round(box.width),\n height: round(box.height)\n })}.\\n\\nWhy:\\n${explanationOf(received)}`\n };\n },\n\n /** The text the node itself draws — not its descendants, and not its label. */\n toHaveText(received: UiNode, expected: string): MatcherResult {\n const actual = textProperty(received);\n return {\n pass: actual === expected,\n message: () =>\n actual === expected\n ? `Expected '${received.id}' not to have the text ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to have the text ${JSON.stringify(expected)}, but it has ` +\n `${actual === undefined ? 'none' : JSON.stringify(actual)}.`\n };\n },\n\n /**\n * What an assistive technology would announce for this node.\n *\n * Compares only the fields given, for the same reason `toHaveBox`\n * does: a component that gains a `posInSet` should not break every\n * test that ever asserted its role.\n */\n toHaveSemantics(received: UiNode, expected: ExpectedSemantics): MatcherResult {\n const rendered = renderedFor(received);\n if (rendered === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const record = rendered.querySemantics(received);\n if (record === null) {\n return {\n pass: false,\n message: () =>\n `Expected '${received.id}' to be in the semantics tree, but it is not — ` +\n `nothing gives it a role or a label, so no assistive technology can see it.\\n\\n${rendered.debug()}`\n };\n }\n const actual: ExpectedSemantics = {\n role: record.role,\n name: record.label,\n states: record.states ?? [],\n disabled: record.disabled === true,\n value: record.valueNow\n };\n const wrong = (Object.keys(expected) as (keyof ExpectedSemantics)[]).filter(key => {\n if (key === 'states') {\n const want = [...(expected.states ?? [])].sort();\n const have = [...(actual.states ?? [])].sort();\n return want.length !== have.length || want.some((state, index) => state !== have[index]);\n }\n return actual[key] !== expected[key];\n });\n return {\n pass: wrong.length === 0,\n message: () =>\n wrong.length === 0\n ? `Expected '${received.id}' not to have semantics ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to have ${wrong\n .map(key => `${key} ${JSON.stringify(expected[key])}`)\n .join(', ')}, but a screen reader sees ${JSON.stringify(actual)}.`\n };\n },\n\n /** Whether the runtime's focus manager currently has this node. */\n toHaveFocus(received: UiNode): MatcherResult {\n const rendered = renderedFor(received);\n if (rendered === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const focused = rendered.runtime.input.focus.focusedNode;\n return {\n pass: focused === received,\n message: () =>\n focused === received\n ? `Expected '${received.id}' not to have focus.`\n : `Expected '${received.id}' to have focus, but ${\n focused === null ? 'nothing does' : `'${focused.id}' does`\n }.`\n };\n }\n};\n\nexpect.extend(matchers);\n\ninterface GessoMatchers<R = unknown> {\n toHaveBox: (expected: Partial<LayoutBox>) => R;\n toHaveText: (expected: string) => R;\n toHaveSemantics: (expected: ExpectedSemantics) => R;\n toHaveFocus: () => R;\n}\n\n/**\n * `T` here is vitest's own default for the interface it is merging\n * into, and it has to be spelled the same way or the merge is an\n * error: `All declarations of 'Matchers' must have identical type\n * parameters.`\n */\ndeclare module 'vitest' {\n // oxlint-disable-next-line no-explicit-any\n interface Matchers<T = any> extends GessoMatchers<T> {}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"matchers.js","names":[],"sources":["../src/matchers.ts"],"sourcesContent":["import { expect } from 'vitest';\n\nimport type { LayoutBox, UiNode, UiRole, UiSemanticState } from 'gesso-core';\n\nimport { textProperty } from './queries';\nimport { renderedFor } from './registry';\n\n/**\n * The matchers, and the reason the package has any.\n *\n * `expect(ui.getLayout(node)).toEqual({ x: 8, y: 8, width: 64, height: 32 })`\n * already works, so a matcher that only compared numbers would be\n * decoration. What these add is the failure message: a `toHaveBox` that\n * misses prints the node's layout explanation — the constraints it was\n * given, the rule that fixed each axis, and the ancestor it is laid out\n * from — which is L8's `engine.explain` arriving exactly where someone\n * is looking at a wrong number and asking why.\n *\n * That is the F7 exit criterion in one place: *fix a layout bug using\n * only the inspector and the testing library*. The inspector answers it\n * on a canvas; this answers it in a terminal.\n *\n * Importing this module registers them:\n *\n * ```ts\n * import 'gesso-testing/matchers';\n * ```\n *\n * A separate entry rather than the root barrel, because `expect.extend`\n * is a side effect on a global and importing `renderTest` should not\n * perform one.\n */\n\ninterface MatcherResult {\n pass: boolean;\n message: () => string;\n}\n\n/** The explanation for a node, or a note saying why there is none. */\nfunction explanationOf(node: UiNode): string {\n const rendered = renderedFor(node);\n if (rendered === null) {\n return 'This node was not mounted by renderTest(), so there is no layout engine to ask why.';\n }\n return rendered.explainText(node);\n}\n\nfunction boxOf(node: UiNode): LayoutBox | null {\n return renderedFor(node)?.getLayout(node) ?? null;\n}\n\nfunction visibleBoxOf(node: UiNode): LayoutBox | null {\n return renderedFor(node)?.getVisibleBox(node) ?? null;\n}\n\nfunction round(value: number): number {\n return Math.round(value * 1000) / 1000;\n}\n\nexport interface ExpectedSemantics {\n role?: UiRole;\n /** The accessible name. */\n name?: string;\n states?: readonly UiSemanticState[];\n disabled?: boolean;\n value?: number;\n}\n\nexport const matchers = {\n /**\n * The node's border box, in layout-root coordinates.\n *\n * Every field is optional, so `toHaveBox({ width: 64 })` asserts the\n * width and says nothing about the rest — which is what a layout test\n * usually means, and stops a test breaking because a sibling above it\n * grew by a pixel.\n */\n toHaveBox(received: UiNode, expected: Partial<LayoutBox>): MatcherResult {\n const box = boxOf(received);\n if (box === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const wrong = (Object.keys(expected) as (keyof LayoutBox)[]).filter(\n key => round(box[key]) !== round(expected[key] as number)\n );\n return {\n pass: wrong.length === 0,\n message: () =>\n wrong.length === 0\n ? `Expected '${received.id}' not to have box ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to have ${wrong.map(key => `${key} ${expected[key]}`).join(', ')}, ` +\n `but its box is ${JSON.stringify({\n x: round(box.x),\n y: round(box.y),\n width: round(box.width),\n height: round(box.height)\n })}.\\n\\nWhy:\\n${explanationOf(received)}`\n };\n },\n\n /**\n * Where the node is *seen*, with scroll offsets and sticky shifts\n * applied.\n *\n * The matcher to reach for when the claim is about scrolling. A\n * sticky header's laid-out box is at the top of its content whether\n * or not sticky works, so `toHaveBox` asserts nothing about it after\n * a scroll; this is the one that does.\n */\n toHaveVisibleBox(received: UiNode, expected: Partial<LayoutBox>): MatcherResult {\n const box = visibleBoxOf(received);\n if (box === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const wrong = (Object.keys(expected) as (keyof LayoutBox)[]).filter(\n key => round(box[key]) !== round(expected[key] as number)\n );\n return {\n pass: wrong.length === 0,\n message: () =>\n wrong.length === 0\n ? `Expected '${received.id}' not to be seen at ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to be seen at ${wrong.map(key => `${key} ${expected[key]}`).join(', ')}, ` +\n `but it is seen at ${JSON.stringify({\n x: round(box.x),\n y: round(box.y),\n width: round(box.width),\n height: round(box.height)\n })}.\\n\\nWhy:\\n${explanationOf(received)}`\n };\n },\n\n /** The text the node itself draws — not its descendants, and not its label. */\n toHaveText(received: UiNode, expected: string): MatcherResult {\n const actual = textProperty(received);\n return {\n pass: actual === expected,\n message: () =>\n actual === expected\n ? `Expected '${received.id}' not to have the text ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to have the text ${JSON.stringify(expected)}, but it has ` +\n `${actual === undefined ? 'none' : JSON.stringify(actual)}.`\n };\n },\n\n /**\n * What an assistive technology would announce for this node.\n *\n * Compares only the fields given, for the same reason `toHaveBox`\n * does: a component that gains a `posInSet` should not break every\n * test that ever asserted its role.\n */\n toHaveSemantics(received: UiNode, expected: ExpectedSemantics): MatcherResult {\n const rendered = renderedFor(received);\n if (rendered === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const record = rendered.querySemantics(received);\n if (record === null) {\n return {\n pass: false,\n message: () =>\n `Expected '${received.id}' to be in the semantics tree, but it is not — ` +\n `nothing gives it a role or a label, so no assistive technology can see it.\\n\\n${rendered.debug()}`\n };\n }\n const actual: ExpectedSemantics = {\n role: record.role,\n name: record.label,\n states: record.states ?? [],\n disabled: record.disabled === true,\n value: record.valueNow\n };\n const wrong = (Object.keys(expected) as (keyof ExpectedSemantics)[]).filter(key => {\n if (key === 'states') {\n const want = [...(expected.states ?? [])].sort();\n const have = [...(actual.states ?? [])].sort();\n return want.length !== have.length || want.some((state, index) => state !== have[index]);\n }\n return actual[key] !== expected[key];\n });\n return {\n pass: wrong.length === 0,\n message: () =>\n wrong.length === 0\n ? `Expected '${received.id}' not to have semantics ${JSON.stringify(expected)}.`\n : `Expected '${received.id}' to have ${wrong\n .map(key => `${key} ${JSON.stringify(expected[key])}`)\n .join(', ')}, but a screen reader sees ${JSON.stringify(actual)}.`\n };\n },\n\n /** Whether the runtime's focus manager currently has this node. */\n toHaveFocus(received: UiNode): MatcherResult {\n const rendered = renderedFor(received);\n if (rendered === null) {\n return {\n pass: false,\n message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`\n };\n }\n const focused = rendered.runtime.input.focus.focusedNode;\n return {\n pass: focused === received,\n message: () =>\n focused === received\n ? `Expected '${received.id}' not to have focus.`\n : `Expected '${received.id}' to have focus, but ${\n focused === null ? 'nothing does' : `'${focused.id}' does`\n }.`\n };\n }\n};\n\nexpect.extend(matchers);\n\ninterface GessoMatchers<R = unknown> {\n toHaveBox: (expected: Partial<LayoutBox>) => R;\n toHaveVisibleBox: (expected: Partial<LayoutBox>) => R;\n toHaveText: (expected: string) => R;\n toHaveSemantics: (expected: ExpectedSemantics) => R;\n toHaveFocus: () => R;\n}\n\n/**\n * `T` here is vitest's own default for the interface it is merging\n * into, and it has to be spelled the same way or the merge is an\n * error: `All declarations of 'Matchers' must have identical type\n * parameters.`\n */\ndeclare module 'vitest' {\n // oxlint-disable-next-line no-explicit-any\n interface Matchers<T = any> extends GessoMatchers<T> {}\n}\n"],"mappings":"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"}
@@ -31,7 +31,9 @@ function nodesUnder(root) {
31
31
  */
32
32
  function textProperty(node) {
33
33
  const text = textContentOf(node);
34
- return text.length > 0 ? text : void 0;
34
+ if (text.length > 0) return text;
35
+ const value = node.properties.get("value");
36
+ return typeof value === "string" && value.length > 0 ? value : void 0;
35
37
  }
36
38
  function recordMatches(record, role, options) {
37
39
  if (record.role !== role) return false;
@@ -191,4 +193,4 @@ function renderedFor(node) {
191
193
  //#endregion
192
194
  export { textProperty as a, nodesUnder as i, renderedFor as n, createQueries as r, registerRendered as t };
193
195
 
194
- //# sourceMappingURL=registry-BlqGkMSO.js.map
196
+ //# sourceMappingURL=registry-CyhS3PwD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry-CyhS3PwD.js","names":[],"sources":["../src/queries.ts","../src/registry.ts"],"sourcesContent":["import type { UiNode, UiRole, UiSemanticState, UiSemanticsRecord } from 'gesso-core';\nimport { textContentOf } from 'gesso-core';\n\nimport type { RenderedBase } from './renderTest';\n\n/** A string matches exactly after whitespace collapsing; a RegExp is tested. */\nexport type TextMatch = string | RegExp;\n\nexport interface RoleQueryOptions {\n /**\n * The accessible name, as a screen reader would announce it: the\n * node's `label` if it has one, else the text it draws.\n */\n name?: TextMatch;\n /** Every one of these must be on the record. */\n states?: readonly UiSemanticState[];\n /** Match only enabled (`false`) or only disabled (`true`) nodes. */\n disabled?: boolean;\n}\n\n/**\n * The queries a render answers.\n *\n * Three variants of each, and the difference between them is what\n * happens when the count is not one:\n *\n * - `getBy…` returns the single match and throws otherwise. This is\n * the one to reach for; the throw carries the tree.\n * - `queryBy…` returns `null` for no match and still throws for many,\n * because \"there is more than one\" is never the question `queryBy`\n * is asked.\n * - `getAllBy…` returns every match, in document order, and throws\n * only when there are none.\n *\n * `findBy…` is `getBy…` with frames: it drives the clock until the\n * match appears, for anything that arrives after the frame it was\n * asked for — a channel patch, a resolved image, a component that\n * awaited something.\n */\nexport interface Queries {\n getByRole(role: UiRole, options?: RoleQueryOptions): UiNode;\n queryByRole(role: UiRole, options?: RoleQueryOptions): UiNode | null;\n getAllByRole(role: UiRole, options?: RoleQueryOptions): UiNode[];\n findByRole(role: UiRole, options?: RoleQueryOptions & { maxFrames?: number }): Promise<UiNode>;\n\n /** By accessible name, whatever the role. */\n getByLabel(name: TextMatch): UiNode;\n queryByLabel(name: TextMatch): UiNode | null;\n getAllByLabel(name: TextMatch): UiNode[];\n findByLabel(name: TextMatch, options?: { maxFrames?: number }): Promise<UiNode>;\n\n /**\n * By the text a node actually draws.\n *\n * Distinct from `getByLabel`, and the difference matters: the `Text`\n * inside a `Button` is *claimed* as the button's name and so has no\n * semantics record of its own. `getByLabel('Save')` finds the button;\n * `getByText('Save')` finds the text node inside it.\n */\n getByText(text: TextMatch): UiNode;\n queryByText(text: TextMatch): UiNode | null;\n getAllByText(text: TextMatch): UiNode[];\n findByText(text: TextMatch, options?: { maxFrames?: number }): Promise<UiNode>;\n\n /** Every node under the layout root, in document order. */\n allNodes(): UiNode[];\n /** The text every node under `node` draws, in document order. */\n textOf(node?: UiNode): string[];\n}\n\n/** Collapses runs of whitespace and trims, the way a reader would read it. */\nfunction normalize(value: string): string {\n return value.replace(/\\s+/g, ' ').trim();\n}\n\nfunction matches(actual: string | undefined, expected: TextMatch): boolean {\n if (actual === undefined) {\n return false;\n }\n const text = normalize(actual);\n return typeof expected === 'string' ? text === normalize(expected) : expected.test(text);\n}\n\nfunction describeMatch(expected: TextMatch): string {\n return typeof expected === 'string' ? JSON.stringify(expected) : String(expected);\n}\n\nexport function nodesUnder(root: UiNode): UiNode[] {\n const out: UiNode[] = [];\n const visit = (node: UiNode): void => {\n out.push(node);\n for (let child = node.firstChild; child !== null; child = child.nextSibling) {\n visit(child);\n }\n };\n visit(root);\n return out;\n}\n\n/**\n * The text a node draws, if it draws any.\n *\n * A paragraph given runs holds its text in them rather than in a\n * `text` property, and `getByText` has to find it either way, so this\n * asks the same question the layout engine, paint, selection, find and\n * the semantics mirror all ask.\n */\nexport function textProperty(node: UiNode): string | undefined {\n const text = textContentOf(node);\n if (text.length > 0) {\n return text;\n }\n // An `EditableText` keeps what it shows in `value`, not in `text`.\n // Without this nothing in a spec can read what a field is\n // displaying — `getByText` skips every input on the page and\n // `toHaveText` reports that a field full of text has none. The\n // library's own specs never noticed because they assert the form\n // model instead, which is the one thing a user cannot see.\n const value = node.properties.get('value');\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n\nfunction recordMatches(record: UiSemanticsRecord, role: UiRole, options: RoleQueryOptions): boolean {\n if (record.role !== role) {\n return false;\n }\n if (options.name !== undefined && !matches(record.label, options.name)) {\n return false;\n }\n if (options.disabled !== undefined && (record.disabled === true) !== options.disabled) {\n return false;\n }\n if (options.states !== undefined) {\n const states = record.states ?? [];\n if (!options.states.every(state => states.includes(state))) {\n return false;\n }\n }\n return true;\n}\n\nfunction describeRoleQuery(role: UiRole, options: RoleQueryOptions): string {\n const parts = [`role ${JSON.stringify(role)}`];\n if (options.name !== undefined) {\n parts.push(`name ${describeMatch(options.name)}`);\n }\n if (options.states !== undefined) {\n parts.push(`states [${options.states.join(', ')}]`);\n }\n if (options.disabled !== undefined) {\n parts.push(options.disabled ? 'disabled' : 'enabled');\n }\n return parts.join(', ');\n}\n\n/**\n * What is actually there, so a failed query does not send the reader\n * to a debugger.\n *\n * A missed query is nearly always a name that differs by a word or a\n * role the component does not emit, and both are answered by printing\n * the tree the assistive technology can see next to the tree that was\n * drawn.\n */\nfunction failure(base: RenderedBase, wanted: string, found: number): Error {\n const roles = [...base.semanticsTree().values()]\n .map(\n record =>\n ` ${record.role ?? '(no role)'}${record.label === undefined ? '' : ` · ${JSON.stringify(record.label)}`}`\n )\n .join('\\n');\n const count = found === 0 ? 'Nothing matches' : `${found} nodes match`;\n return new Error(\n `${count} ${wanted}.\\n\\n` +\n `The semantics tree has:\\n${roles.length > 0 ? roles : ' (nothing)'}\\n\\n` +\n `The node tree is:\\n${base.debug()}`\n );\n}\n\n/**\n * Runs frames until the query answers, then answers.\n *\n * The cap is frames rather than milliseconds because the clock here is\n * manual: there is no wall clock to time out against, and \"a hundred\n * frames and it still is not there\" is both reproducible and the more\n * useful thing to report.\n */\nasync function findWith(base: RenderedBase, query: () => UiNode[], wanted: string, maxFrames: number): Promise<UiNode> {\n for (let index = 0; index <= maxFrames; index++) {\n const found = query();\n if (found.length === 1) {\n return found[0];\n }\n if (found.length > 1) {\n throw failure(base, wanted, found.length);\n }\n await new Promise(resolve => setTimeout(resolve, 0));\n base.frame();\n }\n throw failure(base, `${wanted} after ${maxFrames} frames`, 0);\n}\n\nconst DEFAULT_MAX_FRAMES = 100;\n\nexport function createQueries(base: RenderedBase): Queries {\n const root = (): UiNode => base.runtime.layoutRoot();\n const allNodes = (): UiNode[] => nodesUnder(root());\n\n /**\n * Every node by id, so a semantics record can be turned back into the\n * node it describes. Records are keyed by node id and the tree is\n * built by a document-order walk, so iterating the semantics map and\n * looking each record up here yields matches in tree order.\n */\n const nodeById = (): Map<string, UiNode> => new Map(allNodes().map(node => [node.id, node]));\n\n const byRole = (role: UiRole, options: RoleQueryOptions): UiNode[] => {\n const nodes = nodeById();\n const found: UiNode[] = [];\n for (const record of base.semanticsTree().values()) {\n if (!recordMatches(record, role, options)) {\n continue;\n }\n const node = nodes.get(record.id);\n if (node !== undefined) {\n found.push(node);\n }\n }\n return found;\n };\n\n const byLabel = (name: TextMatch): UiNode[] => {\n const nodes = nodeById();\n const found: UiNode[] = [];\n for (const record of base.semanticsTree().values()) {\n if (!matches(record.label, name)) {\n continue;\n }\n const node = nodes.get(record.id);\n if (node !== undefined) {\n found.push(node);\n }\n }\n return found;\n };\n\n const byText = (text: TextMatch): UiNode[] => allNodes().filter(node => matches(textProperty(node), text));\n\n /** The three variants, built once from one finder. */\n function variants<A extends unknown[]>(find: (...args: A) => UiNode[], describe: (...args: A) => string) {\n const getAll = (...args: A): UiNode[] => {\n const found = find(...args);\n if (found.length === 0) {\n throw failure(base, describe(...args), 0);\n }\n return found;\n };\n const get = (...args: A): UiNode => {\n const found = find(...args);\n if (found.length !== 1) {\n throw failure(base, describe(...args), found.length);\n }\n return found[0];\n };\n const query = (...args: A): UiNode | null => {\n const found = find(...args);\n if (found.length > 1) {\n throw failure(base, describe(...args), found.length);\n }\n return found[0] ?? null;\n };\n return { get, query, getAll };\n }\n\n const role = variants(\n (r: UiRole, options: RoleQueryOptions = {}) => byRole(r, options),\n (r: UiRole, options: RoleQueryOptions = {}) => describeRoleQuery(r, options)\n );\n const label = variants(\n (name: TextMatch) => byLabel(name),\n (name: TextMatch) => `the accessible name ${describeMatch(name)}`\n );\n const text = variants(\n (value: TextMatch) => byText(value),\n (value: TextMatch) => `the text ${describeMatch(value)}`\n );\n\n return {\n getByRole: role.get,\n queryByRole: role.query,\n getAllByRole: role.getAll,\n findByRole: (r, options = {}) => {\n const { maxFrames = DEFAULT_MAX_FRAMES, ...rest } = options;\n return findWith(base, () => byRole(r, rest), describeRoleQuery(r, rest), maxFrames);\n },\n\n getByLabel: label.get,\n queryByLabel: label.query,\n getAllByLabel: label.getAll,\n findByLabel: (name, options = {}) =>\n findWith(\n base,\n () => byLabel(name),\n `the accessible name ${describeMatch(name)}`,\n options.maxFrames ?? DEFAULT_MAX_FRAMES\n ),\n\n getByText: text.get,\n queryByText: text.query,\n getAllByText: text.getAll,\n findByText: (value, options = {}) =>\n findWith(base, () => byText(value), `the text ${describeMatch(value)}`, options.maxFrames ?? DEFAULT_MAX_FRAMES),\n\n allNodes,\n textOf: node =>\n nodesUnder(node ?? root())\n .map(textProperty)\n .filter((value): value is string => value !== undefined)\n };\n}\n","import type { UiNode } from 'gesso-core';\n\nimport type { Rendered } from './renderTest';\n\n/**\n * Which render a node came from.\n *\n * The matchers need it and cannot be handed it: `expect(node).toHaveBox(…)`\n * gives them a node and nothing else, and a node has no idea which\n * runtime laid it out — the graph is deliberately free of back\n * references to anything above it. So `renderTest` registers its layout\n * root here and `renderedFor` walks up from any node to find it.\n *\n * A `WeakMap` keyed by the root, rather than a module-level \"current\n * render\": two trees can be mounted at once (a spec comparing them,\n * or a suite running in parallel), and a global would make the second\n * one silently answer for the first.\n */\nconst RENDERS = new WeakMap<UiNode, Rendered>();\n\nexport function registerRendered(root: UiNode, rendered: Rendered): void {\n RENDERS.set(root, rendered);\n}\n\n/** The render `node` belongs to, or null if it was not mounted by one. */\nexport function renderedFor(node: UiNode): Rendered | null {\n for (let current: UiNode | null = node; current !== null; current = current.parent) {\n const rendered = RENDERS.get(current);\n if (rendered !== undefined) {\n return rendered;\n }\n }\n return null;\n}\n"],"mappings":";;;AAuEA,SAAS,UAAU,OAAuB;CACxC,OAAO,MAAM,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAAK;AACzC;AAEA,SAAS,QAAQ,QAA4B,UAA8B;CACzE,IAAI,WAAW,KAAA,GACb,OAAO;CAET,MAAM,OAAO,UAAU,MAAM;CAC7B,OAAO,OAAO,aAAa,WAAW,SAAS,UAAU,QAAQ,IAAI,SAAS,KAAK,IAAI;AACzF;AAEA,SAAS,cAAc,UAA6B;CAClD,OAAO,OAAO,aAAa,WAAW,KAAK,UAAU,QAAQ,IAAI,OAAO,QAAQ;AAClF;AAEA,SAAgB,WAAW,MAAwB;CACjD,MAAM,MAAgB,CAAC;CACvB,MAAM,SAAS,SAAuB;EACpC,IAAI,KAAK,IAAI;EACb,KAAK,IAAI,QAAQ,KAAK,YAAY,UAAU,MAAM,QAAQ,MAAM,aAC9D,MAAM,KAAK;CAEf;CACA,MAAM,IAAI;CACV,OAAO;AACT;;;;;;;;;AAUA,SAAgB,aAAa,MAAkC;CAC7D,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,KAAK,SAAS,GAChB,OAAO;CAQT,MAAM,QAAQ,KAAK,WAAW,IAAI,OAAO;CACzC,OAAO,OAAO,UAAU,YAAY,MAAM,SAAS,IAAI,QAAQ,KAAA;AACjE;AAEA,SAAS,cAAc,QAA2B,MAAc,SAAoC;CAClG,IAAI,OAAO,SAAS,MAClB,OAAO;CAET,IAAI,QAAQ,SAAS,KAAA,KAAa,CAAC,QAAQ,OAAO,OAAO,QAAQ,IAAI,GACnE,OAAO;CAET,IAAI,QAAQ,aAAa,KAAA,KAAc,OAAO,aAAa,SAAU,QAAQ,UAC3E,OAAO;CAET,IAAI,QAAQ,WAAW,KAAA,GAAW;EAChC,MAAM,SAAS,OAAO,UAAU,CAAC;EACjC,IAAI,CAAC,QAAQ,OAAO,OAAM,UAAS,OAAO,SAAS,KAAK,CAAC,GACvD,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,MAAc,SAAmC;CAC1E,MAAM,QAAQ,CAAC,QAAQ,KAAK,UAAU,IAAI,GAAG;CAC7C,IAAI,QAAQ,SAAS,KAAA,GACnB,MAAM,KAAK,QAAQ,cAAc,QAAQ,IAAI,GAAG;CAElD,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,KAAK,WAAW,QAAQ,OAAO,KAAK,IAAI,EAAE,EAAE;CAEpD,IAAI,QAAQ,aAAa,KAAA,GACvB,MAAM,KAAK,QAAQ,WAAW,aAAa,SAAS;CAEtD,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;AAWA,SAAS,QAAQ,MAAoB,QAAgB,OAAsB;CACzE,MAAM,QAAQ,CAAC,GAAG,KAAK,cAAc,CAAC,CAAC,OAAO,CAAC,CAAC,CAC7C,KACC,WACE,KAAK,OAAO,QAAQ,cAAc,OAAO,UAAU,KAAA,IAAY,KAAK,MAAM,KAAK,UAAU,OAAO,KAAK,KACzG,CAAC,CACA,KAAK,IAAI;CACZ,MAAM,QAAQ,UAAU,IAAI,oBAAoB,GAAG,MAAM;CACzD,uBAAO,IAAI,MACT,GAAG,MAAM,GAAG,OAAO,gCACW,MAAM,SAAS,IAAI,QAAQ,cAAc,yBAC/C,KAAK,MAAM,GACrC;AACF;;;;;;;;;AAUA,eAAe,SAAS,MAAoB,OAAuB,QAAgB,WAAoC;CACrH,KAAK,IAAI,QAAQ,GAAG,SAAS,WAAW,SAAS;EAC/C,MAAM,QAAQ,MAAM;EACpB,IAAI,MAAM,WAAW,GACnB,OAAO,MAAM;EAEf,IAAI,MAAM,SAAS,GACjB,MAAM,QAAQ,MAAM,QAAQ,MAAM,MAAM;EAE1C,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,CAAC,CAAC;EACnD,KAAK,MAAM;CACb;CACA,MAAM,QAAQ,MAAM,GAAG,OAAO,SAAS,UAAU,UAAU,CAAC;AAC9D;AAEA,MAAM,qBAAqB;AAE3B,SAAgB,cAAc,MAA6B;CACzD,MAAM,aAAqB,KAAK,QAAQ,WAAW;CACnD,MAAM,iBAA2B,WAAW,KAAK,CAAC;;;;;;;CAQlD,MAAM,iBAAsC,IAAI,IAAI,SAAS,CAAC,CAAC,KAAI,SAAQ,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;CAE3F,MAAM,UAAU,MAAc,YAAwC;EACpE,MAAM,QAAQ,SAAS;EACvB,MAAM,QAAkB,CAAC;EACzB,KAAK,MAAM,UAAU,KAAK,cAAc,CAAC,CAAC,OAAO,GAAG;GAClD,IAAI,CAAC,cAAc,QAAQ,MAAM,OAAO,GACtC;GAEF,MAAM,OAAO,MAAM,IAAI,OAAO,EAAE;GAChC,IAAI,SAAS,KAAA,GACX,MAAM,KAAK,IAAI;EAEnB;EACA,OAAO;CACT;CAEA,MAAM,WAAW,SAA8B;EAC7C,MAAM,QAAQ,SAAS;EACvB,MAAM,QAAkB,CAAC;EACzB,KAAK,MAAM,UAAU,KAAK,cAAc,CAAC,CAAC,OAAO,GAAG;GAClD,IAAI,CAAC,QAAQ,OAAO,OAAO,IAAI,GAC7B;GAEF,MAAM,OAAO,MAAM,IAAI,OAAO,EAAE;GAChC,IAAI,SAAS,KAAA,GACX,MAAM,KAAK,IAAI;EAEnB;EACA,OAAO;CACT;CAEA,MAAM,UAAU,SAA8B,SAAS,CAAC,CAAC,QAAO,SAAQ,QAAQ,aAAa,IAAI,GAAG,IAAI,CAAC;;CAGzG,SAAS,SAA8B,MAAgC,UAAkC;EACvG,MAAM,UAAU,GAAG,SAAsB;GACvC,MAAM,QAAQ,KAAK,GAAG,IAAI;GAC1B,IAAI,MAAM,WAAW,GACnB,MAAM,QAAQ,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC;GAE1C,OAAO;EACT;EACA,MAAM,OAAO,GAAG,SAAoB;GAClC,MAAM,QAAQ,KAAK,GAAG,IAAI;GAC1B,IAAI,MAAM,WAAW,GACnB,MAAM,QAAQ,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,MAAM;GAErD,OAAO,MAAM;EACf;EACA,MAAM,SAAS,GAAG,SAA2B;GAC3C,MAAM,QAAQ,KAAK,GAAG,IAAI;GAC1B,IAAI,MAAM,SAAS,GACjB,MAAM,QAAQ,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,MAAM;GAErD,OAAO,MAAM,MAAM;EACrB;EACA,OAAO;GAAE;GAAK;GAAO;EAAO;CAC9B;CAEA,MAAM,OAAO,UACV,GAAW,UAA4B,CAAC,MAAM,OAAO,GAAG,OAAO,IAC/D,GAAW,UAA4B,CAAC,MAAM,kBAAkB,GAAG,OAAO,CAC7E;CACA,MAAM,QAAQ,UACX,SAAoB,QAAQ,IAAI,IAChC,SAAoB,uBAAuB,cAAc,IAAI,GAChE;CACA,MAAM,OAAO,UACV,UAAqB,OAAO,KAAK,IACjC,UAAqB,YAAY,cAAc,KAAK,GACvD;CAEA,OAAO;EACL,WAAW,KAAK;EAChB,aAAa,KAAK;EAClB,cAAc,KAAK;EACnB,aAAa,GAAG,UAAU,CAAC,MAAM;GAC/B,MAAM,EAAE,YAAY,oBAAoB,GAAG,SAAS;GACpD,OAAO,SAAS,YAAY,OAAO,GAAG,IAAI,GAAG,kBAAkB,GAAG,IAAI,GAAG,SAAS;EACpF;EAEA,YAAY,MAAM;EAClB,cAAc,MAAM;EACpB,eAAe,MAAM;EACrB,cAAc,MAAM,UAAU,CAAC,MAC7B,SACE,YACM,QAAQ,IAAI,GAClB,uBAAuB,cAAc,IAAI,KACzC,QAAQ,aAAa,kBACvB;EAEF,WAAW,KAAK;EAChB,aAAa,KAAK;EAClB,cAAc,KAAK;EACnB,aAAa,OAAO,UAAU,CAAC,MAC7B,SAAS,YAAY,OAAO,KAAK,GAAG,YAAY,cAAc,KAAK,KAAK,QAAQ,aAAa,kBAAkB;EAEjH;EACA,SAAQ,SACN,WAAW,QAAQ,KAAK,CAAC,CAAC,CACvB,IAAI,YAAY,CAAC,CACjB,QAAQ,UAA2B,UAAU,KAAA,CAAS;CAC7D;AACF;;;;;;;;;;;;;;;;;AC7SA,MAAM,0BAAU,IAAI,QAA0B;AAE9C,SAAgB,iBAAiB,MAAc,UAA0B;CACvE,QAAQ,IAAI,MAAM,QAAQ;AAC5B;;AAGA,SAAgB,YAAY,MAA+B;CACzD,KAAK,IAAI,UAAyB,MAAM,YAAY,MAAM,UAAU,QAAQ,QAAQ;EAClF,MAAM,WAAW,QAAQ,IAAI,OAAO;EACpC,IAAI,aAAa,KAAA,GACf,OAAO;CAEX;CACA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gesso-testing",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Mount a component without a browser, query it the way a screen reader would, and read back why a box is the size it is.",
5
5
  "license": "MIT",
6
6
  "author": "Kevin Baker",
@@ -49,8 +49,8 @@
49
49
  "LICENSE"
50
50
  ],
51
51
  "dependencies": {
52
- "gesso-core": "^0.2.1",
53
- "gesso-framework": "^0.2.1"
52
+ "gesso-core": "^0.3.0",
53
+ "gesso-framework": "^0.3.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "rxjs": "^7.8.2",
@@ -1 +0,0 @@
1
- {"version":3,"file":"registry-BlqGkMSO.js","names":[],"sources":["../src/queries.ts","../src/registry.ts"],"sourcesContent":["import type { UiNode, UiRole, UiSemanticState, UiSemanticsRecord } from 'gesso-core';\nimport { textContentOf } from 'gesso-core';\n\nimport type { RenderedBase } from './renderTest';\n\n/** A string matches exactly after whitespace collapsing; a RegExp is tested. */\nexport type TextMatch = string | RegExp;\n\nexport interface RoleQueryOptions {\n /**\n * The accessible name, as a screen reader would announce it: the\n * node's `label` if it has one, else the text it draws.\n */\n name?: TextMatch;\n /** Every one of these must be on the record. */\n states?: readonly UiSemanticState[];\n /** Match only enabled (`false`) or only disabled (`true`) nodes. */\n disabled?: boolean;\n}\n\n/**\n * The queries a render answers.\n *\n * Three variants of each, and the difference between them is what\n * happens when the count is not one:\n *\n * - `getBy…` returns the single match and throws otherwise. This is\n * the one to reach for; the throw carries the tree.\n * - `queryBy…` returns `null` for no match and still throws for many,\n * because \"there is more than one\" is never the question `queryBy`\n * is asked.\n * - `getAllBy…` returns every match, in document order, and throws\n * only when there are none.\n *\n * `findBy…` is `getBy…` with frames: it drives the clock until the\n * match appears, for anything that arrives after the frame it was\n * asked for — a channel patch, a resolved image, a component that\n * awaited something.\n */\nexport interface Queries {\n getByRole(role: UiRole, options?: RoleQueryOptions): UiNode;\n queryByRole(role: UiRole, options?: RoleQueryOptions): UiNode | null;\n getAllByRole(role: UiRole, options?: RoleQueryOptions): UiNode[];\n findByRole(role: UiRole, options?: RoleQueryOptions & { maxFrames?: number }): Promise<UiNode>;\n\n /** By accessible name, whatever the role. */\n getByLabel(name: TextMatch): UiNode;\n queryByLabel(name: TextMatch): UiNode | null;\n getAllByLabel(name: TextMatch): UiNode[];\n findByLabel(name: TextMatch, options?: { maxFrames?: number }): Promise<UiNode>;\n\n /**\n * By the text a node actually draws.\n *\n * Distinct from `getByLabel`, and the difference matters: the `Text`\n * inside a `Button` is *claimed* as the button's name and so has no\n * semantics record of its own. `getByLabel('Save')` finds the button;\n * `getByText('Save')` finds the text node inside it.\n */\n getByText(text: TextMatch): UiNode;\n queryByText(text: TextMatch): UiNode | null;\n getAllByText(text: TextMatch): UiNode[];\n findByText(text: TextMatch, options?: { maxFrames?: number }): Promise<UiNode>;\n\n /** Every node under the layout root, in document order. */\n allNodes(): UiNode[];\n /** The text every node under `node` draws, in document order. */\n textOf(node?: UiNode): string[];\n}\n\n/** Collapses runs of whitespace and trims, the way a reader would read it. */\nfunction normalize(value: string): string {\n return value.replace(/\\s+/g, ' ').trim();\n}\n\nfunction matches(actual: string | undefined, expected: TextMatch): boolean {\n if (actual === undefined) {\n return false;\n }\n const text = normalize(actual);\n return typeof expected === 'string' ? text === normalize(expected) : expected.test(text);\n}\n\nfunction describeMatch(expected: TextMatch): string {\n return typeof expected === 'string' ? JSON.stringify(expected) : String(expected);\n}\n\nexport function nodesUnder(root: UiNode): UiNode[] {\n const out: UiNode[] = [];\n const visit = (node: UiNode): void => {\n out.push(node);\n for (let child = node.firstChild; child !== null; child = child.nextSibling) {\n visit(child);\n }\n };\n visit(root);\n return out;\n}\n\n/**\n * The text a node draws, if it draws any.\n *\n * A paragraph given runs holds its text in them rather than in a\n * `text` property, and `getByText` has to find it either way, so this\n * asks the same question the layout engine, paint, selection, find and\n * the semantics mirror all ask.\n */\nexport function textProperty(node: UiNode): string | undefined {\n const text = textContentOf(node);\n return text.length > 0 ? text : undefined;\n}\n\nfunction recordMatches(record: UiSemanticsRecord, role: UiRole, options: RoleQueryOptions): boolean {\n if (record.role !== role) {\n return false;\n }\n if (options.name !== undefined && !matches(record.label, options.name)) {\n return false;\n }\n if (options.disabled !== undefined && (record.disabled === true) !== options.disabled) {\n return false;\n }\n if (options.states !== undefined) {\n const states = record.states ?? [];\n if (!options.states.every(state => states.includes(state))) {\n return false;\n }\n }\n return true;\n}\n\nfunction describeRoleQuery(role: UiRole, options: RoleQueryOptions): string {\n const parts = [`role ${JSON.stringify(role)}`];\n if (options.name !== undefined) {\n parts.push(`name ${describeMatch(options.name)}`);\n }\n if (options.states !== undefined) {\n parts.push(`states [${options.states.join(', ')}]`);\n }\n if (options.disabled !== undefined) {\n parts.push(options.disabled ? 'disabled' : 'enabled');\n }\n return parts.join(', ');\n}\n\n/**\n * What is actually there, so a failed query does not send the reader\n * to a debugger.\n *\n * A missed query is nearly always a name that differs by a word or a\n * role the component does not emit, and both are answered by printing\n * the tree the assistive technology can see next to the tree that was\n * drawn.\n */\nfunction failure(base: RenderedBase, wanted: string, found: number): Error {\n const roles = [...base.semanticsTree().values()]\n .map(\n record =>\n ` ${record.role ?? '(no role)'}${record.label === undefined ? '' : ` · ${JSON.stringify(record.label)}`}`\n )\n .join('\\n');\n const count = found === 0 ? 'Nothing matches' : `${found} nodes match`;\n return new Error(\n `${count} ${wanted}.\\n\\n` +\n `The semantics tree has:\\n${roles.length > 0 ? roles : ' (nothing)'}\\n\\n` +\n `The node tree is:\\n${base.debug()}`\n );\n}\n\n/**\n * Runs frames until the query answers, then answers.\n *\n * The cap is frames rather than milliseconds because the clock here is\n * manual: there is no wall clock to time out against, and \"a hundred\n * frames and it still is not there\" is both reproducible and the more\n * useful thing to report.\n */\nasync function findWith(base: RenderedBase, query: () => UiNode[], wanted: string, maxFrames: number): Promise<UiNode> {\n for (let index = 0; index <= maxFrames; index++) {\n const found = query();\n if (found.length === 1) {\n return found[0];\n }\n if (found.length > 1) {\n throw failure(base, wanted, found.length);\n }\n await new Promise(resolve => setTimeout(resolve, 0));\n base.frame();\n }\n throw failure(base, `${wanted} after ${maxFrames} frames`, 0);\n}\n\nconst DEFAULT_MAX_FRAMES = 100;\n\nexport function createQueries(base: RenderedBase): Queries {\n const root = (): UiNode => base.runtime.layoutRoot();\n const allNodes = (): UiNode[] => nodesUnder(root());\n\n /**\n * Every node by id, so a semantics record can be turned back into the\n * node it describes. Records are keyed by node id and the tree is\n * built by a document-order walk, so iterating the semantics map and\n * looking each record up here yields matches in tree order.\n */\n const nodeById = (): Map<string, UiNode> => new Map(allNodes().map(node => [node.id, node]));\n\n const byRole = (role: UiRole, options: RoleQueryOptions): UiNode[] => {\n const nodes = nodeById();\n const found: UiNode[] = [];\n for (const record of base.semanticsTree().values()) {\n if (!recordMatches(record, role, options)) {\n continue;\n }\n const node = nodes.get(record.id);\n if (node !== undefined) {\n found.push(node);\n }\n }\n return found;\n };\n\n const byLabel = (name: TextMatch): UiNode[] => {\n const nodes = nodeById();\n const found: UiNode[] = [];\n for (const record of base.semanticsTree().values()) {\n if (!matches(record.label, name)) {\n continue;\n }\n const node = nodes.get(record.id);\n if (node !== undefined) {\n found.push(node);\n }\n }\n return found;\n };\n\n const byText = (text: TextMatch): UiNode[] => allNodes().filter(node => matches(textProperty(node), text));\n\n /** The three variants, built once from one finder. */\n function variants<A extends unknown[]>(find: (...args: A) => UiNode[], describe: (...args: A) => string) {\n const getAll = (...args: A): UiNode[] => {\n const found = find(...args);\n if (found.length === 0) {\n throw failure(base, describe(...args), 0);\n }\n return found;\n };\n const get = (...args: A): UiNode => {\n const found = find(...args);\n if (found.length !== 1) {\n throw failure(base, describe(...args), found.length);\n }\n return found[0];\n };\n const query = (...args: A): UiNode | null => {\n const found = find(...args);\n if (found.length > 1) {\n throw failure(base, describe(...args), found.length);\n }\n return found[0] ?? null;\n };\n return { get, query, getAll };\n }\n\n const role = variants(\n (r: UiRole, options: RoleQueryOptions = {}) => byRole(r, options),\n (r: UiRole, options: RoleQueryOptions = {}) => describeRoleQuery(r, options)\n );\n const label = variants(\n (name: TextMatch) => byLabel(name),\n (name: TextMatch) => `the accessible name ${describeMatch(name)}`\n );\n const text = variants(\n (value: TextMatch) => byText(value),\n (value: TextMatch) => `the text ${describeMatch(value)}`\n );\n\n return {\n getByRole: role.get,\n queryByRole: role.query,\n getAllByRole: role.getAll,\n findByRole: (r, options = {}) => {\n const { maxFrames = DEFAULT_MAX_FRAMES, ...rest } = options;\n return findWith(base, () => byRole(r, rest), describeRoleQuery(r, rest), maxFrames);\n },\n\n getByLabel: label.get,\n queryByLabel: label.query,\n getAllByLabel: label.getAll,\n findByLabel: (name, options = {}) =>\n findWith(\n base,\n () => byLabel(name),\n `the accessible name ${describeMatch(name)}`,\n options.maxFrames ?? DEFAULT_MAX_FRAMES\n ),\n\n getByText: text.get,\n queryByText: text.query,\n getAllByText: text.getAll,\n findByText: (value, options = {}) =>\n findWith(base, () => byText(value), `the text ${describeMatch(value)}`, options.maxFrames ?? DEFAULT_MAX_FRAMES),\n\n allNodes,\n textOf: node =>\n nodesUnder(node ?? root())\n .map(textProperty)\n .filter((value): value is string => value !== undefined)\n };\n}\n","import type { UiNode } from 'gesso-core';\n\nimport type { Rendered } from './renderTest';\n\n/**\n * Which render a node came from.\n *\n * The matchers need it and cannot be handed it: `expect(node).toHaveBox(…)`\n * gives them a node and nothing else, and a node has no idea which\n * runtime laid it out — the graph is deliberately free of back\n * references to anything above it. So `renderTest` registers its layout\n * root here and `renderedFor` walks up from any node to find it.\n *\n * A `WeakMap` keyed by the root, rather than a module-level \"current\n * render\": two trees can be mounted at once (a spec comparing them,\n * or a suite running in parallel), and a global would make the second\n * one silently answer for the first.\n */\nconst RENDERS = new WeakMap<UiNode, Rendered>();\n\nexport function registerRendered(root: UiNode, rendered: Rendered): void {\n RENDERS.set(root, rendered);\n}\n\n/** The render `node` belongs to, or null if it was not mounted by one. */\nexport function renderedFor(node: UiNode): Rendered | null {\n for (let current: UiNode | null = node; current !== null; current = current.parent) {\n const rendered = RENDERS.get(current);\n if (rendered !== undefined) {\n return rendered;\n }\n }\n return null;\n}\n"],"mappings":";;;AAuEA,SAAS,UAAU,OAAuB;CACxC,OAAO,MAAM,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAAK;AACzC;AAEA,SAAS,QAAQ,QAA4B,UAA8B;CACzE,IAAI,WAAW,KAAA,GACb,OAAO;CAET,MAAM,OAAO,UAAU,MAAM;CAC7B,OAAO,OAAO,aAAa,WAAW,SAAS,UAAU,QAAQ,IAAI,SAAS,KAAK,IAAI;AACzF;AAEA,SAAS,cAAc,UAA6B;CAClD,OAAO,OAAO,aAAa,WAAW,KAAK,UAAU,QAAQ,IAAI,OAAO,QAAQ;AAClF;AAEA,SAAgB,WAAW,MAAwB;CACjD,MAAM,MAAgB,CAAC;CACvB,MAAM,SAAS,SAAuB;EACpC,IAAI,KAAK,IAAI;EACb,KAAK,IAAI,QAAQ,KAAK,YAAY,UAAU,MAAM,QAAQ,MAAM,aAC9D,MAAM,KAAK;CAEf;CACA,MAAM,IAAI;CACV,OAAO;AACT;;;;;;;;;AAUA,SAAgB,aAAa,MAAkC;CAC7D,MAAM,OAAO,cAAc,IAAI;CAC/B,OAAO,KAAK,SAAS,IAAI,OAAO,KAAA;AAClC;AAEA,SAAS,cAAc,QAA2B,MAAc,SAAoC;CAClG,IAAI,OAAO,SAAS,MAClB,OAAO;CAET,IAAI,QAAQ,SAAS,KAAA,KAAa,CAAC,QAAQ,OAAO,OAAO,QAAQ,IAAI,GACnE,OAAO;CAET,IAAI,QAAQ,aAAa,KAAA,KAAc,OAAO,aAAa,SAAU,QAAQ,UAC3E,OAAO;CAET,IAAI,QAAQ,WAAW,KAAA,GAAW;EAChC,MAAM,SAAS,OAAO,UAAU,CAAC;EACjC,IAAI,CAAC,QAAQ,OAAO,OAAM,UAAS,OAAO,SAAS,KAAK,CAAC,GACvD,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,MAAc,SAAmC;CAC1E,MAAM,QAAQ,CAAC,QAAQ,KAAK,UAAU,IAAI,GAAG;CAC7C,IAAI,QAAQ,SAAS,KAAA,GACnB,MAAM,KAAK,QAAQ,cAAc,QAAQ,IAAI,GAAG;CAElD,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,KAAK,WAAW,QAAQ,OAAO,KAAK,IAAI,EAAE,EAAE;CAEpD,IAAI,QAAQ,aAAa,KAAA,GACvB,MAAM,KAAK,QAAQ,WAAW,aAAa,SAAS;CAEtD,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;AAWA,SAAS,QAAQ,MAAoB,QAAgB,OAAsB;CACzE,MAAM,QAAQ,CAAC,GAAG,KAAK,cAAc,CAAC,CAAC,OAAO,CAAC,CAAC,CAC7C,KACC,WACE,KAAK,OAAO,QAAQ,cAAc,OAAO,UAAU,KAAA,IAAY,KAAK,MAAM,KAAK,UAAU,OAAO,KAAK,KACzG,CAAC,CACA,KAAK,IAAI;CACZ,MAAM,QAAQ,UAAU,IAAI,oBAAoB,GAAG,MAAM;CACzD,uBAAO,IAAI,MACT,GAAG,MAAM,GAAG,OAAO,gCACW,MAAM,SAAS,IAAI,QAAQ,cAAc,yBAC/C,KAAK,MAAM,GACrC;AACF;;;;;;;;;AAUA,eAAe,SAAS,MAAoB,OAAuB,QAAgB,WAAoC;CACrH,KAAK,IAAI,QAAQ,GAAG,SAAS,WAAW,SAAS;EAC/C,MAAM,QAAQ,MAAM;EACpB,IAAI,MAAM,WAAW,GACnB,OAAO,MAAM;EAEf,IAAI,MAAM,SAAS,GACjB,MAAM,QAAQ,MAAM,QAAQ,MAAM,MAAM;EAE1C,MAAM,IAAI,SAAQ,YAAW,WAAW,SAAS,CAAC,CAAC;EACnD,KAAK,MAAM;CACb;CACA,MAAM,QAAQ,MAAM,GAAG,OAAO,SAAS,UAAU,UAAU,CAAC;AAC9D;AAEA,MAAM,qBAAqB;AAE3B,SAAgB,cAAc,MAA6B;CACzD,MAAM,aAAqB,KAAK,QAAQ,WAAW;CACnD,MAAM,iBAA2B,WAAW,KAAK,CAAC;;;;;;;CAQlD,MAAM,iBAAsC,IAAI,IAAI,SAAS,CAAC,CAAC,KAAI,SAAQ,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;CAE3F,MAAM,UAAU,MAAc,YAAwC;EACpE,MAAM,QAAQ,SAAS;EACvB,MAAM,QAAkB,CAAC;EACzB,KAAK,MAAM,UAAU,KAAK,cAAc,CAAC,CAAC,OAAO,GAAG;GAClD,IAAI,CAAC,cAAc,QAAQ,MAAM,OAAO,GACtC;GAEF,MAAM,OAAO,MAAM,IAAI,OAAO,EAAE;GAChC,IAAI,SAAS,KAAA,GACX,MAAM,KAAK,IAAI;EAEnB;EACA,OAAO;CACT;CAEA,MAAM,WAAW,SAA8B;EAC7C,MAAM,QAAQ,SAAS;EACvB,MAAM,QAAkB,CAAC;EACzB,KAAK,MAAM,UAAU,KAAK,cAAc,CAAC,CAAC,OAAO,GAAG;GAClD,IAAI,CAAC,QAAQ,OAAO,OAAO,IAAI,GAC7B;GAEF,MAAM,OAAO,MAAM,IAAI,OAAO,EAAE;GAChC,IAAI,SAAS,KAAA,GACX,MAAM,KAAK,IAAI;EAEnB;EACA,OAAO;CACT;CAEA,MAAM,UAAU,SAA8B,SAAS,CAAC,CAAC,QAAO,SAAQ,QAAQ,aAAa,IAAI,GAAG,IAAI,CAAC;;CAGzG,SAAS,SAA8B,MAAgC,UAAkC;EACvG,MAAM,UAAU,GAAG,SAAsB;GACvC,MAAM,QAAQ,KAAK,GAAG,IAAI;GAC1B,IAAI,MAAM,WAAW,GACnB,MAAM,QAAQ,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC;GAE1C,OAAO;EACT;EACA,MAAM,OAAO,GAAG,SAAoB;GAClC,MAAM,QAAQ,KAAK,GAAG,IAAI;GAC1B,IAAI,MAAM,WAAW,GACnB,MAAM,QAAQ,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,MAAM;GAErD,OAAO,MAAM;EACf;EACA,MAAM,SAAS,GAAG,SAA2B;GAC3C,MAAM,QAAQ,KAAK,GAAG,IAAI;GAC1B,IAAI,MAAM,SAAS,GACjB,MAAM,QAAQ,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,MAAM;GAErD,OAAO,MAAM,MAAM;EACrB;EACA,OAAO;GAAE;GAAK;GAAO;EAAO;CAC9B;CAEA,MAAM,OAAO,UACV,GAAW,UAA4B,CAAC,MAAM,OAAO,GAAG,OAAO,IAC/D,GAAW,UAA4B,CAAC,MAAM,kBAAkB,GAAG,OAAO,CAC7E;CACA,MAAM,QAAQ,UACX,SAAoB,QAAQ,IAAI,IAChC,SAAoB,uBAAuB,cAAc,IAAI,GAChE;CACA,MAAM,OAAO,UACV,UAAqB,OAAO,KAAK,IACjC,UAAqB,YAAY,cAAc,KAAK,GACvD;CAEA,OAAO;EACL,WAAW,KAAK;EAChB,aAAa,KAAK;EAClB,cAAc,KAAK;EACnB,aAAa,GAAG,UAAU,CAAC,MAAM;GAC/B,MAAM,EAAE,YAAY,oBAAoB,GAAG,SAAS;GACpD,OAAO,SAAS,YAAY,OAAO,GAAG,IAAI,GAAG,kBAAkB,GAAG,IAAI,GAAG,SAAS;EACpF;EAEA,YAAY,MAAM;EAClB,cAAc,MAAM;EACpB,eAAe,MAAM;EACrB,cAAc,MAAM,UAAU,CAAC,MAC7B,SACE,YACM,QAAQ,IAAI,GAClB,uBAAuB,cAAc,IAAI,KACzC,QAAQ,aAAa,kBACvB;EAEF,WAAW,KAAK;EAChB,aAAa,KAAK;EAClB,cAAc,KAAK;EACnB,aAAa,OAAO,UAAU,CAAC,MAC7B,SAAS,YAAY,OAAO,KAAK,GAAG,YAAY,cAAc,KAAK,KAAK,QAAQ,aAAa,kBAAkB;EAEjH;EACA,SAAQ,SACN,WAAW,QAAQ,KAAK,CAAC,CAAC,CACvB,IAAI,YAAY,CAAC,CACjB,QAAQ,UAA2B,UAAU,KAAA,CAAS;CAC7D;AACF;;;;;;;;;;;;;;;;;ACnSA,MAAM,0BAAU,IAAI,QAA0B;AAE9C,SAAgB,iBAAiB,MAAc,UAA0B;CACvE,QAAQ,IAAI,MAAM,QAAQ;AAC5B;;AAGA,SAAgB,YAAY,MAA+B;CACzD,KAAK,IAAI,UAAyB,MAAM,YAAY,MAAM,UAAU,QAAQ,QAAQ;EAClF,MAAM,WAAW,QAAQ,IAAI,OAAO;EACpC,IAAI,aAAa,KAAA,GACf,OAAO;CAEX;CACA,OAAO;AACT"}