gesso-testing 0.3.0 → 0.4.1

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,61 @@
1
1
  # gesso-testing
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - Updated dependencies
9
+ - gesso-core@0.4.1
10
+ - gesso-framework@0.4.1
11
+
12
+ ## 0.4.0
13
+
14
+ ### Minor Changes
15
+
16
+ - **`DoubleClick`, dispatched after a second Click on the same node.** The pointer
17
+ controller pairs Clicks on the same node within 500 ms and 4 px — the window the
18
+ editing and selection controllers already use for a word — and dispatches
19
+ `DoubleClick` after the second, as the DOM's `dblclick` follows its `click`. A
20
+ press that became a drag or was cancelled spends the pair.
21
+
22
+ `onDoubleClick` on any element; `fireEvent` gains `doubleClick` and
23
+ `contextMenu`.
24
+
25
+ - **The coordinate path names the device, so a spec can press with a finger.**
26
+ `fireEvent.pointerDown`, `pointerMove` and `pointerUp` take
27
+ `pointer: 'mouse' | 'touch' | 'pen'`, passed to the controller as a
28
+ `UiPointerDevice` with the ids a browser gives each. A mouse when absent, as
29
+ everywhere.
30
+
31
+ Every touch behaviour the engine has — a pan that scrolls, a long press that
32
+ asks for a menu, the finger's slop — was reachable only from a shell until now.
33
+
34
+ ### Patch Changes
35
+
36
+ - **`toHaveText('')` matches a field that is empty.** `textProperty` answers
37
+ `undefined` for empty text, so that `getByText` does not find every empty node
38
+ on the page — and `toHaveText` used it directly, so asserting that a field had
39
+ been emptied failed with "it has none". That was true, and not the answer to the
40
+ question asked of one node.
41
+
42
+ The matcher now reads a node whose value or text is the empty string as showing
43
+ `''`, and the queries keep their rule.
44
+
45
+ Found by gessosheet, whose rules bar spec had to assert an emptied field
46
+ indirectly.
47
+
48
+ - Updated dependencies
49
+ - Updated dependencies
50
+ - Updated dependencies
51
+ - Updated dependencies
52
+ - Updated dependencies
53
+ - Updated dependencies
54
+ - Updated dependencies
55
+ - Updated dependencies
56
+ - gesso-core@0.4.0
57
+ - gesso-framework@0.4.0
58
+
3
59
  ## 0.3.0
4
60
 
5
61
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LayoutBox, LayoutExplanation, UiKeyModifiers, UiManualFrameClock, UiNode, UiRole, UiSemanticState, UiSemanticsMap, UiSemanticsRecord } from "gesso-core";
1
+ import { LayoutBox, LayoutExplanation, UiKeyModifiers, UiManualFrameClock, UiNode, UiPointerKind, UiRole, UiSemanticState, UiSemanticsMap, UiSemanticsRecord } from "gesso-core";
2
2
  import { RecordedCall } from "gesso-core/testing";
3
3
  import { ChannelRegistry, ChannelReplica, ChannelToken, FrameMetrics, FrameworkChild, GessoRuntime, GessoRuntimeOptions, ServedChannel } from "gesso-framework";
4
4
  //#region src/fireEvent.d.ts
