gesso-testing 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ # gesso-testing
2
+
3
+ ## 0.1.0
4
+
5
+ First public release.
6
+
7
+ `renderTest(root)` builds, lays out and describes a tree on a manual clock,
8
+ then answers `getByRole`, `getByLabel` and `getByText` -- with `query`,
9
+ `getAll` and `find` variants -- from the very semantics tree the accessibility
10
+ mirror hands to the platform. There is no second definition of what a control
11
+ is for a test to drift away from.
12
+
13
+ It also carries `fireEvent` (pointer, wheel, keyboard, focus and typing, all
14
+ through the real controllers), `getLayout`, `explainText`, `settle` and a
15
+ `debug()` print of the tree.
16
+
17
+ The root entry imports no test runner, so it works under Vitest, under
18
+ `node:test`, or in a plain script. The matchers -- `toHaveBox`, `toHaveText`,
19
+ `toHaveSemantics`, `toHaveFocus` -- are behind `gesso-testing/matchers`,
20
+ because `expect.extend` is a side effect on a global. A `toHaveBox` that
21
+ misses prints the node's layout explanation beneath it.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Baker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # gesso-testing
2
+
3
+ Mount a component with no browser, query it the way a screen reader would, and read back why a box is the size it is.
4
+
5
+ ```bash
6
+ npm install --save-dev gesso-testing
7
+ ```
8
+
9
+ ```ts
10
+ import { createComponent } from 'gesso-framework';
11
+ import { renderTest } from 'gesso-testing';
12
+ import 'gesso-testing/matchers';
13
+
14
+ it('calls back on a press, and not while disabled', () => {
15
+ const presses: number[] = [];
16
+ const ui = renderTest(createComponent(Button, { label: 'Retry', onClick: () => presses.push(1) }));
17
+
18
+ ui.fireEvent.click(ui.getByRole('button'));
19
+ expect(presses.length).toBe(1);
20
+ expect(ui.getByRole('button')).toHaveSemantics({ name: 'Retry' });
21
+ });
22
+ ```
23
+
24
+ ## Why the queries are what they are
25
+
26
+ `getByRole`, `getByLabel` and `getByText` -- with `query`, `getAll` and `find` variants -- answer from **the very semantics tree the accessibility mirror hands to the platform**. There is no second description of your component to keep in step: if a test can find a control, an assistive technology can too, and if it cannot, that is the bug.
27
+
28
+ `renderTest` builds, lays out and describes a tree on a manual clock over a canvas double. No browser, no `jsdom`, no canvas polyfill.
29
+
30
+ ## When a box is wrong
31
+
32
+ A `toHaveBox` that misses prints `explain`'s answer underneath it, so a failed assertion tells you which rule decided the size rather than only that two numbers differ.
33
+
34
+ ## Entry points
35
+
36
+ - `gesso-testing` -- `renderTest` and its queries
37
+ - `gesso-testing/matchers` -- registers the matchers with Vitest
38
+
39
+ `vitest` is an optional peer: the matchers need it, the renderer does not.
40
+
41
+ ## Documentation
42
+
43
+ [Testing](https://github.com/kevinpbaker/gesso/blob/main/apps/docs/guide/testing.md)
44
+
45
+ MIT (c) Kevin Baker
@@ -0,0 +1,313 @@
1
+ import { LayoutBox, LayoutExplanation, UiKeyModifiers, UiManualFrameClock, UiNode, UiRole, UiSemanticState, UiSemanticsMap, UiSemanticsRecord } from "gesso-core";
2
+ import { RecordedCall } from "gesso-core/testing";
3
+ import { ChannelReplica, ChannelToken, FrameMetrics, FrameworkChild, GessoRuntime, GessoRuntimeOptions, ServedChannel } from "gesso-framework";
4
+ //#region src/fireEvent.d.ts
5
+ interface PointAt {
6
+ x?: number;
7
+ y?: number;
8
+ modifiers?: Partial<UiKeyModifiers>;
9
+ }
10
+ /**
11
+ * The events a test sends, bound to one render.
12
+ *
13
+ * Bound rather than global — `@testing-library`'s `fireEvent` is a free
14
+ * function because the DOM it acts on is a global, and a Gesso runtime
15
+ * is not: two can be mounted in one file, in one worker, at once. So
16
+ * this comes off the render (`ui.fireEvent.click(node)`), and
17
+ * `createFireEvent(runtime)` is there for anyone driving a runtime this
18
+ * library did not mount.
19
+ *
20
+ * Everything here goes through the same controllers a real pointer and
21
+ * a real keyboard go through. That is the whole point: a test that
22
+ * poked a component's handler directly would pass while the component
23
+ * was unreachable by mouse, by keyboard, and by a screen reader.
24
+ */
25
+ interface FireEvent {
26
+ /**
27
+ * A click on a node, with no hit test.
28
+ *
29
+ * Addressed by node rather than by coordinate because that is what a
30
+ * query returns, and because a component's job is to respond to a
31
+ * click on it — whether the pixel at (x, y) lands on it is the hit
32
+ * tester's business and `LayoutEngine`'s specs already cover it. Use
33
+ * `pointerDown`/`pointerUp` for the coordinate path.
34
+ */
35
+ click(node: UiNode, at?: PointAt): void;
36
+ /** A press on a node without the release, for hold and drag behaviour. */
37
+ pressDown(node: UiNode, at?: PointAt): void;
38
+ pressUp(node: UiNode, at?: PointAt): void;
39
+ /** A press-and-move on a node: what grabbing a divider or a thumb is. */
40
+ 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;
54
+ wheel(options: {
55
+ x?: number;
56
+ y?: number;
57
+ deltaX?: number;
58
+ deltaY?: number;
59
+ modifiers?: Partial<UiKeyModifiers>;
60
+ }): void;
61
+ /** A key press at whatever has focus. */
62
+ keyDown(key: string, modifiers?: Partial<UiKeyModifiers>): void;
63
+ keyUp(key: string, modifiers?: Partial<UiKeyModifiers>): void;
64
+ /** Down then up, which is what pressing a key is. */
65
+ press(key: string, modifiers?: Partial<UiKeyModifiers>): void;
66
+ focus(node: UiNode): boolean;
67
+ blur(): void;
68
+ tab(): boolean;
69
+ shiftTab(): boolean;
70
+ /**
71
+ * Types into the focused editable, one insertion, as the editing
72
+ * proxy's `beforeinput` would deliver it.
73
+ */
74
+ type(text: string): void;
75
+ paste(text: string): void;
76
+ }
77
+ declare function createFireEvent(runtime: GessoRuntime): FireEvent;
78
+ //#endregion
79
+ //#region src/queries.d.ts
80
+ /** A string matches exactly after whitespace collapsing; a RegExp is tested. */
81
+ type TextMatch = string | RegExp;
82
+ interface RoleQueryOptions {
83
+ /**
84
+ * The accessible name, as a screen reader would announce it: the
85
+ * node's `label` if it has one, else the text it draws.
86
+ */
87
+ name?: TextMatch;
88
+ /** Every one of these must be on the record. */
89
+ states?: readonly UiSemanticState[];
90
+ /** Match only enabled (`false`) or only disabled (`true`) nodes. */
91
+ disabled?: boolean;
92
+ }
93
+ /**
94
+ * The queries a render answers.
95
+ *
96
+ * Three variants of each, and the difference between them is what
97
+ * happens when the count is not one:
98
+ *
99
+ * - `getBy…` returns the single match and throws otherwise. This is
100
+ * the one to reach for; the throw carries the tree.
101
+ * - `queryBy…` returns `null` for no match and still throws for many,
102
+ * because "there is more than one" is never the question `queryBy`
103
+ * is asked.
104
+ * - `getAllBy…` returns every match, in document order, and throws
105
+ * only when there are none.
106
+ *
107
+ * `findBy…` is `getBy…` with frames: it drives the clock until the
108
+ * match appears, for anything that arrives after the frame it was
109
+ * asked for — a channel patch, a resolved image, a component that
110
+ * awaited something.
111
+ */
112
+ interface Queries {
113
+ getByRole(role: UiRole, options?: RoleQueryOptions): UiNode;
114
+ queryByRole(role: UiRole, options?: RoleQueryOptions): UiNode | null;
115
+ getAllByRole(role: UiRole, options?: RoleQueryOptions): UiNode[];
116
+ findByRole(role: UiRole, options?: RoleQueryOptions & {
117
+ maxFrames?: number;
118
+ }): Promise<UiNode>;
119
+ /** By accessible name, whatever the role. */
120
+ getByLabel(name: TextMatch): UiNode;
121
+ queryByLabel(name: TextMatch): UiNode | null;
122
+ getAllByLabel(name: TextMatch): UiNode[];
123
+ findByLabel(name: TextMatch, options?: {
124
+ maxFrames?: number;
125
+ }): Promise<UiNode>;
126
+ /**
127
+ * By the text a node actually draws.
128
+ *
129
+ * Distinct from `getByLabel`, and the difference matters: the `Text`
130
+ * inside a `Button` is *claimed* as the button's name and so has no
131
+ * semantics record of its own. `getByLabel('Save')` finds the button;
132
+ * `getByText('Save')` finds the text node inside it.
133
+ */
134
+ getByText(text: TextMatch): UiNode;
135
+ queryByText(text: TextMatch): UiNode | null;
136
+ getAllByText(text: TextMatch): UiNode[];
137
+ findByText(text: TextMatch, options?: {
138
+ maxFrames?: number;
139
+ }): Promise<UiNode>;
140
+ /** Every node under the layout root, in document order. */
141
+ allNodes(): UiNode[];
142
+ /** The text every node under `node` draws, in document order. */
143
+ textOf(node?: UiNode): string[];
144
+ }
145
+ declare function nodesUnder(root: UiNode): UiNode[];
146
+ /**
147
+ * The text a node draws, if it draws any.
148
+ *
149
+ * A paragraph given runs holds its text in them rather than in a
150
+ * `text` property, and `getByText` has to find it either way, so this
151
+ * asks the same question the layout engine, paint, selection, find and
152
+ * the semantics mirror all ask.
153
+ */
154
+ declare function textProperty(node: UiNode): string | undefined;
155
+ //#endregion
156
+ //#region src/renderTest.d.ts
157
+ /**
158
+ * The pieces of a mounted tree that are not queries.
159
+ *
160
+ * Split out so the queries can be mixed in without either half having
161
+ * to know the other's shape.
162
+ */
163
+ interface RenderedBase {
164
+ /** The runtime itself, for anything this library does not wrap. */
165
+ readonly runtime: GessoRuntime;
166
+ /** The clock frames are driven from; `frame()` is the usual way in. */
167
+ readonly clock: UiManualFrameClock;
168
+ /** Metrics for every frame that has run, in order. */
169
+ readonly frames: readonly FrameMetrics[];
170
+ /** Every canvas call since the last `clearDraws()`. An escape hatch. */
171
+ readonly draws: readonly RecordedCall[];
172
+ /** The event senders, bound to this runtime. */
173
+ readonly fireEvent: FireEvent;
174
+ /**
175
+ * Runs the pending frame, if there is one.
176
+ *
177
+ * Without a time it advances 16 ms per call, so a test can drive
178
+ * frames without keeping a clock of its own.
179
+ */
180
+ frame(time?: number): void;
181
+ /**
182
+ * Runs frames until nothing is pending, then resolves.
183
+ *
184
+ * Awaits the microtask queue between frames, which is what makes it
185
+ * the right thing after anything asynchronous: a channel patch, an
186
+ * image resolving, a promise a component awaited. Throws rather than
187
+ * spinning forever if the tree never goes quiet.
188
+ */
189
+ settle(options?: {
190
+ maxFrames?: number;
191
+ }): Promise<void>;
192
+ /** The node's border box in layout-root coordinates. */
193
+ getLayout(node: UiNode): LayoutBox;
194
+ /** Why the node has the size it has, as `LayoutEngine.explain` gives it. */
195
+ explain(node: UiNode): LayoutExplanation;
196
+ /** The same explanation as the sentences `formatExplanation` prints. */
197
+ explainText(node: UiNode): string;
198
+ /**
199
+ * The semantics record for a node: what a screen reader would
200
+ * announce for it. Throws, with the tree, when the node has none —
201
+ * which is nearly always the finding rather than an inconvenience.
202
+ */
203
+ getSemantics(node: UiNode): UiSemanticsRecord;
204
+ /** The same, returning null instead of throwing. */
205
+ querySemantics(node: UiNode): UiSemanticsRecord | null;
206
+ /** The whole semantics tree, as the accessibility mirror has it. */
207
+ semanticsTree(): UiSemanticsMap;
208
+ /** The tree as text: type, role, name, states and box, one node per line. */
209
+ debug(node?: UiNode): string;
210
+ /** Empties the recorded canvas calls. */
211
+ clearDraws(): void;
212
+ /** Stops the runtime and releases what it holds. */
213
+ unmount(): void;
214
+ }
215
+ type Rendered = RenderedBase & Queries;
216
+ interface RenderTestOptions extends Omit<Partial<GessoRuntimeOptions>, 'root' | 'canvas' | 'clock'> {
217
+ /**
218
+ * Whether to run the first frame before returning. Default true.
219
+ *
220
+ * Turn it off to watch the first frame happen — a listener attached
221
+ * in `onCreate` sees it either way, but a spec asserting on what the
222
+ * first frame *did* needs to be holding the result when it runs.
223
+ */
224
+ autoFrame?: boolean;
225
+ /** Runs after the runtime is built and before it starts. */
226
+ onCreate?: (runtime: GessoRuntime) => void;
227
+ }
228
+ /**
229
+ * Mounts a component or element tree with no browser and no DOM, and
230
+ * hands back the queries, the events and the layout answers.
231
+ *
232
+ * ```ts
233
+ * const ui = renderTest(createComponent(Checkbox, { label: 'Wrap lines' }));
234
+ * ui.fireEvent.click(ui.getByRole('checkbox'));
235
+ * ui.frame();
236
+ * expect(ui.getSemantics(ui.getByRole('checkbox')).states).toEqual(['checked']);
237
+ * ```
238
+ *
239
+ * Three things are decided here rather than by the caller, because
240
+ * getting any of them wrong makes a test that passes for the wrong
241
+ * reason:
242
+ *
243
+ * - **The clock is manual.** Frames happen when the test says so, so
244
+ * an assertion never races a scheduler.
245
+ * - **Text is measured by `CharacterCountTextMeasurer`**, not by the
246
+ * canvas double. A double's `measureText` answers the same width
247
+ * whatever the font size, so a heading and its caption would come
248
+ * out the same size; the deterministic measurer is proportional to
249
+ * the font size and identical on every machine.
250
+ * - **A first frame has run** by the time this returns, so the graph
251
+ * is built, laid out and described before the first query.
252
+ */
253
+ declare function renderTest(root: FrameworkChild, options?: RenderTestOptions): Rendered;
254
+ //#endregion
255
+ //#region src/debug.d.ts
256
+ /**
257
+ * The tree as text, one node per line.
258
+ *
259
+ * Printed with what a query would have matched on, in the order a
260
+ * query would have considered it: the node's type and id, then the
261
+ * role and accessible name its semantics record carries, then its
262
+ * states, then the text it draws, then its box. A missed query prints
263
+ * this, and the answer to "why did `getByRole('switch')` not find it"
264
+ * is usually on the line for the node that turned out to be a
265
+ * `checkbox`.
266
+ *
267
+ * Nodes with no semantics record are printed too, dimmer in content if
268
+ * not in colour: a `Row` that exists only for layout is exactly the
269
+ * thing a reader needs to see is *not* in the tree an assistive
270
+ * technology reads.
271
+ */
272
+ declare function formatTree(runtime: GessoRuntime, root: UiNode): string;
273
+ //#endregion
274
+ //#region src/registry.d.ts
275
+ /** The render `node` belongs to, or null if it was not mounted by one. */
276
+ declare function renderedFor(node: UiNode): Rendered | null;
277
+ //#endregion
278
+ //#region src/channels.d.ts
279
+ /** What `serveForTest` hands back: the replicas a screen would bind to, and a way to wait for patches. */
280
+ interface ServedForTest {
281
+ /** The replica of one served channel, as `ctx.channel(token)` would return it. */
282
+ get<V extends object, C extends object>(token: ChannelToken<V, C>): ChannelReplica<V, C>;
283
+ /**
284
+ * Waits for the patch stream to deliver. With a condition, until it
285
+ * holds; without one, until two turns of the event loop have passed
286
+ * with the ports drained, which is enough for a command's effect to
287
+ * come back as a patch.
288
+ */
289
+ settle(until?: () => boolean, timeoutMs?: number): Promise<void>;
290
+ /** Every error a served channel reported, in order. */
291
+ readonly errors: readonly string[];
292
+ dispose(): void;
293
+ }
294
+ /**
295
+ * Serves channels the way an application worker does, over a real patch
296
+ * stream, with no worker: the same `serveChannels` data goes in, and the
297
+ * replicas a screen would read come out.
298
+ *
299
+ * This is the spec for a worker's *wiring*, which the domain classes'
300
+ * own specs do not cover: that a command reaches its handler, that its
301
+ * effect comes back as a patch to the right key, that a view key is
302
+ * plain data. The domain classes stay as testable as they were; this
303
+ * tests the lines between them and the barrier.
304
+ *
305
+ * const served = serveForTest(transitionsChannels(catalogue, queue));
306
+ * const view = served.get(Queue);
307
+ * view.send.play({ playlistId: '1' });
308
+ * await served.settle(() => view.view.current.value !== null);
309
+ */
310
+ declare function serveForTest(channels: readonly ServedChannel[]): ServedForTest;
311
+ //#endregion
312
+ 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 };
313
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,241 @@
1
+ import { a as textProperty, i as nodesUnder, n as renderedFor, r as createQueries, t as registerRendered } from "./registry-BlqGkMSO.js";
2
+ import { CharacterCountTextMeasurer, UiEventType, UiManualFrameClock, UiPointerEvent, formatExplanation, noKeyModifiers } from "gesso-core";
3
+ import { FakeCanvasHost, RecordingCanvasContext } from "gesso-core/testing";
4
+ import { GessoRuntime, createChannelRegistry } from "gesso-framework";
5
+ //#region src/fireEvent.ts
6
+ function modifiersOf(partial) {
7
+ return {
8
+ ...noKeyModifiers(),
9
+ ...partial
10
+ };
11
+ }
12
+ function createFireEvent(runtime) {
13
+ const dispatch = (type, node, at = {}) => {
14
+ runtime.input.dispatcher.dispatch(new UiPointerEvent(type, at.x ?? 0, at.y ?? 0, 1, modifiersOf(at.modifiers)), node);
15
+ };
16
+ return {
17
+ click: (node, at) => dispatch(UiEventType.Click, node, at),
18
+ pressDown: (node, at) => dispatch(UiEventType.PointerDown, node, at),
19
+ pressUp: (node, at) => dispatch(UiEventType.PointerUp, node, at),
20
+ pan: (node, x, y) => dispatch(UiEventType.PanMove, node, {
21
+ x,
22
+ y
23
+ }),
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)),
27
+ wheel: (options) => void runtime.input.wheel.wheel(options.x ?? 0, options.y ?? 0, options.deltaX ?? 0, options.deltaY ?? 0, modifiersOf(options.modifiers)),
28
+ keyDown: (key, modifiers) => void runtime.input.keyboard.keyDown(key, modifiersOf(modifiers)),
29
+ keyUp: (key, modifiers) => void runtime.input.keyboard.keyUp(key, modifiersOf(modifiers)),
30
+ press: (key, modifiers) => {
31
+ runtime.input.keyboard.keyDown(key, modifiersOf(modifiers));
32
+ runtime.input.keyboard.keyUp(key, modifiersOf(modifiers));
33
+ },
34
+ focus: (node) => runtime.input.focus.focus(node),
35
+ blur: () => runtime.input.focus.blur(),
36
+ tab: () => runtime.input.focus.focusNext(),
37
+ shiftTab: () => runtime.input.focus.focusPrevious(),
38
+ type: (text) => void runtime.input.editing.insertText(text),
39
+ paste: (text) => void runtime.input.editing.paste(text)
40
+ };
41
+ }
42
+ //#endregion
43
+ //#region src/debug.ts
44
+ /** `12,8 64×32`, rounded, because a test reader wants the shape not the sixteenths. */
45
+ function formatBox(box) {
46
+ const round = (value) => Math.round(value * 100) / 100;
47
+ return `${round(box.x)},${round(box.y)} ${round(box.width)}×${round(box.height)}`;
48
+ }
49
+ /**
50
+ * The tree as text, one node per line.
51
+ *
52
+ * Printed with what a query would have matched on, in the order a
53
+ * query would have considered it: the node's type and id, then the
54
+ * role and accessible name its semantics record carries, then its
55
+ * states, then the text it draws, then its box. A missed query prints
56
+ * this, and the answer to "why did `getByRole('switch')` not find it"
57
+ * is usually on the line for the node that turned out to be a
58
+ * `checkbox`.
59
+ *
60
+ * Nodes with no semantics record are printed too, dimmer in content if
61
+ * not in colour: a `Row` that exists only for layout is exactly the
62
+ * thing a reader needs to see is *not* in the tree an assistive
63
+ * technology reads.
64
+ */
65
+ function formatTree(runtime, root) {
66
+ const semantics = runtime.semanticsTree();
67
+ const lines = [];
68
+ const visit = (node, depth) => {
69
+ const parts = [`${node.type}#${node.id}`];
70
+ const record = semantics.get(node.id);
71
+ if (record !== void 0) {
72
+ if (record.role !== void 0) parts.push(`role=${record.role}`);
73
+ if (record.label !== void 0) parts.push(`name=${JSON.stringify(record.label)}`);
74
+ if (record.states !== void 0 && record.states.length > 0) parts.push(`states=[${record.states.join(",")}]`);
75
+ if (record.disabled === true) parts.push("disabled");
76
+ if (record.valueNow !== void 0) parts.push(`value=${record.valueNow}`);
77
+ }
78
+ const text = node.properties.get("text");
79
+ if (typeof text === "string" && text.length > 0 && record?.label !== text) parts.push(`text=${JSON.stringify(text)}`);
80
+ if (node.properties.get("visible") === false) parts.push("hidden");
81
+ let box;
82
+ try {
83
+ box = formatBox(runtime.debugLayoutBox(node));
84
+ } catch {
85
+ box = "not laid out";
86
+ }
87
+ parts.push(`[${box}]`);
88
+ lines.push(`${" ".repeat(depth)}${parts.join(" ")}`);
89
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) visit(child, depth + 1);
90
+ };
91
+ visit(root, 0);
92
+ return lines.join("\n");
93
+ }
94
+ //#endregion
95
+ //#region src/renderTest.ts
96
+ const DEFAULT_WIDTH = 800;
97
+ const DEFAULT_HEIGHT = 600;
98
+ const DEFAULT_MAX_FRAMES = 100;
99
+ /**
100
+ * Mounts a component or element tree with no browser and no DOM, and
101
+ * hands back the queries, the events and the layout answers.
102
+ *
103
+ * ```ts
104
+ * const ui = renderTest(createComponent(Checkbox, { label: 'Wrap lines' }));
105
+ * ui.fireEvent.click(ui.getByRole('checkbox'));
106
+ * ui.frame();
107
+ * expect(ui.getSemantics(ui.getByRole('checkbox')).states).toEqual(['checked']);
108
+ * ```
109
+ *
110
+ * Three things are decided here rather than by the caller, because
111
+ * getting any of them wrong makes a test that passes for the wrong
112
+ * reason:
113
+ *
114
+ * - **The clock is manual.** Frames happen when the test says so, so
115
+ * an assertion never races a scheduler.
116
+ * - **Text is measured by `CharacterCountTextMeasurer`**, not by the
117
+ * canvas double. A double's `measureText` answers the same width
118
+ * whatever the font size, so a heading and its caption would come
119
+ * out the same size; the deterministic measurer is proportional to
120
+ * the font size and identical on every machine.
121
+ * - **A first frame has run** by the time this returns, so the graph
122
+ * is built, laid out and described before the first query.
123
+ */
124
+ function renderTest(root, options = {}) {
125
+ const { autoFrame = true, onCreate, ...runtimeOptions } = options;
126
+ const context = new RecordingCanvasContext();
127
+ const canvas = new FakeCanvasHost(context);
128
+ const width = runtimeOptions.width ?? DEFAULT_WIDTH;
129
+ const height = runtimeOptions.height ?? DEFAULT_HEIGHT;
130
+ canvas.width = width;
131
+ canvas.height = height;
132
+ const measurer = runtimeOptions.textMeasurer ?? new CharacterCountTextMeasurer();
133
+ const frames = [];
134
+ let clock;
135
+ const runtime = new GessoRuntime({
136
+ ...runtimeOptions,
137
+ width,
138
+ height,
139
+ root,
140
+ canvas,
141
+ textMeasurer: measurer,
142
+ clock: (callback) => clock = new UiManualFrameClock(callback)
143
+ });
144
+ runtime.onFrame((metrics) => frames.push(metrics));
145
+ onCreate?.(runtime);
146
+ runtime.start();
147
+ let now = 0;
148
+ const frame = (time) => {
149
+ now = time ?? now + 16;
150
+ if (clock.isPending) clock.tick(now);
151
+ };
152
+ const settle = async (settleOptions = {}) => {
153
+ const max = settleOptions.maxFrames ?? DEFAULT_MAX_FRAMES;
154
+ for (let index = 0; index <= max; index++) {
155
+ await new Promise((resolve) => setTimeout(resolve, 0));
156
+ if (!clock.isPending) return;
157
+ frame();
158
+ }
159
+ throw new Error(`settle() ran ${max} frames and the tree was still asking for another. Something is scheduling a frame every frame; drive it with frame() instead.`);
160
+ };
161
+ const base = {
162
+ runtime,
163
+ get clock() {
164
+ return clock;
165
+ },
166
+ frames,
167
+ get draws() {
168
+ return context.calls;
169
+ },
170
+ fireEvent: createFireEvent(runtime),
171
+ frame,
172
+ settle,
173
+ getLayout: (node) => runtime.debugLayoutBox(node),
174
+ explain: (node) => runtime.explain(node),
175
+ explainText: (node) => formatExplanation(runtime.explain(node)),
176
+ getSemantics: (node) => {
177
+ const record = runtime.semanticsTree().get(node.id);
178
+ if (record === void 0) throw new Error(`'${node.id}' is not in the semantics tree: nothing gives it a role or a label, so no assistive technology can see it.\n\n${formatTree(runtime, runtime.layoutRoot())}`);
179
+ return record;
180
+ },
181
+ querySemantics: (node) => runtime.semanticsTree().get(node.id) ?? null,
182
+ semanticsTree: () => runtime.semanticsTree(),
183
+ debug: (node) => formatTree(runtime, node ?? runtime.layoutRoot()),
184
+ clearDraws: () => {
185
+ context.calls.length = 0;
186
+ },
187
+ unmount: () => runtime.dispose()
188
+ };
189
+ const rendered = {
190
+ ...base,
191
+ ...createQueries(base)
192
+ };
193
+ registerRendered(runtime.layoutRoot(), rendered);
194
+ if (autoFrame) frame(0);
195
+ return rendered;
196
+ }
197
+ //#endregion
198
+ //#region src/channels.ts
199
+ /**
200
+ * Serves channels the way an application worker does, over a real patch
201
+ * stream, with no worker: the same `serveChannels` data goes in, and the
202
+ * replicas a screen would read come out.
203
+ *
204
+ * This is the spec for a worker's *wiring*, which the domain classes'
205
+ * own specs do not cover: that a command reaches its handler, that its
206
+ * effect comes back as a patch to the right key, that a view key is
207
+ * plain data. The domain classes stay as testable as they were; this
208
+ * tests the lines between them and the barrier.
209
+ *
210
+ * const served = serveForTest(transitionsChannels(catalogue, queue));
211
+ * const view = served.get(Queue);
212
+ * view.send.play({ playlistId: '1' });
213
+ * await served.settle(() => view.view.current.value !== null);
214
+ */
215
+ function serveForTest(channels) {
216
+ const errors = [];
217
+ const handle = createChannelRegistry(channels.map((served) => ({
218
+ token: served.token,
219
+ source: served.source
220
+ })), (_name, message) => errors.push(message));
221
+ return {
222
+ errors,
223
+ get: (token) => handle.registry.get(token),
224
+ async settle(until, timeoutMs = 2e3) {
225
+ const deadline = Date.now() + timeoutMs;
226
+ if (until === void 0) {
227
+ for (let turn = 0; turn < 2; turn++) await new Promise((resolve) => setTimeout(resolve, 0));
228
+ return;
229
+ }
230
+ while (!until()) {
231
+ if (Date.now() > deadline) throw new Error(`serveForTest: timed out after ${timeoutMs} ms waiting for the condition to hold.`);
232
+ await new Promise((resolve) => setTimeout(resolve, 5));
233
+ }
234
+ },
235
+ dispose: () => handle.dispose()
236
+ };
237
+ }
238
+ //#endregion
239
+ export { createFireEvent, formatTree, nodesUnder, renderTest, renderedFor, serveForTest, textProperty };
240
+
241
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,80 @@
1
+ import { LayoutBox, UiNode, UiRole, UiSemanticState } from "gesso-core";
2
+ //#region src/matchers.d.ts
3
+ /**
4
+ * The matchers, and the reason the package has any.
5
+ *
6
+ * `expect(ui.getLayout(node)).toEqual({ x: 8, y: 8, width: 64, height: 32 })`
7
+ * already works, so a matcher that only compared numbers would be
8
+ * decoration. What these add is the failure message: a `toHaveBox` that
9
+ * misses prints the node's layout explanation — the constraints it was
10
+ * given, the rule that fixed each axis, and the ancestor it is laid out
11
+ * from — which is L8's `engine.explain` arriving exactly where someone
12
+ * is looking at a wrong number and asking why.
13
+ *
14
+ * That is the F7 exit criterion in one place: *fix a layout bug using
15
+ * only the inspector and the testing library*. The inspector answers it
16
+ * on a canvas; this answers it in a terminal.
17
+ *
18
+ * Importing this module registers them:
19
+ *
20
+ * ```ts
21
+ * import 'gesso-testing/matchers';
22
+ * ```
23
+ *
24
+ * A separate entry rather than the root barrel, because `expect.extend`
25
+ * is a side effect on a global and importing `renderTest` should not
26
+ * perform one.
27
+ */
28
+ interface MatcherResult {
29
+ pass: boolean;
30
+ message: () => string;
31
+ }
32
+ interface ExpectedSemantics {
33
+ role?: UiRole;
34
+ /** The accessible name. */
35
+ name?: string;
36
+ states?: readonly UiSemanticState[];
37
+ disabled?: boolean;
38
+ value?: number;
39
+ }
40
+ declare const matchers: {
41
+ /**
42
+ * The node's border box, in layout-root coordinates.
43
+ *
44
+ * Every field is optional, so `toHaveBox({ width: 64 })` asserts the
45
+ * width and says nothing about the rest — which is what a layout test
46
+ * usually means, and stops a test breaking because a sibling above it
47
+ * grew by a pixel.
48
+ */
49
+ toHaveBox(received: UiNode, expected: Partial<LayoutBox>): MatcherResult;
50
+ /** The text the node itself draws — not its descendants, and not its label. */
51
+ toHaveText(received: UiNode, expected: string): MatcherResult;
52
+ /**
53
+ * What an assistive technology would announce for this node.
54
+ *
55
+ * Compares only the fields given, for the same reason `toHaveBox`
56
+ * does: a component that gains a `posInSet` should not break every
57
+ * test that ever asserted its role.
58
+ */
59
+ toHaveSemantics(received: UiNode, expected: ExpectedSemantics): MatcherResult;
60
+ /** Whether the runtime's focus manager currently has this node. */
61
+ toHaveFocus(received: UiNode): MatcherResult;
62
+ };
63
+ interface GessoMatchers<R = unknown> {
64
+ toHaveBox: (expected: Partial<LayoutBox>) => R;
65
+ toHaveText: (expected: string) => R;
66
+ toHaveSemantics: (expected: ExpectedSemantics) => R;
67
+ toHaveFocus: () => R;
68
+ }
69
+ /**
70
+ * `T` here is vitest's own default for the interface it is merging
71
+ * into, and it has to be spelled the same way or the merge is an
72
+ * error: `All declarations of 'Matchers' must have identical type
73
+ * parameters.`
74
+ */
75
+ declare module 'vitest' {
76
+ interface Matchers<T = any> extends GessoMatchers<T> {}
77
+ }
78
+ //#endregion
79
+ export { ExpectedSemantics, matchers };
80
+ //# sourceMappingURL=matchers.d.ts.map
@@ -0,0 +1,106 @@
1
+ import { a as textProperty, n as renderedFor } from "./registry-BlqGkMSO.js";
2
+ import { expect } from "vitest";
3
+ //#region src/matchers.ts
4
+ /** The explanation for a node, or a note saying why there is none. */
5
+ function explanationOf(node) {
6
+ const rendered = renderedFor(node);
7
+ if (rendered === null) return "This node was not mounted by renderTest(), so there is no layout engine to ask why.";
8
+ return rendered.explainText(node);
9
+ }
10
+ function boxOf(node) {
11
+ return renderedFor(node)?.getLayout(node) ?? null;
12
+ }
13
+ function round(value) {
14
+ return Math.round(value * 1e3) / 1e3;
15
+ }
16
+ const matchers = {
17
+ /**
18
+ * The node's border box, in layout-root coordinates.
19
+ *
20
+ * Every field is optional, so `toHaveBox({ width: 64 })` asserts the
21
+ * width and says nothing about the rest — which is what a layout test
22
+ * usually means, and stops a test breaking because a sibling above it
23
+ * grew by a pixel.
24
+ */
25
+ toHaveBox(received, expected) {
26
+ const box = boxOf(received);
27
+ if (box === null) return {
28
+ pass: false,
29
+ message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`
30
+ };
31
+ const wrong = Object.keys(expected).filter((key) => round(box[key]) !== round(expected[key]));
32
+ return {
33
+ pass: wrong.length === 0,
34
+ message: () => wrong.length === 0 ? `Expected '${received.id}' not to have box ${JSON.stringify(expected)}.` : `Expected '${received.id}' to have ${wrong.map((key) => `${key} ${expected[key]}`).join(", ")}, but its box is ${JSON.stringify({
35
+ x: round(box.x),
36
+ y: round(box.y),
37
+ width: round(box.width),
38
+ height: round(box.height)
39
+ })}.\n\nWhy:\n${explanationOf(received)}`
40
+ };
41
+ },
42
+ /** The text the node itself draws — not its descendants, and not its label. */
43
+ toHaveText(received, expected) {
44
+ const actual = textProperty(received);
45
+ return {
46
+ pass: actual === expected,
47
+ 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)}.`
48
+ };
49
+ },
50
+ /**
51
+ * What an assistive technology would announce for this node.
52
+ *
53
+ * Compares only the fields given, for the same reason `toHaveBox`
54
+ * does: a component that gains a `posInSet` should not break every
55
+ * test that ever asserted its role.
56
+ */
57
+ toHaveSemantics(received, expected) {
58
+ const rendered = renderedFor(received);
59
+ if (rendered === null) return {
60
+ pass: false,
61
+ message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`
62
+ };
63
+ const record = rendered.querySemantics(received);
64
+ if (record === null) return {
65
+ pass: false,
66
+ message: () => `Expected '${received.id}' to be in the semantics tree, but it is not — nothing gives it a role or a label, so no assistive technology can see it.\n\n${rendered.debug()}`
67
+ };
68
+ const actual = {
69
+ role: record.role,
70
+ name: record.label,
71
+ states: record.states ?? [],
72
+ disabled: record.disabled === true,
73
+ value: record.valueNow
74
+ };
75
+ const wrong = Object.keys(expected).filter((key) => {
76
+ if (key === "states") {
77
+ const want = [...expected.states ?? []].sort();
78
+ const have = [...actual.states ?? []].sort();
79
+ return want.length !== have.length || want.some((state, index) => state !== have[index]);
80
+ }
81
+ return actual[key] !== expected[key];
82
+ });
83
+ return {
84
+ pass: wrong.length === 0,
85
+ message: () => wrong.length === 0 ? `Expected '${received.id}' not to have semantics ${JSON.stringify(expected)}.` : `Expected '${received.id}' to have ${wrong.map((key) => `${key} ${JSON.stringify(expected[key])}`).join(", ")}, but a screen reader sees ${JSON.stringify(actual)}.`
86
+ };
87
+ },
88
+ /** Whether the runtime's focus manager currently has this node. */
89
+ toHaveFocus(received) {
90
+ const rendered = renderedFor(received);
91
+ if (rendered === null) return {
92
+ pass: false,
93
+ message: () => `Expected a node mounted by renderTest(); '${received.id}' was not.`
94
+ };
95
+ const focused = rendered.runtime.input.focus.focusedNode;
96
+ return {
97
+ pass: focused === received,
98
+ message: () => focused === received ? `Expected '${received.id}' not to have focus.` : `Expected '${received.id}' to have focus, but ${focused === null ? "nothing does" : `'${focused.id}' does`}.`
99
+ };
100
+ }
101
+ };
102
+ expect.extend(matchers);
103
+ //#endregion
104
+ export { matchers };
105
+
106
+ //# sourceMappingURL=matchers.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,194 @@
1
+ import { textContentOf } from "gesso-core";
2
+ //#region src/queries.ts
3
+ /** Collapses runs of whitespace and trims, the way a reader would read it. */
4
+ function normalize(value) {
5
+ return value.replace(/\s+/g, " ").trim();
6
+ }
7
+ function matches(actual, expected) {
8
+ if (actual === void 0) return false;
9
+ const text = normalize(actual);
10
+ return typeof expected === "string" ? text === normalize(expected) : expected.test(text);
11
+ }
12
+ function describeMatch(expected) {
13
+ return typeof expected === "string" ? JSON.stringify(expected) : String(expected);
14
+ }
15
+ function nodesUnder(root) {
16
+ const out = [];
17
+ const visit = (node) => {
18
+ out.push(node);
19
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) visit(child);
20
+ };
21
+ visit(root);
22
+ return out;
23
+ }
24
+ /**
25
+ * The text a node draws, if it draws any.
26
+ *
27
+ * A paragraph given runs holds its text in them rather than in a
28
+ * `text` property, and `getByText` has to find it either way, so this
29
+ * asks the same question the layout engine, paint, selection, find and
30
+ * the semantics mirror all ask.
31
+ */
32
+ function textProperty(node) {
33
+ const text = textContentOf(node);
34
+ return text.length > 0 ? text : void 0;
35
+ }
36
+ function recordMatches(record, role, options) {
37
+ if (record.role !== role) return false;
38
+ if (options.name !== void 0 && !matches(record.label, options.name)) return false;
39
+ if (options.disabled !== void 0 && record.disabled === true !== options.disabled) return false;
40
+ if (options.states !== void 0) {
41
+ const states = record.states ?? [];
42
+ if (!options.states.every((state) => states.includes(state))) return false;
43
+ }
44
+ return true;
45
+ }
46
+ function describeRoleQuery(role, options) {
47
+ const parts = [`role ${JSON.stringify(role)}`];
48
+ if (options.name !== void 0) parts.push(`name ${describeMatch(options.name)}`);
49
+ if (options.states !== void 0) parts.push(`states [${options.states.join(", ")}]`);
50
+ if (options.disabled !== void 0) parts.push(options.disabled ? "disabled" : "enabled");
51
+ return parts.join(", ");
52
+ }
53
+ /**
54
+ * What is actually there, so a failed query does not send the reader
55
+ * to a debugger.
56
+ *
57
+ * A missed query is nearly always a name that differs by a word or a
58
+ * role the component does not emit, and both are answered by printing
59
+ * the tree the assistive technology can see next to the tree that was
60
+ * drawn.
61
+ */
62
+ function failure(base, wanted, found) {
63
+ const roles = [...base.semanticsTree().values()].map((record) => ` ${record.role ?? "(no role)"}${record.label === void 0 ? "" : ` · ${JSON.stringify(record.label)}`}`).join("\n");
64
+ const count = found === 0 ? "Nothing matches" : `${found} nodes match`;
65
+ return /* @__PURE__ */ new Error(`${count} ${wanted}.\n\nThe semantics tree has:\n${roles.length > 0 ? roles : " (nothing)"}\n\nThe node tree is:\n${base.debug()}`);
66
+ }
67
+ /**
68
+ * Runs frames until the query answers, then answers.
69
+ *
70
+ * The cap is frames rather than milliseconds because the clock here is
71
+ * manual: there is no wall clock to time out against, and "a hundred
72
+ * frames and it still is not there" is both reproducible and the more
73
+ * useful thing to report.
74
+ */
75
+ async function findWith(base, query, wanted, maxFrames) {
76
+ for (let index = 0; index <= maxFrames; index++) {
77
+ const found = query();
78
+ if (found.length === 1) return found[0];
79
+ if (found.length > 1) throw failure(base, wanted, found.length);
80
+ await new Promise((resolve) => setTimeout(resolve, 0));
81
+ base.frame();
82
+ }
83
+ throw failure(base, `${wanted} after ${maxFrames} frames`, 0);
84
+ }
85
+ const DEFAULT_MAX_FRAMES = 100;
86
+ function createQueries(base) {
87
+ const root = () => base.runtime.layoutRoot();
88
+ const allNodes = () => nodesUnder(root());
89
+ /**
90
+ * Every node by id, so a semantics record can be turned back into the
91
+ * node it describes. Records are keyed by node id and the tree is
92
+ * built by a document-order walk, so iterating the semantics map and
93
+ * looking each record up here yields matches in tree order.
94
+ */
95
+ const nodeById = () => new Map(allNodes().map((node) => [node.id, node]));
96
+ const byRole = (role, options) => {
97
+ const nodes = nodeById();
98
+ const found = [];
99
+ for (const record of base.semanticsTree().values()) {
100
+ if (!recordMatches(record, role, options)) continue;
101
+ const node = nodes.get(record.id);
102
+ if (node !== void 0) found.push(node);
103
+ }
104
+ return found;
105
+ };
106
+ const byLabel = (name) => {
107
+ const nodes = nodeById();
108
+ const found = [];
109
+ for (const record of base.semanticsTree().values()) {
110
+ if (!matches(record.label, name)) continue;
111
+ const node = nodes.get(record.id);
112
+ if (node !== void 0) found.push(node);
113
+ }
114
+ return found;
115
+ };
116
+ const byText = (text) => allNodes().filter((node) => matches(textProperty(node), text));
117
+ /** The three variants, built once from one finder. */
118
+ function variants(find, describe) {
119
+ const getAll = (...args) => {
120
+ const found = find(...args);
121
+ if (found.length === 0) throw failure(base, describe(...args), 0);
122
+ return found;
123
+ };
124
+ const get = (...args) => {
125
+ const found = find(...args);
126
+ if (found.length !== 1) throw failure(base, describe(...args), found.length);
127
+ return found[0];
128
+ };
129
+ const query = (...args) => {
130
+ const found = find(...args);
131
+ if (found.length > 1) throw failure(base, describe(...args), found.length);
132
+ return found[0] ?? null;
133
+ };
134
+ return {
135
+ get,
136
+ query,
137
+ getAll
138
+ };
139
+ }
140
+ const role = variants((r, options = {}) => byRole(r, options), (r, options = {}) => describeRoleQuery(r, options));
141
+ const label = variants((name) => byLabel(name), (name) => `the accessible name ${describeMatch(name)}`);
142
+ const text = variants((value) => byText(value), (value) => `the text ${describeMatch(value)}`);
143
+ return {
144
+ getByRole: role.get,
145
+ queryByRole: role.query,
146
+ getAllByRole: role.getAll,
147
+ findByRole: (r, options = {}) => {
148
+ const { maxFrames = DEFAULT_MAX_FRAMES, ...rest } = options;
149
+ return findWith(base, () => byRole(r, rest), describeRoleQuery(r, rest), maxFrames);
150
+ },
151
+ getByLabel: label.get,
152
+ queryByLabel: label.query,
153
+ getAllByLabel: label.getAll,
154
+ findByLabel: (name, options = {}) => findWith(base, () => byLabel(name), `the accessible name ${describeMatch(name)}`, options.maxFrames ?? DEFAULT_MAX_FRAMES),
155
+ getByText: text.get,
156
+ queryByText: text.query,
157
+ getAllByText: text.getAll,
158
+ findByText: (value, options = {}) => findWith(base, () => byText(value), `the text ${describeMatch(value)}`, options.maxFrames ?? DEFAULT_MAX_FRAMES),
159
+ allNodes,
160
+ textOf: (node) => nodesUnder(node ?? root()).map(textProperty).filter((value) => value !== void 0)
161
+ };
162
+ }
163
+ //#endregion
164
+ //#region src/registry.ts
165
+ /**
166
+ * Which render a node came from.
167
+ *
168
+ * The matchers need it and cannot be handed it: `expect(node).toHaveBox(…)`
169
+ * gives them a node and nothing else, and a node has no idea which
170
+ * runtime laid it out — the graph is deliberately free of back
171
+ * references to anything above it. So `renderTest` registers its layout
172
+ * root here and `renderedFor` walks up from any node to find it.
173
+ *
174
+ * A `WeakMap` keyed by the root, rather than a module-level "current
175
+ * render": two trees can be mounted at once (a spec comparing them,
176
+ * or a suite running in parallel), and a global would make the second
177
+ * one silently answer for the first.
178
+ */
179
+ const RENDERS = /* @__PURE__ */ new WeakMap();
180
+ function registerRendered(root, rendered) {
181
+ RENDERS.set(root, rendered);
182
+ }
183
+ /** The render `node` belongs to, or null if it was not mounted by one. */
184
+ function renderedFor(node) {
185
+ for (let current = node; current !== null; current = current.parent) {
186
+ const rendered = RENDERS.get(current);
187
+ if (rendered !== void 0) return rendered;
188
+ }
189
+ return null;
190
+ }
191
+ //#endregion
192
+ export { textProperty as a, nodesUnder as i, renderedFor as n, createQueries as r, registerRendered as t };
193
+
194
+ //# sourceMappingURL=registry-BlqGkMSO.js.map
@@ -0,0 +1 @@
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"}
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "gesso-testing",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "author": "Kevin Baker",
7
+ "homepage": "https://github.com/kevinpbaker/gesso#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kevinpbaker/gesso.git",
11
+ "directory": "packages/testing"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/kevinpbaker/gesso/issues"
15
+ },
16
+ "keywords": [
17
+ "gesso",
18
+ "canvas",
19
+ "ui",
20
+ "typescript",
21
+ "testing",
22
+ "vitest",
23
+ "component-testing",
24
+ "accessibility",
25
+ "test-utils"
26
+ ],
27
+ "type": "module",
28
+ "sideEffects": [
29
+ "./src/matchers.ts",
30
+ "./dist/matchers.js"
31
+ ],
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "./matchers": {
38
+ "types": "./dist/matchers.d.ts",
39
+ "default": "./dist/matchers.js"
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": [
46
+ "README.md",
47
+ "CHANGELOG.md",
48
+ "dist",
49
+ "LICENSE"
50
+ ],
51
+ "dependencies": {
52
+ "gesso-core": "^0.1.0",
53
+ "gesso-framework": "^0.1.0"
54
+ },
55
+ "peerDependencies": {
56
+ "rxjs": "^7.8.2",
57
+ "vitest": "^4.1.10"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "vitest": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "rxjs": "^7.8.2"
66
+ },
67
+ "scripts": {
68
+ "build": "tsdown"
69
+ }
70
+ }