@@ -33,24 +33,31 @@ interface FireEvent {
33
33
  * `pointerDown`/`pointerUp` for the coordinate path.
34
34
  */
35
35
  click(node: UiNode, at?: PointAt): void;
36
+ /**
37
+ * Two clicks on a node and the DoubleClick that follows them, in the
38
+ * order a real pointer produces them. Use the coordinate path twice
39
+ * to ask whether two presses *make* a double click; this is for
40
+ * asking what a double click does.
41
+ */
42
+ doubleClick(node: UiNode, at?: PointAt): void;
43
+ /** A request for a node's menu: the right button, or a held finger. */
44
+ contextMenu(node: UiNode, at?: PointAt): void;
36
45
  /** A press on a node without the release, for hold and drag behaviour. */
37
46
  pressDown(node: UiNode, at?: PointAt): void;
38
47
  pressUp(node: UiNode, at?: PointAt): void;
39
48
  /** A press-and-move on a node: what grabbing a divider or a thumb is. */
40
49
  pan(node: UiNode, x: number, y: number): void;
41
- /** The coordinate path, through the hit tester. */
42
- pointerDown(x: number, y: number, options?: {
43
- buttons?: number;
44
- modifiers?: Partial<UiKeyModifiers>;
45
- }): void;
46
- pointerMove(x: number, y: number, options?: {
47
- buttons?: number;
48
- modifiers?: Partial<UiKeyModifiers>;
49
- }): void;
50
- pointerUp(x: number, y: number, options?: {
51
- buttons?: number;
52
- modifiers?: Partial<UiKeyModifiers>;
53
- }): void;
50
+ /**
51
+ * The coordinate path, through the hit tester.
52
+ *
53
+ * `pointer` says what made the contact — a finger, a pen — for the
54
+ * behaviours that are only a finger's: a pan that scrolls, a long
55
+ * press that asks for a menu, a larger slop. A mouse when absent, as
56
+ * everywhere else a device is not named.
57
+ */
58
+ pointerDown(x: number, y: number, options?: PointerOptions): void;
59
+ pointerMove(x: number, y: number, options?: PointerOptions): void;
60
+ pointerUp(x: number, y: number, options?: PointerOptions): void;
54
61
  wheel(options: {
55
62
  x?: number;
56
63
  y?: number;
@@ -75,6 +82,12 @@ interface FireEvent {
75
82
  paste(text: string): void;
76
83
  }
77
84
  declare function createFireEvent(runtime: GessoRuntime): FireEvent;
85
+ /** What `pointerDown`, `pointerMove` and `pointerUp` take. */
86
+ interface PointerOptions {
87
+ readonly buttons?: number;
88
+ readonly modifiers?: Partial<UiKeyModifiers>;
89
+ readonly pointer?: UiPointerKind;
90
+ }
78
91
  //#endregion
79
92
  //#region src/queries.d.ts
80
93
  /** A string matches exactly after whitespace collapsing; a RegExp is tested. */
@@ -329,5 +342,5 @@ interface ServedForTest {
329
342
  */
330
343
  declare function serveForTest(channels: readonly ServedChannel[]): ServedForTest;
331
344
  //#endregion
332
- export { type FireEvent, type PointAt, type Queries, type RenderTestOptions, type Rendered, type RenderedBase, type RoleQueryOptions, type ServedForTest, type TextMatch, createFireEvent, formatTree, nodesUnder, renderTest, renderedFor, serveForTest, textProperty };
345
+ export { type FireEvent, type PointAt, type PointerOptions, type Queries, type RenderTestOptions, type Rendered, type RenderedBase, type RoleQueryOptions, type ServedForTest, type TextMatch, createFireEvent, formatTree, nodesUnder, renderTest, renderedFor, serveForTest, textProperty };
333
346
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as textProperty, i as nodesUnder, n as renderedFor, r as createQueries, t as registerRendered } from "./registry-CyhS3PwD.js";
2
- import { CharacterCountTextMeasurer, UiEventType, UiManualFrameClock, UiPointerEvent, formatExplanation, noKeyModifiers } from "gesso-core";
2
+ import { CharacterCountTextMeasurer, MOUSE_POINTER, 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";
5
5
  //#region src/fireEvent.ts
@@ -15,15 +15,21 @@ function createFireEvent(runtime) {
15
15
  };
16
16
  return {
17
17
  click: (node, at) => dispatch(UiEventType.Click, node, at),
18
+ doubleClick: (node, at) => {
19
+ dispatch(UiEventType.Click, node, at);
20
+ dispatch(UiEventType.Click, node, at);
21
+ dispatch(UiEventType.DoubleClick, node, at);
22
+ },
23
+ contextMenu: (node, at) => dispatch(UiEventType.ContextMenu, node, at),
18
24
  pressDown: (node, at) => dispatch(UiEventType.PointerDown, node, at),
19
25
  pressUp: (node, at) => dispatch(UiEventType.PointerUp, node, at),
20
26
  pan: (node, x, y) => dispatch(UiEventType.PanMove, node, {
21
27
  x,
22
28
  y
23
29
  }),
24
- pointerDown: (x, y, options = {}) => void runtime.input.pointer.pointerDown(x, y, options.buttons ?? 1, modifiersOf(options.modifiers)),
25
- pointerMove: (x, y, options = {}) => void runtime.input.pointer.pointerMove(x, y, options.buttons ?? 0, modifiersOf(options.modifiers)),
26
- pointerUp: (x, y, options = {}) => void runtime.input.pointer.pointerUp(x, y, options.buttons ?? 0, modifiersOf(options.modifiers)),
30
+ pointerDown: (x, y, options = {}) => void runtime.input.pointer.pointerDown(x, y, options.buttons ?? 1, modifiersOf(options.modifiers), deviceOf(options.pointer)),
31
+ pointerMove: (x, y, options = {}) => void runtime.input.pointer.pointerMove(x, y, options.buttons ?? 0, modifiersOf(options.modifiers), deviceOf(options.pointer)),
32
+ pointerUp: (x, y, options = {}) => void runtime.input.pointer.pointerUp(x, y, options.buttons ?? 0, modifiersOf(options.modifiers), deviceOf(options.pointer)),
27
33
  wheel: (options) => void runtime.input.wheel.wheel(options.x ?? 0, options.y ?? 0, options.deltaX ?? 0, options.deltaY ?? 0, modifiersOf(options.modifiers)),
28
34
  keyDown: (key, modifiers) => void runtime.input.keyboard.keyDown(key, modifiersOf(modifiers)),
29
35
  keyUp: (key, modifiers) => void runtime.input.keyboard.keyUp(key, modifiersOf(modifiers)),
@@ -39,6 +45,21 @@ function createFireEvent(runtime) {
39
45
  paste: (text) => void runtime.input.editing.paste(text)
40
46
  };
41
47
  }
48
+ /** One device per kind, with the ids a browser gives them, so a press and its moves agree on who made them. */
49
+ const DEVICES = {
50
+ mouse: MOUSE_POINTER,
51
+ pen: Object.freeze({
52
+ id: 3,
53
+ kind: "pen"
54
+ }),
55
+ touch: Object.freeze({
56
+ id: 2,
57
+ kind: "touch"
58
+ })
59
+ };
60
+ function deviceOf(kind) {
61
+ return DEVICES[kind ?? "mouse"];
62
+ }
42
63
  //#endregion
43
64
  //#region src/debug.ts
44
65
  /** `12,8 64×32`, rounded, because a test reader wants the shape not the sixteenths. */
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, 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":"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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 {\n MOUSE_POINTER,\n noKeyModifiers,\n UiEventType,\n UiPointerEvent,\n type UiKeyModifiers,\n type UiNode,\n type UiPointerDevice,\n type UiPointerKind\n} 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 /**\n * Two clicks on a node and the DoubleClick that follows them, in the\n * order a real pointer produces them. Use the coordinate path twice\n * to ask whether two presses *make* a double click; this is for\n * asking what a double click does.\n */\n doubleClick(node: UiNode, at?: PointAt): void;\n /** A request for a node's menu: the right button, or a held finger. */\n contextMenu(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 /**\n * The coordinate path, through the hit tester.\n *\n * `pointer` says what made the contact — a finger, a pen — for the\n * behaviours that are only a finger's: a pan that scrolls, a long\n * press that asks for a menu, a larger slop. A mouse when absent, as\n * everywhere else a device is not named.\n */\n pointerDown(x: number, y: number, options?: PointerOptions): void;\n pointerMove(x: number, y: number, options?: PointerOptions): void;\n pointerUp(x: number, y: number, options?: PointerOptions): 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 doubleClick: (node, at) => {\n dispatch(UiEventType.Click, node, at);\n dispatch(UiEventType.Click, node, at);\n dispatch(UiEventType.DoubleClick, node, at);\n },\n contextMenu: (node, at) => dispatch(UiEventType.ContextMenu, 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(\n x,\n y,\n options.buttons ?? 1,\n modifiersOf(options.modifiers),\n deviceOf(options.pointer)\n ),\n pointerMove: (x, y, options = {}) =>\n void runtime.input.pointer.pointerMove(\n x,\n y,\n options.buttons ?? 0,\n modifiersOf(options.modifiers),\n deviceOf(options.pointer)\n ),\n pointerUp: (x, y, options = {}) =>\n void runtime.input.pointer.pointerUp(\n x,\n y,\n options.buttons ?? 0,\n modifiersOf(options.modifiers),\n deviceOf(options.pointer)\n ),\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\n/** What `pointerDown`, `pointerMove` and `pointerUp` take. */\nexport interface PointerOptions {\n readonly buttons?: number;\n readonly modifiers?: Partial<UiKeyModifiers>;\n readonly pointer?: UiPointerKind;\n}\n\n/** One device per kind, with the ids a browser gives them, so a press and its moves agree on who made them. */\nconst DEVICES: Readonly<Record<UiPointerKind, UiPointerDevice>> = {\n mouse: MOUSE_POINTER,\n pen: Object.freeze({ id: 3, kind: 'pen' as const }),\n touch: Object.freeze({ id: 2, kind: 'touch' as const })\n};\n\nfunction deviceOf(kind: UiPointerKind | undefined): UiPointerDevice {\n return DEVICES[kind ?? 'mouse'];\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":"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"}
package/dist/matchers.js CHANGED
@@ -70,10 +70,11 @@ const matchers = {
70
70
  },
71
71
  /** The text the node itself draws — not its descendants, and not its label. */
72
72
  toHaveText(received, expected) {
73
- const actual = textProperty(received);
73
+ const own = received.properties.get("value") ?? received.properties.get("text");
74
+ const actual = textProperty(received) ?? (own === "" ? "" : void 0);
74
75
  return {
75
76
  pass: actual === expected,
76
- message: () => actual === expected ? `Expected '${received.id}' not to have the text ${JSON.stringify(expected)}.` : `Expected '${received.id}' to have the text ${JSON.stringify(expected)}, but it has ${actual === void 0 ? "none" : JSON.stringify(actual)}.`
77
+ message: () => actual === expected ? `Expected '${received.id}' not to have the text ${JSON.stringify(expected)}.` : `Expected '${received.id}' to have the text ${JSON.stringify(expected)}, but it has ${actual === void 0 || actual === "" ? "none" : JSON.stringify(actual)}.`
77
78
  };
78
79
  },
79
80
  /**
@@ -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 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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"}
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 // `textProperty` answers undefined for empty text, so that\n // `getByText` does not find every empty node on the page. Here the\n // question is narrower — what does this one node show — and a field\n // or a text holding the empty string shows exactly that: asserting\n // `toHaveText('')` on an emptied field used to fail with \"it has\n // none\", which was true and was not the answer to the question.\n const own = received.properties.get('value') ?? received.properties.get('text');\n const actual = textProperty(received) ?? (own === '' ? '' : undefined);\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 || actual === '' ? '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":";;;;AAuCA,SAAS,cAAc,MAAsB;CAC3C,MAAM,WAAW,YAAY,IAAI;CACjC,IAAI,aAAa,MACf,OAAO;CAET,OAAO,SAAS,YAAY,IAAI;AAClC;AAEA,SAAS,MAAM,MAAgC;CAC7C,OAAO,YAAY,IAAI,CAAC,EAAE,UAAU,IAAI,KAAK;AAC/C;AAEA,SAAS,aAAa,MAAgC;CACpD,OAAO,YAAY,IAAI,CAAC,EAAE,cAAc,IAAI,KAAK;AACnD;AAEA,SAAS,MAAM,OAAuB;CACpC,OAAO,KAAK,MAAM,QAAQ,GAAI,IAAI;AACpC;AAWA,MAAa,WAAW;;;;;;;;;CAStB,UAAU,UAAkB,UAA6C;EACvE,MAAM,MAAM,MAAM,QAAQ;EAC1B,IAAI,QAAQ,MACV,OAAO;GACL,MAAM;GACN,eAAe,6CAA6C,SAAS,GAAG;EAC1E;EAEF,MAAM,QAAS,OAAO,KAAK,QAAQ,CAAC,CAAyB,QAC3D,QAAO,MAAM,IAAI,IAAI,MAAM,MAAM,SAAS,IAAc,CAC1D;EACA,OAAO;GACL,MAAM,MAAM,WAAW;GACvB,eACE,MAAM,WAAW,IACb,aAAa,SAAS,GAAG,oBAAoB,KAAK,UAAU,QAAQ,EAAE,KACtE,aAAa,SAAS,GAAG,YAAY,MAAM,KAAI,QAAO,GAAG,IAAI,GAAG,SAAS,MAAM,CAAC,CAAC,KAAK,IAAI,EAAE,mBAC1E,KAAK,UAAU;IAC/B,GAAG,MAAM,IAAI,CAAC;IACd,GAAG,MAAM,IAAI,CAAC;IACd,OAAO,MAAM,IAAI,KAAK;IACtB,QAAQ,MAAM,IAAI,MAAM;GAC1B,CAAC,EAAE,aAAa,cAAc,QAAQ;EAC9C;CACF;;;;;;;;;;CAWA,iBAAiB,UAAkB,UAA6C;EAC9E,MAAM,MAAM,aAAa,QAAQ;EACjC,IAAI,QAAQ,MACV,OAAO;GACL,MAAM;GACN,eAAe,6CAA6C,SAAS,GAAG;EAC1E;EAEF,MAAM,QAAS,OAAO,KAAK,QAAQ,CAAC,CAAyB,QAC3D,QAAO,MAAM,IAAI,IAAI,MAAM,MAAM,SAAS,IAAc,CAC1D;EACA,OAAO;GACL,MAAM,MAAM,WAAW;GACvB,eACE,MAAM,WAAW,IACb,aAAa,SAAS,GAAG,sBAAsB,KAAK,UAAU,QAAQ,EAAE,KACxE,aAAa,SAAS,GAAG,kBAAkB,MAAM,KAAI,QAAO,GAAG,IAAI,GAAG,SAAS,MAAM,CAAC,CAAC,KAAK,IAAI,EAAE,sBAC7E,KAAK,UAAU;IAClC,GAAG,MAAM,IAAI,CAAC;IACd,GAAG,MAAM,IAAI,CAAC;IACd,OAAO,MAAM,IAAI,KAAK;IACtB,QAAQ,MAAM,IAAI,MAAM;GAC1B,CAAC,EAAE,aAAa,cAAc,QAAQ;EAC9C;CACF;;CAGA,WAAW,UAAkB,UAAiC;EAO5D,MAAM,MAAM,SAAS,WAAW,IAAI,OAAO,KAAK,SAAS,WAAW,IAAI,MAAM;EAC9E,MAAM,SAAS,aAAa,QAAQ,MAAM,QAAQ,KAAK,KAAK,KAAA;EAC5D,OAAO;GACL,MAAM,WAAW;GACjB,eACE,WAAW,WACP,aAAa,SAAS,GAAG,yBAAyB,KAAK,UAAU,QAAQ,EAAE,KAC3E,aAAa,SAAS,GAAG,qBAAqB,KAAK,UAAU,QAAQ,EAAE,eACpE,WAAW,KAAA,KAAa,WAAW,KAAK,SAAS,KAAK,UAAU,MAAM,EAAE;EACnF;CACF;;;;;;;;CASA,gBAAgB,UAAkB,UAA4C;EAC5E,MAAM,WAAW,YAAY,QAAQ;EACrC,IAAI,aAAa,MACf,OAAO;GACL,MAAM;GACN,eAAe,6CAA6C,SAAS,GAAG;EAC1E;EAEF,MAAM,SAAS,SAAS,eAAe,QAAQ;EAC/C,IAAI,WAAW,MACb,OAAO;GACL,MAAM;GACN,eACE,aAAa,SAAS,GAAG,+HACwD,SAAS,MAAM;EACpG;EAEF,MAAM,SAA4B;GAChC,MAAM,OAAO;GACb,MAAM,OAAO;GACb,QAAQ,OAAO,UAAU,CAAC;GAC1B,UAAU,OAAO,aAAa;GAC9B,OAAO,OAAO;EAChB;EACA,MAAM,QAAS,OAAO,KAAK,QAAQ,CAAC,CAAiC,QAAO,QAAO;GACjF,IAAI,QAAQ,UAAU;IACpB,MAAM,OAAO,CAAC,GAAI,SAAS,UAAU,CAAC,CAAE,CAAC,CAAC,KAAK;IAC/C,MAAM,OAAO,CAAC,GAAI,OAAO,UAAU,CAAC,CAAE,CAAC,CAAC,KAAK;IAC7C,OAAO,KAAK,WAAW,KAAK,UAAU,KAAK,MAAM,OAAO,UAAU,UAAU,KAAK,MAAM;GACzF;GACA,OAAO,OAAO,SAAS,SAAS;EAClC,CAAC;EACD,OAAO;GACL,MAAM,MAAM,WAAW;GACvB,eACE,MAAM,WAAW,IACb,aAAa,SAAS,GAAG,0BAA0B,KAAK,UAAU,QAAQ,EAAE,KAC5E,aAAa,SAAS,GAAG,YAAY,MAClC,KAAI,QAAO,GAAG,IAAI,GAAG,KAAK,UAAU,SAAS,IAAI,GAAG,CAAC,CACrD,KAAK,IAAI,EAAE,6BAA6B,KAAK,UAAU,MAAM,EAAE;EAC1E;CACF;;CAGA,YAAY,UAAiC;EAC3C,MAAM,WAAW,YAAY,QAAQ;EACrC,IAAI,aAAa,MACf,OAAO;GACL,MAAM;GACN,eAAe,6CAA6C,SAAS,GAAG;EAC1E;EAEF,MAAM,UAAU,SAAS,QAAQ,MAAM,MAAM;EAC7C,OAAO;GACL,MAAM,YAAY;GAClB,eACE,YAAY,WACR,aAAa,SAAS,GAAG,wBACzB,aAAa,SAAS,GAAG,uBACvB,YAAY,OAAO,iBAAiB,IAAI,QAAQ,GAAG,QACpD;EACT;CACF;AACF;AAEA,OAAO,OAAO,QAAQ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gesso-testing",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.3.0",
53
- "gesso-framework": "^0.3.0"
52
+ "gesso-core": "^0.4.1",
53
+ "gesso-framework": "^0.4.1"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "rxjs": "^7.8.2",