@orkestrel/test 0.0.5 → 0.0.6

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/README.md CHANGED
@@ -5,10 +5,12 @@ real callback rather than a spy. A real host delay. A throw-to-value converter a
5
5
  narrower, so `!` and `as` stay banned in tests. Two async collectors and a JSON copier. A frozen
6
6
  hostile-value corpus for proving guards are total. A cleanup list that gives every owned resource
7
7
  back, newest first. A scratch directory the test owns and destroys, a loopback port for a server the
8
- test built, and a symlink-refusing source-file walker. A helper ships here only when enough packages
9
- had already written their own; the guide's
10
- [Limits](guides/test.md#limits) section states that rule and what it excluded. Add it as a
11
- devDependency; nothing here runs in production code. Part of the `@orkestrel` line.
8
+ test built, and a symlink-refusing source-file walker. And the browser journey layer, which drives
9
+ a real interface by role and accessible name through the installed Vitest provider. A helper ships
10
+ here only when enough packages had already written their own; the guide's
11
+ [Limits](guides/test.md#limits) section states that rule, what it excluded, and the one door the
12
+ journey layer came through instead. Add it as a devDependency; nothing here runs in production
13
+ code. Part of the `@orkestrel` line.
12
14
 
13
15
  It has **zero runtime dependencies**, and no exported signature names an `@orkestrel/*` type. Both
14
16
  rules exist for one reason: a test helper hands its types straight into the consumer's assertions,
@@ -27,8 +29,10 @@ npm install -D @orkestrel/test
27
29
 
28
30
  ## Usage
29
31
 
30
- `@orkestrel/test` is the host-independent core. `@orkestrel/test/server` is the Node face. Core
31
- touches neither `node:*` nor the DOM, so a browser test project imports it unchanged.
32
+ `@orkestrel/test` is the host-independent core. `@orkestrel/test/server` is the Node face.
33
+ `@orkestrel/test/browser` is the journey layer, which drives a real browser through the installed
34
+ Vitest provider. Core touches neither `node:*` nor the DOM, so a browser test project imports it
35
+ unchanged.
32
36
 
33
37
  ```ts
34
38
  import {
@@ -141,6 +145,14 @@ leaves the root through a link in the middle, and skips a symlink met while walk
141
145
  sandbox against hostile filesystem content: those refusals stop accidental escape, not an adversary
142
146
  who can create hard links where the test process already writes.
143
147
 
148
+ The browser face is the journey layer: an accessible-name resolver with exact failure voices,
149
+ region and disclosure targeting, input and traversal verbs, perception readers, a WCAG contrast
150
+ instrument that composites translucent layers, and the capture portfolio. Every acting verb
151
+ resolves its own target from a role and an accessible name — none takes an element, a component
152
+ instance, or a selector — and the whole environment imports `vitest/browser` and DOM globals and
153
+ nothing else, with `vitest` declared as a peer dependency. The guide's
154
+ [Browser](guides/test.md#browser) section carries every export and every voice.
155
+
144
156
  `createLoopback` binds a server the test built. The caller constructs it and keeps every route,
145
157
  header, and status on it; this package supplies the bind and the release and nothing else.
146
158
 
@@ -179,9 +191,10 @@ rule deciding what ships and what stays in the package that owns it — see
179
191
 
180
192
  ## Package
181
193
 
182
- Published as two typed entry points per the `exports` field in `package.json`: `@orkestrel/test` for
183
- the host-independent core, `@orkestrel/test/server` for the Node helpers. Both ship ESM and
184
- CommonJS.
194
+ Published as three typed entry points per the `exports` field in `package.json`: `@orkestrel/test`
195
+ for the host-independent core, `@orkestrel/test/browser` for the journey layer, and
196
+ `@orkestrel/test/server` for the Node helpers. Core and server ship ESM and CommonJS; the browser
197
+ face ships ESM only, because `vitest/browser` is an ES-only module.
185
198
 
186
199
  ## License
187
200
 
@@ -0,0 +1,478 @@
1
+ /**
2
+ * The interactive ARIA roles a bare accessible name is searched across.
3
+ *
4
+ * @remarks
5
+ * A person names a control, not a role, so the one-argument resolver searches every role a control
6
+ * can compute. The two-argument form searches exactly the role it is given, which is how a name
7
+ * shared by a tab and its panel is disambiguated.
8
+ */
9
+ export declare const ACCESSIBLE_ROLES: readonly string[];
10
+
11
+ /** One theme-and-viewport pair a capture run renders. */
12
+ export declare interface CaptureVariant {
13
+ /** The variant's name, which is the second half of every filename the run writes. */
14
+ readonly name: string;
15
+ /** The viewport width in pixels. */
16
+ readonly width: number;
17
+ /** The viewport height in pixels. */
18
+ readonly height: number;
19
+ /**
20
+ * The document change this variant needs before the viewport is resized — a theme attribute, a
21
+ * density class, a language direction. Omit it when the variant is a viewport alone.
22
+ */
23
+ readonly apply?: () => void;
24
+ }
25
+
26
+ /**
27
+ * Clicks one visible, focus-reachable control by its accessible name through the browser provider.
28
+ *
29
+ * @param name - The target's exact accessible name.
30
+ * @returns A promise resolving after trusted activation completes.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * await clickAccessible('Apply')
35
+ * ```
36
+ */
37
+ export declare function clickAccessible(name: string): Promise<void>;
38
+
39
+ /**
40
+ * Clicks one visible, focus-reachable control by its exact ARIA role and accessible name,
41
+ * disambiguating a bare name that answers for more than one rendered element.
42
+ *
43
+ * @param role - The control's exact ARIA role.
44
+ * @param name - The target's exact accessible name.
45
+ * @returns A promise resolving after trusted activation completes.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * await clickAccessible('tab', 'Drafts')
50
+ * ```
51
+ */
52
+ export declare function clickAccessible(role: string, name: string): Promise<void>;
53
+
54
+ /**
55
+ * Clicks one human-reachable control by role and accessible-name text inside a named region.
56
+ *
57
+ * @param region - The containing region's exact accessible name.
58
+ * @param role - The control's exact ARIA role.
59
+ * @param name - The rendered accessible-name text that identifies the control in that region.
60
+ * @returns A promise resolving after trusted activation completes.
61
+ * @throws When the named control is absent, unreachable, or ambiguous inside the region.
62
+ *
63
+ * @remarks
64
+ * Use this form when repeated short verbs such as `Add`, or a line whose status completes its
65
+ * accessible name, need the same region context a person uses to disambiguate them.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * await clickAccessibleWithin('Ledger', 'button', 'Monthly income')
70
+ * ```
71
+ */
72
+ export declare function clickAccessibleWithin(region: string, role: string, name: string): Promise<void>;
73
+
74
+ /**
75
+ * Opens or closes one native details disclosure by its rendered summary.
76
+ *
77
+ * @param name - The summary text a person reads.
78
+ * @returns A promise resolving after trusted activation completes.
79
+ * @throws When no visible, focus-reachable native summary has that rendered name, or several do.
80
+ *
81
+ * @remarks
82
+ * Chromium exposes `<summary>` as a native disclosure rather than through an ARIA role accepted by
83
+ * `getByRole`, so this resolver names the platform element and its rendered text directly.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * await clickDisclosure('Advanced')
88
+ * ```
89
+ */
90
+ export declare function clickDisclosure(name: string): Promise<void>;
91
+
92
+ /**
93
+ * Measures the WCAG 2.x contrast ratio between an element's computed text and background colors.
94
+ *
95
+ * @param element - The element whose rendered text contrast to measure.
96
+ * @returns The relative-luminance contrast ratio.
97
+ * @throws When the browser does not expose parseable computed colors.
98
+ *
99
+ * @remarks
100
+ * A transparent or translucent background resolves through the element's ancestors: every painted
101
+ * layer from the element up to the first opaque one composites top-over-bottom onto that opaque
102
+ * base, so a 3% surface tint reads as a tint over what shows through it rather than as a
103
+ * full-strength paint. A translucent foreground then resolves against that effective background
104
+ * before luminance is measured.
105
+ *
106
+ * Every element from the target upwards must be reachable, and at least one of them must paint:
107
+ * the measurement throws rather than assuming a white canvas when nothing in the chain declares a
108
+ * background color. The element itself must expose a computed foreground color — a detached
109
+ * element exposes none, and the measurement throws rather than guessing one.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const container = render('<p style="background: #000; color: #fff">Ready</p>')
114
+ * contrast(requireValue(container.firstElementChild)) // 21
115
+ * ```
116
+ */
117
+ export declare function contrast(element: Element): number;
118
+
119
+ /**
120
+ * Creates the capture portfolio one run places its screenshots through.
121
+ *
122
+ * @param options - The state registry, the variant matrix, the variant this run renders, the
123
+ * directory it writes into, and whether it writes at all.
124
+ * @returns The portfolio: its registry expansion, what it has placed, and `place`.
125
+ * @throws When no registered variant carries the name `variant` names.
126
+ *
127
+ * @remarks
128
+ * A disabled portfolio is the ordinary run. `place` then resizes nothing, writes nothing, and
129
+ * records nothing, so a journey calls it unconditionally and a suite with the flag unset pays for
130
+ * none of it. The portfolio refuses an unregistered variant at creation. An enabled run refuses an
131
+ * unregistered state name and a second placement of one state.
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * const portfolio = createPortfolio({
136
+ * states: ['start-empty'],
137
+ * variants: [{ name: 'dark-390', width: 390, height: 844 }],
138
+ * variant: 'dark-390',
139
+ * directory: '../../tmp/capture/states',
140
+ * })
141
+ * await portfolio.place('start-empty')
142
+ * ```
143
+ */
144
+ export declare function createPortfolio(options: PortfolioOptions): PortfolioInterface;
145
+
146
+ /**
147
+ * Expands a capture registry across every variant into the filenames a complete portfolio holds.
148
+ *
149
+ * @param states - The registered state names.
150
+ * @param variants - The variants the portfolio is rendered in.
151
+ * @returns One `<state>--<variant>.png` name per pair, each state's variants together, in registry
152
+ * order.
153
+ *
154
+ * @remarks
155
+ * The expansion is the portfolio's own definition of complete, so a duplicate in it is a registry
156
+ * defect a proof reads directly rather than a collision discovered on disk.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * expandCaptures(['start'], [{ name: 'dark-390', width: 390, height: 844 }])
161
+ * // ['start--dark-390.png']
162
+ * ```
163
+ */
164
+ export declare function expandCaptures(states: readonly string[], variants: readonly CaptureVariant[]): readonly string[];
165
+
166
+ /**
167
+ * Replaces a named field's value in one operation, for text too long to type key by key.
168
+ *
169
+ * @param name - The field's exact accessible name.
170
+ * @param text - The text to place in the field.
171
+ * @returns A promise resolving after the browser commits the value.
172
+ *
173
+ * @remarks
174
+ * The provider drives the real element, so the field publishes the same input event a person's
175
+ * typing publishes. Use {@link typeAccessible} wherever the keystrokes themselves are the subject.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * await fillAccessible('Payload', '{"status":"ready"}')
180
+ * ```
181
+ */
182
+ export declare function fillAccessible(name: string, text: string): Promise<void>;
183
+
184
+ /**
185
+ * Determines whether a rectangle lies wholly outside the browser viewport.
186
+ *
187
+ * @param rectangle - The measured client rectangle to inspect.
188
+ * @returns `true` when no part of the rectangle intersects the viewport.
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * isOutsideViewport(element.getBoundingClientRect())
193
+ * ```
194
+ */
195
+ export declare function isOutsideViewport(rectangle: DOMRectReadOnly): boolean;
196
+
197
+ /** The registry of capture states one run places, and the files it wrote placing them. */
198
+ export declare interface PortfolioInterface {
199
+ /** The name of the variant this run renders. */
200
+ readonly variant: string;
201
+ /** Every state placed so far, in placement order. */
202
+ readonly states: readonly string[];
203
+ /** Every path written so far, in write order. */
204
+ readonly paths: readonly string[];
205
+ /** The registry expanded across every variant: the filenames a complete portfolio holds. */
206
+ readonly files: readonly string[];
207
+ /**
208
+ * Places one registered state: applies the variant, resizes the viewport, and writes the
209
+ * screenshot.
210
+ *
211
+ * @param state - The state name from the registry.
212
+ * @returns The written path, or `undefined` when the portfolio is not enabled.
213
+ * @throws When the state is not registered or has already been placed.
214
+ */
215
+ place(state: string): Promise<string | undefined>;
216
+ }
217
+
218
+ /** Options for a capture portfolio. */
219
+ export declare interface PortfolioOptions {
220
+ /**
221
+ * Every state name the journeys place, declared once. `place` refuses a name absent from this
222
+ * list, so the registry and the disk cannot drift apart.
223
+ */
224
+ readonly states: readonly string[];
225
+ /** Every variant the portfolio can be rendered in. One run renders exactly one of them. */
226
+ readonly variants: readonly CaptureVariant[];
227
+ /** The name of the variant this run renders. Creation throws when no variant carries it. */
228
+ readonly variant: string;
229
+ /** The directory each written file is placed in, relative to the calling test file. */
230
+ readonly directory: string;
231
+ /**
232
+ * Whether this run writes files. An ordinary run leaves it unset, so `place` resizes nothing,
233
+ * writes nothing, and records nothing.
234
+ */
235
+ readonly enabled?: boolean;
236
+ }
237
+
238
+ /**
239
+ * Presses a browser-keyboard sequence using Vitest's installed user-event syntax.
240
+ *
241
+ * @param keys - The keys or key descriptors to press.
242
+ * @returns A promise resolving after the sequence completes.
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * await pressKeys('{ArrowRight}{Enter}')
247
+ * ```
248
+ */
249
+ export declare function pressKeys(keys: string): Promise<void>;
250
+
251
+ /**
252
+ * Collects every class token the stylesheets loaded into this document actually define.
253
+ *
254
+ * @returns The set of class names reachable in the shipped cascade.
255
+ *
256
+ * @remarks
257
+ * The set is what an authored-class conformance check measures against, so a class no loaded
258
+ * stylesheet defines — an invented utility, a misspelled framework name — is absent from it.
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * readCascade().has('card')
263
+ * ```
264
+ */
265
+ export declare function readCascade(): ReadonlySet<string>;
266
+
267
+ /**
268
+ * Reads the rendered text of the element that currently holds focus.
269
+ *
270
+ * @returns The focused HTML element's trimmed rendered text, including an empty string, or
271
+ * `undefined` when focus rests on a non-HTML element. When nothing holds focus, the browser
272
+ * reports the document body as active, so the whole page's rendered text returns.
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * await traverseAccessible('Evaluate')
277
+ * readFocus() // 'Evaluate'
278
+ * ```
279
+ */
280
+ export declare function readFocus(): string | undefined;
281
+
282
+ /**
283
+ * Reads the normalized visible text of the whole page.
284
+ *
285
+ * @returns Every rendered word in the document body, its whitespace runs collapsed and trimmed.
286
+ *
287
+ * @remarks
288
+ * This is the reader for a sentence that spans two regions and for a vocabulary sweep over the
289
+ * words an interface uses. Reach for {@link readPerception} wherever one named region is the
290
+ * subject, because that one throws when the region is missing and this one returns whatever is
291
+ * there.
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * readPage().includes('No cases yet')
296
+ * ```
297
+ */
298
+ export declare function readPage(): string;
299
+
300
+ /**
301
+ * Reads the normalized visible text of one named region, dialog, table, tab panel, or alert.
302
+ *
303
+ * @param name - The region's exact accessible name.
304
+ * @returns The text a screen reader can perceive in the visible region, including descendant
305
+ * visually-hidden content.
306
+ * @throws When the named region is absent, hidden, or ambiguous.
307
+ *
308
+ * @example
309
+ * ```ts
310
+ * readPerception('Run')
311
+ * ```
312
+ */
313
+ export declare function readPerception(name: string): string;
314
+
315
+ /**
316
+ * Reads the normalized visible text of every element a selector matches, in document order.
317
+ *
318
+ * @param root - The subtree to search.
319
+ * @param selector - The CSS selector naming the rows.
320
+ * @returns One line per matched element, its text runs collapsed and single-space joined.
321
+ *
322
+ * @remarks
323
+ * The line is built from the row's text nodes rather than from `textContent`, because adjacent
324
+ * inline elements carry no whitespace between them in compiled template output and would otherwise
325
+ * read as one run-together word.
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * readRows(container, 'li')
330
+ * ```
331
+ */
332
+ export declare function readRows(root: ParentNode, selector: string): readonly string[];
333
+
334
+ /**
335
+ * Reads the value a resolved control renders.
336
+ *
337
+ * @param role - The control's exact ARIA role.
338
+ * @param name - The control's exact accessible name.
339
+ * @returns The control's current value.
340
+ * @throws When the target does not resolve, or resolves to an element that carries no value.
341
+ *
342
+ * @remarks
343
+ * A control's value is a rendered fact a person can read, not internal state, so it is read from
344
+ * the resolved element rather than from the component that produced it.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * readValue('spinbutton', 'Runs') // '3'
349
+ * ```
350
+ */
351
+ export declare function readValue(role: string, name: string): string;
352
+
353
+ /**
354
+ * Renders trusted fixture markup into a container attached to the document.
355
+ *
356
+ * @param markup - The fixture markup to render.
357
+ * @returns The attached container.
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * const container = render('<button type="button">Save</button>')
362
+ * container.remove()
363
+ * ```
364
+ */
365
+ export declare function render(markup: string): HTMLDivElement;
366
+
367
+ /**
368
+ * Resolves one visible, focus-reachable interactive element by its exact accessible name. A
369
+ * wholly-off-viewport target is scrolled into view before reachability is measured.
370
+ *
371
+ * @param name - The accessible name rendered for the target.
372
+ * @returns The one reachable element carrying that name.
373
+ * @throws When no matching element exists; every match is disconnected, hidden, zero-sized, still
374
+ * outside the viewport after being scrolled into view, removed from sequential focus, disabled, or
375
+ * inside an inert subtree; or several reachable matches make the name ambiguous.
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * resolveAccessible('Save changes')
380
+ * ```
381
+ */
382
+ export declare function resolveAccessible(name: string): HTMLElement;
383
+
384
+ /**
385
+ * Resolves one visible, focus-reachable interactive element by its exact ARIA role and accessible
386
+ * name, disambiguating a bare name that answers for more than one rendered element. A
387
+ * wholly-off-viewport target is scrolled into view before reachability is measured.
388
+ *
389
+ * @param role - The element's exact ARIA role.
390
+ * @param name - The accessible name rendered for the target.
391
+ * @returns The one reachable element carrying that role and name.
392
+ * @throws When no matching element exists; every match is disconnected, hidden, zero-sized, still
393
+ * outside the viewport after being scrolled into view, removed from sequential focus, disabled, or
394
+ * inside an inert subtree; or several reachable matches make the role/name pair ambiguous.
395
+ *
396
+ * @example
397
+ * ```ts
398
+ * resolveAccessible('tab', 'Drafts')
399
+ * ```
400
+ */
401
+ export declare function resolveAccessible(role: string, name: string): HTMLElement;
402
+
403
+ /**
404
+ * Resolves one rendered, focus-reachable interactive element without requiring it to intersect the
405
+ * viewport yet.
406
+ *
407
+ * @param first - The accessible name, or the exact ARIA role when `second` is present.
408
+ * @param second - The accessible name when `first` supplies the role.
409
+ * @returns The one rendered element carrying that name and optional role.
410
+ * @throws When no matching element exists, every match is hidden or unreachable, or several
411
+ * rendered matches make the name ambiguous.
412
+ *
413
+ * @remarks
414
+ * This is the resolver the acting verbs use, so a click does not fail on a target the act itself
415
+ * scrolls into view. Use {@link resolveAccessible} wherever the target must already be on screen.
416
+ *
417
+ * @example
418
+ * ```ts
419
+ * resolveRendered('tab', 'Drafts')
420
+ * ```
421
+ */
422
+ export declare function resolveRendered(first: string, second?: string): HTMLElement;
423
+
424
+ /**
425
+ * Reads one resolved CSS property from a real browser element.
426
+ *
427
+ * @param element - The element whose resolved style to inspect.
428
+ * @param property - The CSS property name.
429
+ * @returns The browser's resolved property value.
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * style(button, 'padding-left')
434
+ * ```
435
+ */
436
+ export declare function style(element: Element, property: string): string;
437
+
438
+ /**
439
+ * Reaches a named control only through natural forward Tab traversal from the current focus.
440
+ *
441
+ * @param name - The target's exact accessible name.
442
+ * @returns The target after the browser moves focus to it.
443
+ * @throws When one complete traversal cannot reach the target.
444
+ *
445
+ * @example
446
+ * ```ts
447
+ * await traverseAccessible('Evaluate')
448
+ * ```
449
+ */
450
+ export declare function traverseAccessible(name: string): Promise<HTMLElement>;
451
+
452
+ /**
453
+ * Replaces a named field's value through focus, select-all, deletion, and real keystrokes.
454
+ *
455
+ * @param name - The field's exact accessible name.
456
+ * @param text - The text to type.
457
+ * @returns A promise resolving after every keystroke completes.
458
+ *
459
+ * @example
460
+ * ```ts
461
+ * await typeAccessible('Runs', '3')
462
+ * ```
463
+ */
464
+ export declare function typeAccessible(name: string, text: string): Promise<void>;
465
+
466
+ /**
467
+ * Waits for one animation frame to settle pending browser paint work.
468
+ *
469
+ * @returns A promise resolving after one `requestAnimationFrame`.
470
+ *
471
+ * @example
472
+ * ```ts
473
+ * await waitForFrame()
474
+ * ```
475
+ */
476
+ export declare function waitForFrame(): Promise<void>;
477
+
478
+ export { }
@@ -0,0 +1,619 @@
1
+ import { page, userEvent } from "vitest/browser";
2
+ //#region src/browser/constants.ts
3
+ /**
4
+ * The interactive ARIA roles a bare accessible name is searched across.
5
+ *
6
+ * @remarks
7
+ * A person names a control, not a role, so the one-argument resolver searches every role a control
8
+ * can compute. The two-argument form searches exactly the role it is given, which is how a name
9
+ * shared by a tab and its panel is disambiguated.
10
+ */
11
+ var ACCESSIBLE_ROLES = Object.freeze([
12
+ "button",
13
+ "checkbox",
14
+ "combobox",
15
+ "link",
16
+ "listbox",
17
+ "menuitem",
18
+ "option",
19
+ "radio",
20
+ "searchbox",
21
+ "slider",
22
+ "spinbutton",
23
+ "switch",
24
+ "tab",
25
+ "tabpanel",
26
+ "textbox",
27
+ "treeitem"
28
+ ]);
29
+ //#endregion
30
+ //#region src/browser/helpers.ts
31
+ /**
32
+ * Determines whether a rectangle lies wholly outside the browser viewport.
33
+ *
34
+ * @param rectangle - The measured client rectangle to inspect.
35
+ * @returns `true` when no part of the rectangle intersects the viewport.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * isOutsideViewport(element.getBoundingClientRect())
40
+ * ```
41
+ */
42
+ function isOutsideViewport(rectangle) {
43
+ return rectangle.bottom <= 0 || rectangle.right <= 0 || rectangle.top >= window.innerHeight || rectangle.left >= window.innerWidth;
44
+ }
45
+ /**
46
+ * Resolves one rendered, focus-reachable interactive element without requiring it to intersect the
47
+ * viewport yet.
48
+ *
49
+ * @param first - The accessible name, or the exact ARIA role when `second` is present.
50
+ * @param second - The accessible name when `first` supplies the role.
51
+ * @returns The one rendered element carrying that name and optional role.
52
+ * @throws When no matching element exists, every match is hidden or unreachable, or several
53
+ * rendered matches make the name ambiguous.
54
+ *
55
+ * @remarks
56
+ * This is the resolver the acting verbs use, so a click does not fail on a target the act itself
57
+ * scrolls into view. Use {@link resolveAccessible} wherever the target must already be on screen.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * resolveRendered('tab', 'Drafts')
62
+ * ```
63
+ */
64
+ function resolveRendered(first, second) {
65
+ const name = second ?? first;
66
+ const roles = second === void 0 ? ACCESSIBLE_ROLES : [first];
67
+ const matches = [];
68
+ for (const role of roles) for (const element of page.getByRole(role, {
69
+ name,
70
+ exact: true,
71
+ includeHidden: true
72
+ }).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
73
+ if (matches.length === 0) throw new Error(`No interactive element has the accessible name "${name}"`);
74
+ const reachable = matches.filter((element) => {
75
+ const rectangle = element.getBoundingClientRect();
76
+ return element.isConnected && element.checkVisibility({
77
+ checkOpacity: true,
78
+ checkVisibilityCSS: true
79
+ }) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
80
+ });
81
+ if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not visible and focus-reachable`);
82
+ if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements`);
83
+ const [target] = reachable;
84
+ if (target === void 0) throw new Error(`Interactive target "${name}" could not be resolved`);
85
+ return target;
86
+ }
87
+ function resolveAccessible(first, second) {
88
+ const target = resolveRendered(first, second);
89
+ let rectangle = target.getBoundingClientRect();
90
+ if (isOutsideViewport(rectangle)) {
91
+ target.scrollIntoView({
92
+ block: "nearest",
93
+ behavior: "instant"
94
+ });
95
+ rectangle = target.getBoundingClientRect();
96
+ }
97
+ if (isOutsideViewport(rectangle)) throw new Error(`Interactive target "${second ?? first}" is unreachable after scrolling`);
98
+ return target;
99
+ }
100
+ async function clickAccessible(first, second) {
101
+ const target = resolveRendered(first, second);
102
+ await userEvent.click(target);
103
+ }
104
+ /**
105
+ * Clicks one human-reachable control by role and accessible-name text inside a named region.
106
+ *
107
+ * @param region - The containing region's exact accessible name.
108
+ * @param role - The control's exact ARIA role.
109
+ * @param name - The rendered accessible-name text that identifies the control in that region.
110
+ * @returns A promise resolving after trusted activation completes.
111
+ * @throws When the named control is absent, unreachable, or ambiguous inside the region.
112
+ *
113
+ * @remarks
114
+ * Use this form when repeated short verbs such as `Add`, or a line whose status completes its
115
+ * accessible name, need the same region context a person uses to disambiguate them.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * await clickAccessibleWithin('Ledger', 'button', 'Monthly income')
120
+ * ```
121
+ */
122
+ async function clickAccessibleWithin(region, role, name) {
123
+ const reachable = page.getByRole("region", {
124
+ name: region,
125
+ exact: true
126
+ }).getByRole(role, {
127
+ name,
128
+ exact: false,
129
+ includeHidden: true
130
+ }).elements().filter((element) => {
131
+ if (!(element instanceof HTMLElement)) return false;
132
+ const rectangle = element.getBoundingClientRect();
133
+ return element.isConnected && element.checkVisibility({
134
+ checkOpacity: true,
135
+ checkVisibilityCSS: true
136
+ }) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
137
+ });
138
+ if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not reachable inside "${region}"`);
139
+ if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements inside "${region}"`);
140
+ const [target] = reachable;
141
+ if (!(target instanceof HTMLElement)) throw new Error(`Interactive target "${name}" could not be resolved inside "${region}"`);
142
+ await userEvent.click(target);
143
+ }
144
+ /**
145
+ * Opens or closes one native details disclosure by its rendered summary.
146
+ *
147
+ * @param name - The summary text a person reads.
148
+ * @returns A promise resolving after trusted activation completes.
149
+ * @throws When no visible, focus-reachable native summary has that rendered name, or several do.
150
+ *
151
+ * @remarks
152
+ * Chromium exposes `<summary>` as a native disclosure rather than through an ARIA role accepted by
153
+ * `getByRole`, so this resolver names the platform element and its rendered text directly.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * await clickDisclosure('Advanced')
158
+ * ```
159
+ */
160
+ async function clickDisclosure(name) {
161
+ const reachable = [...document.querySelectorAll("summary")].filter((element) => element.innerText.replaceAll(/\s+/g, " ").trim() === name).filter((element) => {
162
+ const rectangle = element.getBoundingClientRect();
163
+ return element.isConnected && element.checkVisibility({
164
+ checkOpacity: true,
165
+ checkVisibilityCSS: true
166
+ }) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && element.closest("[inert]") === null;
167
+ });
168
+ if (reachable.length === 0) throw new Error(`Native disclosure "${name}" is not visible and focus-reachable`);
169
+ if (reachable.length > 1) throw new Error(`Native disclosure "${name}" is ambiguous across ${reachable.length} elements`);
170
+ const [target] = reachable;
171
+ if (target === void 0) throw new Error(`Native disclosure "${name}" could not be resolved`);
172
+ await userEvent.click(target);
173
+ }
174
+ /**
175
+ * Replaces a named field's value through focus, select-all, deletion, and real keystrokes.
176
+ *
177
+ * @param name - The field's exact accessible name.
178
+ * @param text - The text to type.
179
+ * @returns A promise resolving after every keystroke completes.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * await typeAccessible('Runs', '3')
184
+ * ```
185
+ */
186
+ async function typeAccessible(name, text) {
187
+ await userEvent.click(resolveRendered(name));
188
+ await userEvent.keyboard("{Control>}a{/Control}{Backspace}");
189
+ if (text === "") return;
190
+ await userEvent.keyboard(text.replaceAll("{", "{{").replaceAll("[", "[["));
191
+ }
192
+ /**
193
+ * Replaces a named field's value in one operation, for text too long to type key by key.
194
+ *
195
+ * @param name - The field's exact accessible name.
196
+ * @param text - The text to place in the field.
197
+ * @returns A promise resolving after the browser commits the value.
198
+ *
199
+ * @remarks
200
+ * The provider drives the real element, so the field publishes the same input event a person's
201
+ * typing publishes. Use {@link typeAccessible} wherever the keystrokes themselves are the subject.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * await fillAccessible('Payload', '{"status":"ready"}')
206
+ * ```
207
+ */
208
+ async function fillAccessible(name, text) {
209
+ await userEvent.fill(resolveRendered(name), text);
210
+ }
211
+ /**
212
+ * Presses a browser-keyboard sequence using Vitest's installed user-event syntax.
213
+ *
214
+ * @param keys - The keys or key descriptors to press.
215
+ * @returns A promise resolving after the sequence completes.
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * await pressKeys('{ArrowRight}{Enter}')
220
+ * ```
221
+ */
222
+ async function pressKeys(keys) {
223
+ await userEvent.keyboard(keys);
224
+ }
225
+ /**
226
+ * Reaches a named control only through natural forward Tab traversal from the current focus.
227
+ *
228
+ * @param name - The target's exact accessible name.
229
+ * @returns The target after the browser moves focus to it.
230
+ * @throws When one complete traversal cannot reach the target.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * await traverseAccessible('Evaluate')
235
+ * ```
236
+ */
237
+ async function traverseAccessible(name) {
238
+ resolveRendered(name);
239
+ const cap = document.querySelectorAll("a[href], button, input, select, textarea, [tabindex]").length * 3 + 10;
240
+ const visited = /* @__PURE__ */ new Set();
241
+ const trail = [];
242
+ for (let attempt = 0; attempt < cap; attempt += 1) {
243
+ await userEvent.tab();
244
+ const focused = document.activeElement;
245
+ if (!(focused instanceof HTMLElement) || focused === document.body) continue;
246
+ let current;
247
+ try {
248
+ current = resolveRendered(name);
249
+ } catch {
250
+ continue;
251
+ }
252
+ if (focused === current) return current;
253
+ if (visited.has(focused)) break;
254
+ visited.add(focused);
255
+ trail.push(`${focused.tagName}:${focused.innerText.slice(0, 20)}`);
256
+ }
257
+ throw new Error(`Interactive target "${name}" is not reachable through forward Tab traversal: ${trail.join(" > ")}`);
258
+ }
259
+ /**
260
+ * Reads the normalized visible text of one named region, dialog, table, tab panel, or alert.
261
+ *
262
+ * @param name - The region's exact accessible name.
263
+ * @returns The text a screen reader can perceive in the visible region, including descendant
264
+ * visually-hidden content.
265
+ * @throws When the named region is absent, hidden, or ambiguous.
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * readPerception('Run')
270
+ * ```
271
+ */
272
+ function readPerception(name) {
273
+ const matches = [];
274
+ for (const role of [
275
+ "alert",
276
+ "alertdialog",
277
+ "dialog",
278
+ "region",
279
+ "status",
280
+ "table",
281
+ "tabpanel"
282
+ ]) for (const element of page.getByRole(role, {
283
+ name,
284
+ exact: true,
285
+ includeHidden: true
286
+ }).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
287
+ const visible = matches.filter((element) => {
288
+ const rectangle = element.getBoundingClientRect();
289
+ return element.isConnected && element.checkVisibility({
290
+ checkOpacity: true,
291
+ checkVisibilityCSS: true
292
+ }) && rectangle.width > 0 && rectangle.height > 0;
293
+ });
294
+ if (visible.length === 0) throw new Error(`Named region "${name}" is not visible`);
295
+ if (visible.length > 1) throw new Error(`Named region "${name}" is ambiguous across ${visible.length} elements`);
296
+ const [region] = visible;
297
+ if (region === void 0) throw new Error(`Named region "${name}" could not be resolved`);
298
+ return region.innerText.replaceAll(/\s+/g, " ").trim();
299
+ }
300
+ /**
301
+ * Reads the normalized visible text of the whole page.
302
+ *
303
+ * @returns Every rendered word in the document body, its whitespace runs collapsed and trimmed.
304
+ *
305
+ * @remarks
306
+ * This is the reader for a sentence that spans two regions and for a vocabulary sweep over the
307
+ * words an interface uses. Reach for {@link readPerception} wherever one named region is the
308
+ * subject, because that one throws when the region is missing and this one returns whatever is
309
+ * there.
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * readPage().includes('No cases yet')
314
+ * ```
315
+ */
316
+ function readPage() {
317
+ return document.body.innerText.replaceAll(/\s+/g, " ").trim();
318
+ }
319
+ /**
320
+ * Reads the rendered text of the element that currently holds focus.
321
+ *
322
+ * @returns The focused HTML element's trimmed rendered text, including an empty string, or
323
+ * `undefined` when focus rests on a non-HTML element. When nothing holds focus, the browser
324
+ * reports the document body as active, so the whole page's rendered text returns.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * await traverseAccessible('Evaluate')
329
+ * readFocus() // 'Evaluate'
330
+ * ```
331
+ */
332
+ function readFocus() {
333
+ const focused = document.activeElement;
334
+ return focused instanceof HTMLElement ? focused.innerText.trim() : void 0;
335
+ }
336
+ /**
337
+ * Reads the value a resolved control renders.
338
+ *
339
+ * @param role - The control's exact ARIA role.
340
+ * @param name - The control's exact accessible name.
341
+ * @returns The control's current value.
342
+ * @throws When the target does not resolve, or resolves to an element that carries no value.
343
+ *
344
+ * @remarks
345
+ * A control's value is a rendered fact a person can read, not internal state, so it is read from
346
+ * the resolved element rather than from the component that produced it.
347
+ *
348
+ * @example
349
+ * ```ts
350
+ * readValue('spinbutton', 'Runs') // '3'
351
+ * ```
352
+ */
353
+ function readValue(role, name) {
354
+ const control = resolveAccessible(role, name);
355
+ if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLTextAreaElement) && !(control instanceof HTMLSelectElement)) throw new Error(`Interactive target "${name}" does not carry a value`);
356
+ return control.value;
357
+ }
358
+ /**
359
+ * Waits for one animation frame to settle pending browser paint work.
360
+ *
361
+ * @returns A promise resolving after one `requestAnimationFrame`.
362
+ *
363
+ * @example
364
+ * ```ts
365
+ * await waitForFrame()
366
+ * ```
367
+ */
368
+ function waitForFrame() {
369
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
370
+ }
371
+ /**
372
+ * Renders trusted fixture markup into a container attached to the document.
373
+ *
374
+ * @param markup - The fixture markup to render.
375
+ * @returns The attached container.
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * const container = render('<button type="button">Save</button>')
380
+ * container.remove()
381
+ * ```
382
+ */
383
+ function render(markup) {
384
+ const container = document.createElement("div");
385
+ container.innerHTML = markup;
386
+ document.body.append(container);
387
+ return container;
388
+ }
389
+ /**
390
+ * Measures the WCAG 2.x contrast ratio between an element's computed text and background colors.
391
+ *
392
+ * @param element - The element whose rendered text contrast to measure.
393
+ * @returns The relative-luminance contrast ratio.
394
+ * @throws When the browser does not expose parseable computed colors.
395
+ *
396
+ * @remarks
397
+ * A transparent or translucent background resolves through the element's ancestors: every painted
398
+ * layer from the element up to the first opaque one composites top-over-bottom onto that opaque
399
+ * base, so a 3% surface tint reads as a tint over what shows through it rather than as a
400
+ * full-strength paint. A translucent foreground then resolves against that effective background
401
+ * before luminance is measured.
402
+ *
403
+ * Every element from the target upwards must be reachable, and at least one of them must paint:
404
+ * the measurement throws rather than assuming a white canvas when nothing in the chain declares a
405
+ * background color. The element itself must expose a computed foreground color — a detached
406
+ * element exposes none, and the measurement throws rather than guessing one.
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * const container = render('<p style="background: #000; color: #fff">Ready</p>')
411
+ * contrast(requireValue(container.firstElementChild)) // 21
412
+ * ```
413
+ */
414
+ function contrast(element) {
415
+ const foreground = getComputedStyle(element).color.match(/\d+(?:\.\d+)?/g);
416
+ if (foreground === null || foreground.length < 3) throw new Error("Computed foreground color is unavailable");
417
+ const layers = [];
418
+ let current = element;
419
+ let opaque = false;
420
+ while (current !== null) {
421
+ const channels = getComputedStyle(current).backgroundColor.match(/\d+(?:\.\d+)?/g);
422
+ if (channels !== null && channels.length >= 3) {
423
+ const layerAlpha = channels[3] === void 0 ? 1 : Number(channels[3]);
424
+ if (layerAlpha > 0) layers.push([...channels.slice(0, 3).map(Number), layerAlpha]);
425
+ if (layerAlpha >= 1) {
426
+ opaque = true;
427
+ break;
428
+ }
429
+ }
430
+ current = current.parentElement;
431
+ }
432
+ if (layers.length === 0) throw new Error("Computed background color is unavailable");
433
+ const base = layers[layers.length - 1];
434
+ if (base === void 0) throw new Error("Computed background color is unavailable");
435
+ let composed = base.slice(0, 3).map((channel) => channel / 255);
436
+ if (!opaque) composed = [
437
+ 1,
438
+ 1,
439
+ 1
440
+ ];
441
+ const start = opaque ? layers.length - 2 : layers.length - 1;
442
+ for (let index = start; index >= 0; index -= 1) {
443
+ const layer = layers[index];
444
+ if (layer === void 0) continue;
445
+ const layerAlpha = layer[3] ?? 1;
446
+ composed = composed.map((channel, position) => {
447
+ return (layer[position] ?? 0) / 255 * layerAlpha + channel * (1 - layerAlpha);
448
+ });
449
+ }
450
+ const alpha = foreground[3] === void 0 ? 1 : Number(foreground[3]);
451
+ const backgroundChannels = composed;
452
+ const foregroundLinear = foreground.slice(0, 3).map((channel, index) => {
453
+ const behind = backgroundChannels[index];
454
+ if (behind === void 0) throw new Error("Computed background channel is unavailable");
455
+ return Number(channel) / 255 * alpha + behind * (1 - alpha);
456
+ }).map((channel) => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
457
+ const backgroundLinear = backgroundChannels.map((channel) => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
458
+ const foregroundLuminance = .2126 * (foregroundLinear[0] ?? 0) + .7152 * (foregroundLinear[1] ?? 0) + .0722 * (foregroundLinear[2] ?? 0);
459
+ const backgroundLuminance = .2126 * (backgroundLinear[0] ?? 0) + .7152 * (backgroundLinear[1] ?? 0) + .0722 * (backgroundLinear[2] ?? 0);
460
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
461
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
462
+ return (lighter + .05) / (darker + .05);
463
+ }
464
+ /**
465
+ * Collects every class token the stylesheets loaded into this document actually define.
466
+ *
467
+ * @returns The set of class names reachable in the shipped cascade.
468
+ *
469
+ * @remarks
470
+ * The set is what an authored-class conformance check measures against, so a class no loaded
471
+ * stylesheet defines — an invented utility, a misspelled framework name — is absent from it.
472
+ *
473
+ * @example
474
+ * ```ts
475
+ * readCascade().has('card')
476
+ * ```
477
+ */
478
+ function readCascade() {
479
+ const known = /* @__PURE__ */ new Set();
480
+ const rules = [];
481
+ for (const sheet of document.styleSheets) rules.push(...sheet.cssRules);
482
+ while (rules.length > 0) {
483
+ const rule = rules.pop();
484
+ if (rule instanceof CSSGroupingRule) rules.push(...rule.cssRules);
485
+ if (!(rule instanceof CSSStyleRule)) continue;
486
+ for (const match of rule.selectorText.matchAll(/\.([a-zA-Z][\w-]*)/g)) known.add(String(match[1]));
487
+ }
488
+ return known;
489
+ }
490
+ /**
491
+ * Reads the normalized visible text of every element a selector matches, in document order.
492
+ *
493
+ * @param root - The subtree to search.
494
+ * @param selector - The CSS selector naming the rows.
495
+ * @returns One line per matched element, its text runs collapsed and single-space joined.
496
+ *
497
+ * @remarks
498
+ * The line is built from the row's text nodes rather than from `textContent`, because adjacent
499
+ * inline elements carry no whitespace between them in compiled template output and would otherwise
500
+ * read as one run-together word.
501
+ *
502
+ * @example
503
+ * ```ts
504
+ * readRows(container, 'li')
505
+ * ```
506
+ */
507
+ function readRows(root, selector) {
508
+ const rows = [];
509
+ for (const row of root.querySelectorAll(selector)) {
510
+ const parts = [];
511
+ const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
512
+ while (walker.nextNode() !== null) {
513
+ const text = (walker.currentNode.textContent ?? "").replaceAll(/\s+/g, " ").trim();
514
+ if (text !== "") parts.push(text);
515
+ }
516
+ rows.push(parts.join(" "));
517
+ }
518
+ return rows;
519
+ }
520
+ /**
521
+ * Reads one resolved CSS property from a real browser element.
522
+ *
523
+ * @param element - The element whose resolved style to inspect.
524
+ * @param property - The CSS property name.
525
+ * @returns The browser's resolved property value.
526
+ *
527
+ * @example
528
+ * ```ts
529
+ * style(button, 'padding-left')
530
+ * ```
531
+ */
532
+ function style(element, property) {
533
+ return getComputedStyle(element).getPropertyValue(property);
534
+ }
535
+ /**
536
+ * Expands a capture registry across every variant into the filenames a complete portfolio holds.
537
+ *
538
+ * @param states - The registered state names.
539
+ * @param variants - The variants the portfolio is rendered in.
540
+ * @returns One `<state>--<variant>.png` name per pair, each state's variants together, in registry
541
+ * order.
542
+ *
543
+ * @remarks
544
+ * The expansion is the portfolio's own definition of complete, so a duplicate in it is a registry
545
+ * defect a proof reads directly rather than a collision discovered on disk.
546
+ *
547
+ * @example
548
+ * ```ts
549
+ * expandCaptures(['start'], [{ name: 'dark-390', width: 390, height: 844 }])
550
+ * // ['start--dark-390.png']
551
+ * ```
552
+ */
553
+ function expandCaptures(states, variants) {
554
+ const files = [];
555
+ for (const state of states) for (const variant of variants) files.push(`${state}--${variant.name}.png`);
556
+ return files;
557
+ }
558
+ //#endregion
559
+ //#region src/browser/factories.ts
560
+ /**
561
+ * Creates the capture portfolio one run places its screenshots through.
562
+ *
563
+ * @param options - The state registry, the variant matrix, the variant this run renders, the
564
+ * directory it writes into, and whether it writes at all.
565
+ * @returns The portfolio: its registry expansion, what it has placed, and `place`.
566
+ * @throws When no registered variant carries the name `variant` names.
567
+ *
568
+ * @remarks
569
+ * A disabled portfolio is the ordinary run. `place` then resizes nothing, writes nothing, and
570
+ * records nothing, so a journey calls it unconditionally and a suite with the flag unset pays for
571
+ * none of it. The portfolio refuses an unregistered variant at creation. An enabled run refuses an
572
+ * unregistered state name and a second placement of one state.
573
+ *
574
+ * @example
575
+ * ```ts
576
+ * const portfolio = createPortfolio({
577
+ * states: ['start-empty'],
578
+ * variants: [{ name: 'dark-390', width: 390, height: 844 }],
579
+ * variant: 'dark-390',
580
+ * directory: '../../tmp/capture/states',
581
+ * })
582
+ * await portfolio.place('start-empty')
583
+ * ```
584
+ */
585
+ function createPortfolio(options) {
586
+ const selected = options.variants.find((candidate) => candidate.name === options.variant);
587
+ if (selected === void 0) throw new Error(`Capture variant "${options.variant}" is not registered`);
588
+ const registry = [...options.states];
589
+ const files = expandCaptures(registry, options.variants);
590
+ const enabled = options.enabled ?? false;
591
+ const placed = [];
592
+ const paths = [];
593
+ return {
594
+ variant: options.variant,
595
+ files,
596
+ get states() {
597
+ return [...placed];
598
+ },
599
+ get paths() {
600
+ return [...paths];
601
+ },
602
+ async place(state) {
603
+ if (!enabled) return void 0;
604
+ if (!registry.includes(state)) throw new Error(`Capture state "${state}" is not registered`);
605
+ if (placed.includes(state)) throw new Error(`Capture state "${state}" is already placed`);
606
+ const file = `${state}--${options.variant}.png`;
607
+ selected.apply?.();
608
+ if (window.innerWidth !== selected.width || window.innerHeight !== selected.height) await page.viewport(selected.width, selected.height);
609
+ const written = await page.screenshot({ path: `${options.directory}/${file}` });
610
+ placed.push(state);
611
+ paths.push(written);
612
+ return written;
613
+ }
614
+ };
615
+ }
616
+ //#endregion
617
+ export { ACCESSIBLE_ROLES, clickAccessible, clickAccessibleWithin, clickDisclosure, contrast, createPortfolio, expandCaptures, fillAccessible, isOutsideViewport, pressKeys, readCascade, readFocus, readPage, readPerception, readRows, readValue, render, resolveAccessible, resolveRendered, style, traverseAccessible, typeAccessible, waitForFrame };
618
+
619
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/browser/constants.ts","../../../src/browser/helpers.ts","../../../src/browser/factories.ts"],"sourcesContent":["/**\n * The interactive ARIA roles a bare accessible name is searched across.\n *\n * @remarks\n * A person names a control, not a role, so the one-argument resolver searches every role a control\n * can compute. The two-argument form searches exactly the role it is given, which is how a name\n * shared by a tab and its panel is disambiguated.\n */\nexport const ACCESSIBLE_ROLES: readonly string[] = Object.freeze([\n\t'button',\n\t'checkbox',\n\t'combobox',\n\t'link',\n\t'listbox',\n\t'menuitem',\n\t'option',\n\t'radio',\n\t'searchbox',\n\t'slider',\n\t'spinbutton',\n\t'switch',\n\t'tab',\n\t'tabpanel',\n\t'textbox',\n\t'treeitem',\n])\n","import type { CaptureVariant } from './types.js'\nimport { page, userEvent } from 'vitest/browser'\nimport { ACCESSIBLE_ROLES } from './constants.js'\n\n/**\n * Determines whether a rectangle lies wholly outside the browser viewport.\n *\n * @param rectangle - The measured client rectangle to inspect.\n * @returns `true` when no part of the rectangle intersects the viewport.\n *\n * @example\n * ```ts\n * isOutsideViewport(element.getBoundingClientRect())\n * ```\n */\nexport function isOutsideViewport(rectangle: DOMRectReadOnly): boolean {\n\treturn (\n\t\trectangle.bottom <= 0 ||\n\t\trectangle.right <= 0 ||\n\t\trectangle.top >= window.innerHeight ||\n\t\trectangle.left >= window.innerWidth\n\t)\n}\n\n/**\n * Resolves one rendered, focus-reachable interactive element without requiring it to intersect the\n * viewport yet.\n *\n * @param first - The accessible name, or the exact ARIA role when `second` is present.\n * @param second - The accessible name when `first` supplies the role.\n * @returns The one rendered element carrying that name and optional role.\n * @throws When no matching element exists, every match is hidden or unreachable, or several\n * rendered matches make the name ambiguous.\n *\n * @remarks\n * This is the resolver the acting verbs use, so a click does not fail on a target the act itself\n * scrolls into view. Use {@link resolveAccessible} wherever the target must already be on screen.\n *\n * @example\n * ```ts\n * resolveRendered('tab', 'Drafts')\n * ```\n */\nexport function resolveRendered(first: string, second?: string): HTMLElement {\n\tconst name = second ?? first\n\tconst roles = second === undefined ? ACCESSIBLE_ROLES : [first]\n\tconst matches: HTMLElement[] = []\n\tfor (const role of roles) {\n\t\tfor (const element of page\n\t\t\t.getByRole(role, { name, exact: true, includeHidden: true })\n\t\t\t.elements()) {\n\t\t\tif (element instanceof HTMLElement && !matches.includes(element)) matches.push(element)\n\t\t}\n\t}\n\tif (matches.length === 0) {\n\t\tthrow new Error(`No interactive element has the accessible name \"${name}\"`)\n\t}\n\tconst reachable = matches.filter((element) => {\n\t\tconst rectangle = element.getBoundingClientRect()\n\t\treturn (\n\t\t\telement.isConnected &&\n\t\t\telement.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) &&\n\t\t\trectangle.width > 0 &&\n\t\t\trectangle.height > 0 &&\n\t\t\telement.tabIndex >= 0 &&\n\t\t\t!element.matches(':disabled, [aria-disabled=\"true\"]') &&\n\t\t\telement.closest('[inert]') === null\n\t\t)\n\t})\n\tif (reachable.length === 0) {\n\t\tthrow new Error(`Interactive target \"${name}\" is not visible and focus-reachable`)\n\t}\n\tif (reachable.length > 1) {\n\t\tthrow new Error(`Interactive target \"${name}\" is ambiguous across ${reachable.length} elements`)\n\t}\n\tconst [target] = reachable\n\tif (target === undefined) throw new Error(`Interactive target \"${name}\" could not be resolved`)\n\treturn target\n}\n\n/**\n * Resolves one visible, focus-reachable interactive element by its exact accessible name. A\n * wholly-off-viewport target is scrolled into view before reachability is measured.\n *\n * @param name - The accessible name rendered for the target.\n * @returns The one reachable element carrying that name.\n * @throws When no matching element exists; every match is disconnected, hidden, zero-sized, still\n * outside the viewport after being scrolled into view, removed from sequential focus, disabled, or\n * inside an inert subtree; or several reachable matches make the name ambiguous.\n *\n * @example\n * ```ts\n * resolveAccessible('Save changes')\n * ```\n */\nexport function resolveAccessible(name: string): HTMLElement\n/**\n * Resolves one visible, focus-reachable interactive element by its exact ARIA role and accessible\n * name, disambiguating a bare name that answers for more than one rendered element. A\n * wholly-off-viewport target is scrolled into view before reachability is measured.\n *\n * @param role - The element's exact ARIA role.\n * @param name - The accessible name rendered for the target.\n * @returns The one reachable element carrying that role and name.\n * @throws When no matching element exists; every match is disconnected, hidden, zero-sized, still\n * outside the viewport after being scrolled into view, removed from sequential focus, disabled, or\n * inside an inert subtree; or several reachable matches make the role/name pair ambiguous.\n *\n * @example\n * ```ts\n * resolveAccessible('tab', 'Drafts')\n * ```\n */\nexport function resolveAccessible(role: string, name: string): HTMLElement\nexport function resolveAccessible(first: string, second?: string): HTMLElement {\n\tconst target = resolveRendered(first, second)\n\tlet rectangle = target.getBoundingClientRect()\n\tif (isOutsideViewport(rectangle)) {\n\t\ttarget.scrollIntoView({ block: 'nearest', behavior: 'instant' })\n\t\trectangle = target.getBoundingClientRect()\n\t}\n\tif (isOutsideViewport(rectangle)) {\n\t\tthrow new Error(`Interactive target \"${second ?? first}\" is unreachable after scrolling`)\n\t}\n\treturn target\n}\n\n/**\n * Clicks one visible, focus-reachable control by its accessible name through the browser provider.\n *\n * @param name - The target's exact accessible name.\n * @returns A promise resolving after trusted activation completes.\n *\n * @example\n * ```ts\n * await clickAccessible('Apply')\n * ```\n */\nexport async function clickAccessible(name: string): Promise<void>\n/**\n * Clicks one visible, focus-reachable control by its exact ARIA role and accessible name,\n * disambiguating a bare name that answers for more than one rendered element.\n *\n * @param role - The control's exact ARIA role.\n * @param name - The target's exact accessible name.\n * @returns A promise resolving after trusted activation completes.\n *\n * @example\n * ```ts\n * await clickAccessible('tab', 'Drafts')\n * ```\n */\nexport async function clickAccessible(role: string, name: string): Promise<void>\nexport async function clickAccessible(first: string, second?: string): Promise<void> {\n\tconst target = resolveRendered(first, second)\n\tawait userEvent.click(target)\n}\n\n/**\n * Clicks one human-reachable control by role and accessible-name text inside a named region.\n *\n * @param region - The containing region's exact accessible name.\n * @param role - The control's exact ARIA role.\n * @param name - The rendered accessible-name text that identifies the control in that region.\n * @returns A promise resolving after trusted activation completes.\n * @throws When the named control is absent, unreachable, or ambiguous inside the region.\n *\n * @remarks\n * Use this form when repeated short verbs such as `Add`, or a line whose status completes its\n * accessible name, need the same region context a person uses to disambiguate them.\n *\n * @example\n * ```ts\n * await clickAccessibleWithin('Ledger', 'button', 'Monthly income')\n * ```\n */\nexport async function clickAccessibleWithin(\n\tregion: string,\n\trole: string,\n\tname: string,\n): Promise<void> {\n\tconst matches = page\n\t\t.getByRole('region', { name: region, exact: true })\n\t\t.getByRole(role, { name, exact: false, includeHidden: true })\n\t\t.elements()\n\tconst reachable = matches.filter((element) => {\n\t\tif (!(element instanceof HTMLElement)) return false\n\t\tconst rectangle = element.getBoundingClientRect()\n\t\treturn (\n\t\t\telement.isConnected &&\n\t\t\telement.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) &&\n\t\t\trectangle.width > 0 &&\n\t\t\trectangle.height > 0 &&\n\t\t\telement.tabIndex >= 0 &&\n\t\t\t!element.matches(':disabled, [aria-disabled=\"true\"]') &&\n\t\t\telement.closest('[inert]') === null\n\t\t)\n\t})\n\tif (reachable.length === 0) {\n\t\tthrow new Error(`Interactive target \"${name}\" is not reachable inside \"${region}\"`)\n\t}\n\tif (reachable.length > 1) {\n\t\tthrow new Error(\n\t\t\t`Interactive target \"${name}\" is ambiguous across ${reachable.length} elements inside \"${region}\"`,\n\t\t)\n\t}\n\tconst [target] = reachable\n\tif (!(target instanceof HTMLElement)) {\n\t\tthrow new Error(`Interactive target \"${name}\" could not be resolved inside \"${region}\"`)\n\t}\n\tawait userEvent.click(target)\n}\n\n/**\n * Opens or closes one native details disclosure by its rendered summary.\n *\n * @param name - The summary text a person reads.\n * @returns A promise resolving after trusted activation completes.\n * @throws When no visible, focus-reachable native summary has that rendered name, or several do.\n *\n * @remarks\n * Chromium exposes `<summary>` as a native disclosure rather than through an ARIA role accepted by\n * `getByRole`, so this resolver names the platform element and its rendered text directly.\n *\n * @example\n * ```ts\n * await clickDisclosure('Advanced')\n * ```\n */\nexport async function clickDisclosure(name: string): Promise<void> {\n\tconst matches = [...document.querySelectorAll('summary')].filter(\n\t\t(element) => element.innerText.replaceAll(/\\s+/g, ' ').trim() === name,\n\t)\n\tconst reachable = matches.filter((element) => {\n\t\tconst rectangle = element.getBoundingClientRect()\n\t\treturn (\n\t\t\telement.isConnected &&\n\t\t\telement.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) &&\n\t\t\trectangle.width > 0 &&\n\t\t\trectangle.height > 0 &&\n\t\t\telement.tabIndex >= 0 &&\n\t\t\telement.closest('[inert]') === null\n\t\t)\n\t})\n\tif (reachable.length === 0) {\n\t\tthrow new Error(`Native disclosure \"${name}\" is not visible and focus-reachable`)\n\t}\n\tif (reachable.length > 1) {\n\t\tthrow new Error(`Native disclosure \"${name}\" is ambiguous across ${reachable.length} elements`)\n\t}\n\tconst [target] = reachable\n\tif (target === undefined) throw new Error(`Native disclosure \"${name}\" could not be resolved`)\n\tawait userEvent.click(target)\n}\n\n/**\n * Replaces a named field's value through focus, select-all, deletion, and real keystrokes.\n *\n * @param name - The field's exact accessible name.\n * @param text - The text to type.\n * @returns A promise resolving after every keystroke completes.\n *\n * @example\n * ```ts\n * await typeAccessible('Runs', '3')\n * ```\n */\nexport async function typeAccessible(name: string, text: string): Promise<void> {\n\tawait userEvent.click(resolveRendered(name))\n\tawait userEvent.keyboard('{Control>}a{/Control}{Backspace}')\n\tif (text === '') return\n\tawait userEvent.keyboard(text.replaceAll('{', '{{').replaceAll('[', '[['))\n}\n\n/**\n * Replaces a named field's value in one operation, for text too long to type key by key.\n *\n * @param name - The field's exact accessible name.\n * @param text - The text to place in the field.\n * @returns A promise resolving after the browser commits the value.\n *\n * @remarks\n * The provider drives the real element, so the field publishes the same input event a person's\n * typing publishes. Use {@link typeAccessible} wherever the keystrokes themselves are the subject.\n *\n * @example\n * ```ts\n * await fillAccessible('Payload', '{\"status\":\"ready\"}')\n * ```\n */\nexport async function fillAccessible(name: string, text: string): Promise<void> {\n\tawait userEvent.fill(resolveRendered(name), text)\n}\n\n/**\n * Presses a browser-keyboard sequence using Vitest's installed user-event syntax.\n *\n * @param keys - The keys or key descriptors to press.\n * @returns A promise resolving after the sequence completes.\n *\n * @example\n * ```ts\n * await pressKeys('{ArrowRight}{Enter}')\n * ```\n */\nexport async function pressKeys(keys: string): Promise<void> {\n\tawait userEvent.keyboard(keys)\n}\n\n/**\n * Reaches a named control only through natural forward Tab traversal from the current focus.\n *\n * @param name - The target's exact accessible name.\n * @returns The target after the browser moves focus to it.\n * @throws When one complete traversal cannot reach the target.\n *\n * @example\n * ```ts\n * await traverseAccessible('Evaluate')\n * ```\n */\nexport async function traverseAccessible(name: string): Promise<HTMLElement> {\n\tresolveRendered(name)\n\t// Two facts shape the loop. A Tab pressed before the page has real input focus moves nothing,\n\t// so a step counts only when focus actually lands somewhere; the traversal is over when focus\n\t// revisits an element, because that is one full cycle of the tab order. And the target is\n\t// re-resolved on every step, because a framework may replace the node between resolution and\n\t// focus arrival: the person's target is the role and name, never one node.\n\tconst cap =\n\t\tdocument.querySelectorAll<HTMLElement>('a[href], button, input, select, textarea, [tabindex]')\n\t\t\t.length *\n\t\t\t3 +\n\t\t10\n\tconst visited = new Set<Element>()\n\tconst trail: string[] = []\n\tfor (let attempt = 0; attempt < cap; attempt += 1) {\n\t\tawait userEvent.tab()\n\t\tconst focused = document.activeElement\n\t\tif (!(focused instanceof HTMLElement) || focused === document.body) continue\n\t\tlet current: HTMLElement | undefined\n\t\ttry {\n\t\t\tcurrent = resolveRendered(name)\n\t\t} catch {\n\t\t\tcontinue\n\t\t}\n\t\tif (focused === current) return current\n\t\tif (visited.has(focused)) break\n\t\tvisited.add(focused)\n\t\ttrail.push(`${focused.tagName}:${focused.innerText.slice(0, 20)}`)\n\t}\n\tthrow new Error(\n\t\t`Interactive target \"${name}\" is not reachable through forward Tab traversal: ${trail.join(' > ')}`,\n\t)\n}\n\n/**\n * Reads the normalized visible text of one named region, dialog, table, tab panel, or alert.\n *\n * @param name - The region's exact accessible name.\n * @returns The text a screen reader can perceive in the visible region, including descendant\n * visually-hidden content.\n * @throws When the named region is absent, hidden, or ambiguous.\n *\n * @example\n * ```ts\n * readPerception('Run')\n * ```\n */\nexport function readPerception(name: string): string {\n\tconst matches: HTMLElement[] = []\n\tfor (const role of ['alert', 'alertdialog', 'dialog', 'region', 'status', 'table', 'tabpanel']) {\n\t\tfor (const element of page\n\t\t\t.getByRole(role, { name, exact: true, includeHidden: true })\n\t\t\t.elements()) {\n\t\t\tif (element instanceof HTMLElement && !matches.includes(element)) matches.push(element)\n\t\t}\n\t}\n\tconst visible = matches.filter((element) => {\n\t\tconst rectangle = element.getBoundingClientRect()\n\t\treturn (\n\t\t\telement.isConnected &&\n\t\t\telement.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) &&\n\t\t\trectangle.width > 0 &&\n\t\t\trectangle.height > 0\n\t\t)\n\t})\n\tif (visible.length === 0) throw new Error(`Named region \"${name}\" is not visible`)\n\tif (visible.length > 1) {\n\t\tthrow new Error(`Named region \"${name}\" is ambiguous across ${visible.length} elements`)\n\t}\n\tconst [region] = visible\n\tif (region === undefined) throw new Error(`Named region \"${name}\" could not be resolved`)\n\treturn region.innerText.replaceAll(/\\s+/g, ' ').trim()\n}\n\n/**\n * Reads the normalized visible text of the whole page.\n *\n * @returns Every rendered word in the document body, its whitespace runs collapsed and trimmed.\n *\n * @remarks\n * This is the reader for a sentence that spans two regions and for a vocabulary sweep over the\n * words an interface uses. Reach for {@link readPerception} wherever one named region is the\n * subject, because that one throws when the region is missing and this one returns whatever is\n * there.\n *\n * @example\n * ```ts\n * readPage().includes('No cases yet')\n * ```\n */\nexport function readPage(): string {\n\treturn document.body.innerText.replaceAll(/\\s+/g, ' ').trim()\n}\n\n/**\n * Reads the rendered text of the element that currently holds focus.\n *\n * @returns The focused HTML element's trimmed rendered text, including an empty string, or\n * `undefined` when focus rests on a non-HTML element. When nothing holds focus, the browser\n * reports the document body as active, so the whole page's rendered text returns.\n *\n * @example\n * ```ts\n * await traverseAccessible('Evaluate')\n * readFocus() // 'Evaluate'\n * ```\n */\nexport function readFocus(): string | undefined {\n\tconst focused = document.activeElement\n\treturn focused instanceof HTMLElement ? focused.innerText.trim() : undefined\n}\n\n/**\n * Reads the value a resolved control renders.\n *\n * @param role - The control's exact ARIA role.\n * @param name - The control's exact accessible name.\n * @returns The control's current value.\n * @throws When the target does not resolve, or resolves to an element that carries no value.\n *\n * @remarks\n * A control's value is a rendered fact a person can read, not internal state, so it is read from\n * the resolved element rather than from the component that produced it.\n *\n * @example\n * ```ts\n * readValue('spinbutton', 'Runs') // '3'\n * ```\n */\nexport function readValue(role: string, name: string): string {\n\tconst control = resolveAccessible(role, name)\n\tif (\n\t\t!(control instanceof HTMLInputElement) &&\n\t\t!(control instanceof HTMLTextAreaElement) &&\n\t\t!(control instanceof HTMLSelectElement)\n\t) {\n\t\tthrow new Error(`Interactive target \"${name}\" does not carry a value`)\n\t}\n\treturn control.value\n}\n\n/**\n * Waits for one animation frame to settle pending browser paint work.\n *\n * @returns A promise resolving after one `requestAnimationFrame`.\n *\n * @example\n * ```ts\n * await waitForFrame()\n * ```\n */\nexport function waitForFrame(): Promise<void> {\n\treturn new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n}\n\n/**\n * Renders trusted fixture markup into a container attached to the document.\n *\n * @param markup - The fixture markup to render.\n * @returns The attached container.\n *\n * @example\n * ```ts\n * const container = render('<button type=\"button\">Save</button>')\n * container.remove()\n * ```\n */\nexport function render(markup: string): HTMLDivElement {\n\tconst container = document.createElement('div')\n\tcontainer.innerHTML = markup\n\tdocument.body.append(container)\n\treturn container\n}\n\n/**\n * Measures the WCAG 2.x contrast ratio between an element's computed text and background colors.\n *\n * @param element - The element whose rendered text contrast to measure.\n * @returns The relative-luminance contrast ratio.\n * @throws When the browser does not expose parseable computed colors.\n *\n * @remarks\n * A transparent or translucent background resolves through the element's ancestors: every painted\n * layer from the element up to the first opaque one composites top-over-bottom onto that opaque\n * base, so a 3% surface tint reads as a tint over what shows through it rather than as a\n * full-strength paint. A translucent foreground then resolves against that effective background\n * before luminance is measured.\n *\n * Every element from the target upwards must be reachable, and at least one of them must paint:\n * the measurement throws rather than assuming a white canvas when nothing in the chain declares a\n * background color. The element itself must expose a computed foreground color — a detached\n * element exposes none, and the measurement throws rather than guessing one.\n *\n * @example\n * ```ts\n * const container = render('<p style=\"background: #000; color: #fff\">Ready</p>')\n * contrast(requireValue(container.firstElementChild)) // 21\n * ```\n */\nexport function contrast(element: Element): number {\n\tconst foreground = getComputedStyle(element).color.match(/\\d+(?:\\.\\d+)?/g)\n\tif (foreground === null || foreground.length < 3) {\n\t\tthrow new Error('Computed foreground color is unavailable')\n\t}\n\tconst layers: number[][] = []\n\tlet current: Element | null = element\n\tlet opaque = false\n\twhile (current !== null) {\n\t\tconst channels = getComputedStyle(current).backgroundColor.match(/\\d+(?:\\.\\d+)?/g)\n\t\tif (channels !== null && channels.length >= 3) {\n\t\t\tconst layerAlpha = channels[3] === undefined ? 1 : Number(channels[3])\n\t\t\tif (layerAlpha > 0) {\n\t\t\t\tlayers.push([...channels.slice(0, 3).map(Number), layerAlpha])\n\t\t\t}\n\t\t\tif (layerAlpha >= 1) {\n\t\t\t\topaque = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tcurrent = current.parentElement\n\t}\n\tif (layers.length === 0) throw new Error('Computed background color is unavailable')\n\tconst base = layers[layers.length - 1]\n\tif (base === undefined) throw new Error('Computed background color is unavailable')\n\tlet composed = base.slice(0, 3).map((channel) => channel / 255)\n\tif (!opaque) composed = [1, 1, 1]\n\tconst start = opaque ? layers.length - 2 : layers.length - 1\n\tfor (let index = start; index >= 0; index -= 1) {\n\t\tconst layer = layers[index]\n\t\tif (layer === undefined) continue\n\t\tconst layerAlpha = layer[3] ?? 1\n\t\tcomposed = composed.map((channel, position) => {\n\t\t\tconst top = (layer[position] ?? 0) / 255\n\t\t\treturn top * layerAlpha + channel * (1 - layerAlpha)\n\t\t})\n\t}\n\n\tconst alpha = foreground[3] === undefined ? 1 : Number(foreground[3])\n\tconst backgroundChannels = composed\n\tconst foregroundChannels = foreground.slice(0, 3).map((channel, index) => {\n\t\tconst behind = backgroundChannels[index]\n\t\tif (behind === undefined) throw new Error('Computed background channel is unavailable')\n\t\treturn (Number(channel) / 255) * alpha + behind * (1 - alpha)\n\t})\n\tconst foregroundLinear = foregroundChannels.map((channel) =>\n\t\tchannel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,\n\t)\n\tconst backgroundLinear = backgroundChannels.map((channel) =>\n\t\tchannel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,\n\t)\n\tconst foregroundLuminance =\n\t\t0.2126 * (foregroundLinear[0] ?? 0) +\n\t\t0.7152 * (foregroundLinear[1] ?? 0) +\n\t\t0.0722 * (foregroundLinear[2] ?? 0)\n\tconst backgroundLuminance =\n\t\t0.2126 * (backgroundLinear[0] ?? 0) +\n\t\t0.7152 * (backgroundLinear[1] ?? 0) +\n\t\t0.0722 * (backgroundLinear[2] ?? 0)\n\tconst lighter = Math.max(foregroundLuminance, backgroundLuminance)\n\tconst darker = Math.min(foregroundLuminance, backgroundLuminance)\n\treturn (lighter + 0.05) / (darker + 0.05)\n}\n\n/**\n * Collects every class token the stylesheets loaded into this document actually define.\n *\n * @returns The set of class names reachable in the shipped cascade.\n *\n * @remarks\n * The set is what an authored-class conformance check measures against, so a class no loaded\n * stylesheet defines — an invented utility, a misspelled framework name — is absent from it.\n *\n * @example\n * ```ts\n * readCascade().has('card')\n * ```\n */\nexport function readCascade(): ReadonlySet<string> {\n\tconst known = new Set<string>()\n\tconst rules: CSSRule[] = []\n\tfor (const sheet of document.styleSheets) rules.push(...sheet.cssRules)\n\twhile (rules.length > 0) {\n\t\tconst rule = rules.pop()\n\t\tif (rule instanceof CSSGroupingRule) rules.push(...rule.cssRules)\n\t\tif (!(rule instanceof CSSStyleRule)) continue\n\t\tfor (const match of rule.selectorText.matchAll(/\\.([a-zA-Z][\\w-]*)/g)) {\n\t\t\tknown.add(String(match[1]))\n\t\t}\n\t}\n\treturn known\n}\n\n/**\n * Reads the normalized visible text of every element a selector matches, in document order.\n *\n * @param root - The subtree to search.\n * @param selector - The CSS selector naming the rows.\n * @returns One line per matched element, its text runs collapsed and single-space joined.\n *\n * @remarks\n * The line is built from the row's text nodes rather than from `textContent`, because adjacent\n * inline elements carry no whitespace between them in compiled template output and would otherwise\n * read as one run-together word.\n *\n * @example\n * ```ts\n * readRows(container, 'li')\n * ```\n */\nexport function readRows(root: ParentNode, selector: string): readonly string[] {\n\tconst rows: string[] = []\n\tfor (const row of root.querySelectorAll(selector)) {\n\t\tconst parts: string[] = []\n\t\tconst walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT)\n\t\twhile (walker.nextNode() !== null) {\n\t\t\tconst text = (walker.currentNode.textContent ?? '').replaceAll(/\\s+/g, ' ').trim()\n\t\t\tif (text !== '') parts.push(text)\n\t\t}\n\t\trows.push(parts.join(' '))\n\t}\n\treturn rows\n}\n\n/**\n * Reads one resolved CSS property from a real browser element.\n *\n * @param element - The element whose resolved style to inspect.\n * @param property - The CSS property name.\n * @returns The browser's resolved property value.\n *\n * @example\n * ```ts\n * style(button, 'padding-left')\n * ```\n */\nexport function style(element: Element, property: string): string {\n\treturn getComputedStyle(element).getPropertyValue(property)\n}\n\n/**\n * Expands a capture registry across every variant into the filenames a complete portfolio holds.\n *\n * @param states - The registered state names.\n * @param variants - The variants the portfolio is rendered in.\n * @returns One `<state>--<variant>.png` name per pair, each state's variants together, in registry\n * order.\n *\n * @remarks\n * The expansion is the portfolio's own definition of complete, so a duplicate in it is a registry\n * defect a proof reads directly rather than a collision discovered on disk.\n *\n * @example\n * ```ts\n * expandCaptures(['start'], [{ name: 'dark-390', width: 390, height: 844 }])\n * // ['start--dark-390.png']\n * ```\n */\nexport function expandCaptures(\n\tstates: readonly string[],\n\tvariants: readonly CaptureVariant[],\n): readonly string[] {\n\tconst files: string[] = []\n\tfor (const state of states) {\n\t\tfor (const variant of variants) files.push(`${state}--${variant.name}.png`)\n\t}\n\treturn files\n}\n","import type { PortfolioInterface, PortfolioOptions } from './types.js'\nimport { page } from 'vitest/browser'\nimport { expandCaptures } from './helpers.js'\n\n/**\n * Creates the capture portfolio one run places its screenshots through.\n *\n * @param options - The state registry, the variant matrix, the variant this run renders, the\n * directory it writes into, and whether it writes at all.\n * @returns The portfolio: its registry expansion, what it has placed, and `place`.\n * @throws When no registered variant carries the name `variant` names.\n *\n * @remarks\n * A disabled portfolio is the ordinary run. `place` then resizes nothing, writes nothing, and\n * records nothing, so a journey calls it unconditionally and a suite with the flag unset pays for\n * none of it. The portfolio refuses an unregistered variant at creation. An enabled run refuses an\n * unregistered state name and a second placement of one state.\n *\n * @example\n * ```ts\n * const portfolio = createPortfolio({\n * \tstates: ['start-empty'],\n * \tvariants: [{ name: 'dark-390', width: 390, height: 844 }],\n * \tvariant: 'dark-390',\n * \tdirectory: '../../tmp/capture/states',\n * })\n * await portfolio.place('start-empty')\n * ```\n */\nexport function createPortfolio(options: PortfolioOptions): PortfolioInterface {\n\tconst selected = options.variants.find((candidate) => candidate.name === options.variant)\n\tif (selected === undefined) {\n\t\tthrow new Error(`Capture variant \"${options.variant}\" is not registered`)\n\t}\n\tconst registry = [...options.states]\n\tconst files = expandCaptures(registry, options.variants)\n\tconst enabled = options.enabled ?? false\n\tconst placed: string[] = []\n\tconst paths: string[] = []\n\treturn {\n\t\tvariant: options.variant,\n\t\tfiles,\n\t\tget states() {\n\t\t\treturn [...placed]\n\t\t},\n\t\tget paths() {\n\t\t\treturn [...paths]\n\t\t},\n\t\tasync place(state) {\n\t\t\tif (!enabled) return undefined\n\t\t\tif (!registry.includes(state)) {\n\t\t\t\tthrow new Error(`Capture state \"${state}\" is not registered`)\n\t\t\t}\n\t\t\tif (placed.includes(state)) {\n\t\t\t\tthrow new Error(`Capture state \"${state}\" is already placed`)\n\t\t\t}\n\t\t\tconst file = `${state}--${options.variant}.png`\n\t\t\tselected.apply?.()\n\t\t\tif (window.innerWidth !== selected.width || window.innerHeight !== selected.height) {\n\t\t\t\tawait page.viewport(selected.width, selected.height)\n\t\t\t}\n\t\t\tconst written = await page.screenshot({ path: `${options.directory}/${file}` })\n\t\t\tplaced.push(state)\n\t\t\tpaths.push(written)\n\t\t\treturn written\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;;;;;AAQA,IAAa,mBAAsC,OAAO,OAAO;CAChE;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACD,CAAC;;;;;;;;;;;;;;ACVD,SAAgB,kBAAkB,WAAqC;CACtE,OACC,UAAU,UAAU,KACpB,UAAU,SAAS,KACnB,UAAU,OAAO,OAAO,eACxB,UAAU,QAAQ,OAAO;AAE3B;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,gBAAgB,OAAe,QAA8B;CAC5E,MAAM,OAAO,UAAU;CACvB,MAAM,QAAQ,WAAW,KAAA,IAAY,mBAAmB,CAAC,KAAK;CAC9D,MAAM,UAAyB,CAAC;CAChC,KAAK,MAAM,QAAQ,OAClB,KAAK,MAAM,WAAW,KACpB,UAAU,MAAM;EAAE;EAAM,OAAO;EAAM,eAAe;CAAK,CAAC,CAAC,CAC3D,SAAS,GACV,IAAI,mBAAmB,eAAe,CAAC,QAAQ,SAAS,OAAO,GAAG,QAAQ,KAAK,OAAO;CAGxF,IAAI,QAAQ,WAAW,GACtB,MAAM,IAAI,MAAM,mDAAmD,KAAK,EAAE;CAE3E,MAAM,YAAY,QAAQ,QAAQ,YAAY;EAC7C,MAAM,YAAY,QAAQ,sBAAsB;EAChD,OACC,QAAQ,eACR,QAAQ,gBAAgB;GAAE,cAAc;GAAM,oBAAoB;EAAK,CAAC,KACxE,UAAU,QAAQ,KAClB,UAAU,SAAS,KACnB,QAAQ,YAAY,KACpB,CAAC,QAAQ,QAAQ,qCAAmC,KACpD,QAAQ,QAAQ,SAAS,MAAM;CAEjC,CAAC;CACD,IAAI,UAAU,WAAW,GACxB,MAAM,IAAI,MAAM,uBAAuB,KAAK,qCAAqC;CAElF,IAAI,UAAU,SAAS,GACtB,MAAM,IAAI,MAAM,uBAAuB,KAAK,wBAAwB,UAAU,OAAO,UAAU;CAEhG,MAAM,CAAC,UAAU;CACjB,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,uBAAuB,KAAK,wBAAwB;CAC9F,OAAO;AACR;AAoCA,SAAgB,kBAAkB,OAAe,QAA8B;CAC9E,MAAM,SAAS,gBAAgB,OAAO,MAAM;CAC5C,IAAI,YAAY,OAAO,sBAAsB;CAC7C,IAAI,kBAAkB,SAAS,GAAG;EACjC,OAAO,eAAe;GAAE,OAAO;GAAW,UAAU;EAAU,CAAC;EAC/D,YAAY,OAAO,sBAAsB;CAC1C;CACA,IAAI,kBAAkB,SAAS,GAC9B,MAAM,IAAI,MAAM,uBAAuB,UAAU,MAAM,iCAAiC;CAEzF,OAAO;AACR;AA4BA,eAAsB,gBAAgB,OAAe,QAAgC;CACpF,MAAM,SAAS,gBAAgB,OAAO,MAAM;CAC5C,MAAM,UAAU,MAAM,MAAM;AAC7B;;;;;;;;;;;;;;;;;;;AAoBA,eAAsB,sBACrB,QACA,MACA,MACgB;CAKhB,MAAM,YAJU,KACd,UAAU,UAAU;EAAE,MAAM;EAAQ,OAAO;CAAK,CAAC,CAAC,CAClD,UAAU,MAAM;EAAE;EAAM,OAAO;EAAO,eAAe;CAAK,CAAC,CAAC,CAC5D,SACgB,CAAA,CAAQ,QAAQ,YAAY;EAC7C,IAAI,EAAE,mBAAmB,cAAc,OAAO;EAC9C,MAAM,YAAY,QAAQ,sBAAsB;EAChD,OACC,QAAQ,eACR,QAAQ,gBAAgB;GAAE,cAAc;GAAM,oBAAoB;EAAK,CAAC,KACxE,UAAU,QAAQ,KAClB,UAAU,SAAS,KACnB,QAAQ,YAAY,KACpB,CAAC,QAAQ,QAAQ,qCAAmC,KACpD,QAAQ,QAAQ,SAAS,MAAM;CAEjC,CAAC;CACD,IAAI,UAAU,WAAW,GACxB,MAAM,IAAI,MAAM,uBAAuB,KAAK,6BAA6B,OAAO,EAAE;CAEnF,IAAI,UAAU,SAAS,GACtB,MAAM,IAAI,MACT,uBAAuB,KAAK,wBAAwB,UAAU,OAAO,oBAAoB,OAAO,EACjG;CAED,MAAM,CAAC,UAAU;CACjB,IAAI,EAAE,kBAAkB,cACvB,MAAM,IAAI,MAAM,uBAAuB,KAAK,kCAAkC,OAAO,EAAE;CAExF,MAAM,UAAU,MAAM,MAAM;AAC7B;;;;;;;;;;;;;;;;;AAkBA,eAAsB,gBAAgB,MAA6B;CAIlE,MAAM,YAHU,CAAC,GAAG,SAAS,iBAAiB,SAAS,CAAC,CAAC,CAAC,QACxD,YAAY,QAAQ,UAAU,WAAW,QAAQ,GAAG,CAAC,CAAC,KAAK,MAAM,IAEjD,CAAA,CAAQ,QAAQ,YAAY;EAC7C,MAAM,YAAY,QAAQ,sBAAsB;EAChD,OACC,QAAQ,eACR,QAAQ,gBAAgB;GAAE,cAAc;GAAM,oBAAoB;EAAK,CAAC,KACxE,UAAU,QAAQ,KAClB,UAAU,SAAS,KACnB,QAAQ,YAAY,KACpB,QAAQ,QAAQ,SAAS,MAAM;CAEjC,CAAC;CACD,IAAI,UAAU,WAAW,GACxB,MAAM,IAAI,MAAM,sBAAsB,KAAK,qCAAqC;CAEjF,IAAI,UAAU,SAAS,GACtB,MAAM,IAAI,MAAM,sBAAsB,KAAK,wBAAwB,UAAU,OAAO,UAAU;CAE/F,MAAM,CAAC,UAAU;CACjB,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,sBAAsB,KAAK,wBAAwB;CAC7F,MAAM,UAAU,MAAM,MAAM;AAC7B;;;;;;;;;;;;;AAcA,eAAsB,eAAe,MAAc,MAA6B;CAC/E,MAAM,UAAU,MAAM,gBAAgB,IAAI,CAAC;CAC3C,MAAM,UAAU,SAAS,kCAAkC;CAC3D,IAAI,SAAS,IAAI;CACjB,MAAM,UAAU,SAAS,KAAK,WAAW,KAAK,IAAI,CAAC,CAAC,WAAW,KAAK,IAAI,CAAC;AAC1E;;;;;;;;;;;;;;;;;AAkBA,eAAsB,eAAe,MAAc,MAA6B;CAC/E,MAAM,UAAU,KAAK,gBAAgB,IAAI,GAAG,IAAI;AACjD;;;;;;;;;;;;AAaA,eAAsB,UAAU,MAA6B;CAC5D,MAAM,UAAU,SAAS,IAAI;AAC9B;;;;;;;;;;;;;AAcA,eAAsB,mBAAmB,MAAoC;CAC5E,gBAAgB,IAAI;CAMpB,MAAM,MACL,SAAS,iBAA8B,sDAAsD,CAAC,CAC5F,SACD,IACD;CACD,MAAM,0BAAU,IAAI,IAAa;CACjC,MAAM,QAAkB,CAAC;CACzB,KAAK,IAAI,UAAU,GAAG,UAAU,KAAK,WAAW,GAAG;EAClD,MAAM,UAAU,IAAI;EACpB,MAAM,UAAU,SAAS;EACzB,IAAI,EAAE,mBAAmB,gBAAgB,YAAY,SAAS,MAAM;EACpE,IAAI;EACJ,IAAI;GACH,UAAU,gBAAgB,IAAI;EAC/B,QAAQ;GACP;EACD;EACA,IAAI,YAAY,SAAS,OAAO;EAChC,IAAI,QAAQ,IAAI,OAAO,GAAG;EAC1B,QAAQ,IAAI,OAAO;EACnB,MAAM,KAAK,GAAG,QAAQ,QAAQ,GAAG,QAAQ,UAAU,MAAM,GAAG,EAAE,GAAG;CAClE;CACA,MAAM,IAAI,MACT,uBAAuB,KAAK,oDAAoD,MAAM,KAAK,KAAK,GACjG;AACD;;;;;;;;;;;;;;AAeA,SAAgB,eAAe,MAAsB;CACpD,MAAM,UAAyB,CAAC;CAChC,KAAK,MAAM,QAAQ;EAAC;EAAS;EAAe;EAAU;EAAU;EAAU;EAAS;CAAU,GAC5F,KAAK,MAAM,WAAW,KACpB,UAAU,MAAM;EAAE;EAAM,OAAO;EAAM,eAAe;CAAK,CAAC,CAAC,CAC3D,SAAS,GACV,IAAI,mBAAmB,eAAe,CAAC,QAAQ,SAAS,OAAO,GAAG,QAAQ,KAAK,OAAO;CAGxF,MAAM,UAAU,QAAQ,QAAQ,YAAY;EAC3C,MAAM,YAAY,QAAQ,sBAAsB;EAChD,OACC,QAAQ,eACR,QAAQ,gBAAgB;GAAE,cAAc;GAAM,oBAAoB;EAAK,CAAC,KACxE,UAAU,QAAQ,KAClB,UAAU,SAAS;CAErB,CAAC;CACD,IAAI,QAAQ,WAAW,GAAG,MAAM,IAAI,MAAM,iBAAiB,KAAK,iBAAiB;CACjF,IAAI,QAAQ,SAAS,GACpB,MAAM,IAAI,MAAM,iBAAiB,KAAK,wBAAwB,QAAQ,OAAO,UAAU;CAExF,MAAM,CAAC,UAAU;CACjB,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,iBAAiB,KAAK,wBAAwB;CACxF,OAAO,OAAO,UAAU,WAAW,QAAQ,GAAG,CAAC,CAAC,KAAK;AACtD;;;;;;;;;;;;;;;;;AAkBA,SAAgB,WAAmB;CAClC,OAAO,SAAS,KAAK,UAAU,WAAW,QAAQ,GAAG,CAAC,CAAC,KAAK;AAC7D;;;;;;;;;;;;;;AAeA,SAAgB,YAAgC;CAC/C,MAAM,UAAU,SAAS;CACzB,OAAO,mBAAmB,cAAc,QAAQ,UAAU,KAAK,IAAI,KAAA;AACpE;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,UAAU,MAAc,MAAsB;CAC7D,MAAM,UAAU,kBAAkB,MAAM,IAAI;CAC5C,IACC,EAAE,mBAAmB,qBACrB,EAAE,mBAAmB,wBACrB,EAAE,mBAAmB,oBAErB,MAAM,IAAI,MAAM,uBAAuB,KAAK,yBAAyB;CAEtE,OAAO,QAAQ;AAChB;;;;;;;;;;;AAYA,SAAgB,eAA8B;CAC7C,OAAO,IAAI,SAAe,YAAY,4BAA4B,QAAQ,CAAC,CAAC;AAC7E;;;;;;;;;;;;;AAcA,SAAgB,OAAO,QAAgC;CACtD,MAAM,YAAY,SAAS,cAAc,KAAK;CAC9C,UAAU,YAAY;CACtB,SAAS,KAAK,OAAO,SAAS;CAC9B,OAAO;AACR;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,SAAS,SAA0B;CAClD,MAAM,aAAa,iBAAiB,OAAO,CAAC,CAAC,MAAM,MAAM,gBAAgB;CACzE,IAAI,eAAe,QAAQ,WAAW,SAAS,GAC9C,MAAM,IAAI,MAAM,0CAA0C;CAE3D,MAAM,SAAqB,CAAC;CAC5B,IAAI,UAA0B;CAC9B,IAAI,SAAS;CACb,OAAO,YAAY,MAAM;EACxB,MAAM,WAAW,iBAAiB,OAAO,CAAC,CAAC,gBAAgB,MAAM,gBAAgB;EACjF,IAAI,aAAa,QAAQ,SAAS,UAAU,GAAG;GAC9C,MAAM,aAAa,SAAS,OAAO,KAAA,IAAY,IAAI,OAAO,SAAS,EAAE;GACrE,IAAI,aAAa,GAChB,OAAO,KAAK,CAAC,GAAG,SAAS,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,MAAM,GAAG,UAAU,CAAC;GAE9D,IAAI,cAAc,GAAG;IACpB,SAAS;IACT;GACD;EACD;EACA,UAAU,QAAQ;CACnB;CACA,IAAI,OAAO,WAAW,GAAG,MAAM,IAAI,MAAM,0CAA0C;CACnF,MAAM,OAAO,OAAO,OAAO,SAAS;CACpC,IAAI,SAAS,KAAA,GAAW,MAAM,IAAI,MAAM,0CAA0C;CAClF,IAAI,WAAW,KAAK,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,YAAY,UAAU,GAAG;CAC9D,IAAI,CAAC,QAAQ,WAAW;EAAC;EAAG;EAAG;CAAC;CAChC,MAAM,QAAQ,SAAS,OAAO,SAAS,IAAI,OAAO,SAAS;CAC3D,KAAK,IAAI,QAAQ,OAAO,SAAS,GAAG,SAAS,GAAG;EAC/C,MAAM,QAAQ,OAAO;EACrB,IAAI,UAAU,KAAA,GAAW;EACzB,MAAM,aAAa,MAAM,MAAM;EAC/B,WAAW,SAAS,KAAK,SAAS,aAAa;GAE9C,QADa,MAAM,aAAa,KAAK,MACxB,aAAa,WAAW,IAAI;EAC1C,CAAC;CACF;CAEA,MAAM,QAAQ,WAAW,OAAO,KAAA,IAAY,IAAI,OAAO,WAAW,EAAE;CACpE,MAAM,qBAAqB;CAM3B,MAAM,mBALqB,WAAW,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,SAAS,UAAU;EACzE,MAAM,SAAS,mBAAmB;EAClC,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,4CAA4C;EACtF,OAAQ,OAAO,OAAO,IAAI,MAAO,QAAQ,UAAU,IAAI;CACxD,CACyB,CAAA,CAAmB,KAAK,YAChD,WAAW,SAAU,UAAU,UAAU,UAAU,QAAS,UAAU,GACvE;CACA,MAAM,mBAAmB,mBAAmB,KAAK,YAChD,WAAW,SAAU,UAAU,UAAU,UAAU,QAAS,UAAU,GACvE;CACA,MAAM,sBACL,SAAU,iBAAiB,MAAM,KACjC,SAAU,iBAAiB,MAAM,KACjC,SAAU,iBAAiB,MAAM;CAClC,MAAM,sBACL,SAAU,iBAAiB,MAAM,KACjC,SAAU,iBAAiB,MAAM,KACjC,SAAU,iBAAiB,MAAM;CAClC,MAAM,UAAU,KAAK,IAAI,qBAAqB,mBAAmB;CACjE,MAAM,SAAS,KAAK,IAAI,qBAAqB,mBAAmB;CAChE,QAAQ,UAAU,QAAS,SAAS;AACrC;;;;;;;;;;;;;;;AAgBA,SAAgB,cAAmC;CAClD,MAAM,wBAAQ,IAAI,IAAY;CAC9B,MAAM,QAAmB,CAAC;CAC1B,KAAK,MAAM,SAAS,SAAS,aAAa,MAAM,KAAK,GAAG,MAAM,QAAQ;CACtE,OAAO,MAAM,SAAS,GAAG;EACxB,MAAM,OAAO,MAAM,IAAI;EACvB,IAAI,gBAAgB,iBAAiB,MAAM,KAAK,GAAG,KAAK,QAAQ;EAChE,IAAI,EAAE,gBAAgB,eAAe;EACrC,KAAK,MAAM,SAAS,KAAK,aAAa,SAAS,qBAAqB,GACnE,MAAM,IAAI,OAAO,MAAM,EAAE,CAAC;CAE5B;CACA,OAAO;AACR;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,SAAS,MAAkB,UAAqC;CAC/E,MAAM,OAAiB,CAAC;CACxB,KAAK,MAAM,OAAO,KAAK,iBAAiB,QAAQ,GAAG;EAClD,MAAM,QAAkB,CAAC;EACzB,MAAM,SAAS,SAAS,iBAAiB,KAAK,WAAW,SAAS;EAClE,OAAO,OAAO,SAAS,MAAM,MAAM;GAClC,MAAM,QAAQ,OAAO,YAAY,eAAe,GAAA,CAAI,WAAW,QAAQ,GAAG,CAAC,CAAC,KAAK;GACjF,IAAI,SAAS,IAAI,MAAM,KAAK,IAAI;EACjC;EACA,KAAK,KAAK,MAAM,KAAK,GAAG,CAAC;CAC1B;CACA,OAAO;AACR;;;;;;;;;;;;;AAcA,SAAgB,MAAM,SAAkB,UAA0B;CACjE,OAAO,iBAAiB,OAAO,CAAC,CAAC,iBAAiB,QAAQ;AAC3D;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,eACf,QACA,UACoB;CACpB,MAAM,QAAkB,CAAC;CACzB,KAAK,MAAM,SAAS,QACnB,KAAK,MAAM,WAAW,UAAU,MAAM,KAAK,GAAG,MAAM,IAAI,QAAQ,KAAK,KAAK;CAE3E,OAAO;AACR;;;;;;;;;;;;;;;;;;;;;;;;;;;;AClpBA,SAAgB,gBAAgB,SAA+C;CAC9E,MAAM,WAAW,QAAQ,SAAS,MAAM,cAAc,UAAU,SAAS,QAAQ,OAAO;CACxF,IAAI,aAAa,KAAA,GAChB,MAAM,IAAI,MAAM,oBAAoB,QAAQ,QAAQ,oBAAoB;CAEzE,MAAM,WAAW,CAAC,GAAG,QAAQ,MAAM;CACnC,MAAM,QAAQ,eAAe,UAAU,QAAQ,QAAQ;CACvD,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,SAAmB,CAAC;CAC1B,MAAM,QAAkB,CAAC;CACzB,OAAO;EACN,SAAS,QAAQ;EACjB;EACA,IAAI,SAAS;GACZ,OAAO,CAAC,GAAG,MAAM;EAClB;EACA,IAAI,QAAQ;GACX,OAAO,CAAC,GAAG,KAAK;EACjB;EACA,MAAM,MAAM,OAAO;GAClB,IAAI,CAAC,SAAS,OAAO,KAAA;GACrB,IAAI,CAAC,SAAS,SAAS,KAAK,GAC3B,MAAM,IAAI,MAAM,kBAAkB,MAAM,oBAAoB;GAE7D,IAAI,OAAO,SAAS,KAAK,GACxB,MAAM,IAAI,MAAM,kBAAkB,MAAM,oBAAoB;GAE7D,MAAM,OAAO,GAAG,MAAM,IAAI,QAAQ,QAAQ;GAC1C,SAAS,QAAQ;GACjB,IAAI,OAAO,eAAe,SAAS,SAAS,OAAO,gBAAgB,SAAS,QAC3E,MAAM,KAAK,SAAS,SAAS,OAAO,SAAS,MAAM;GAEpD,MAAM,UAAU,MAAM,KAAK,WAAW,EAAE,MAAM,GAAG,QAAQ,UAAU,GAAG,OAAO,CAAC;GAC9E,OAAO,KAAK,KAAK;GACjB,MAAM,KAAK,OAAO;GAClB,OAAO;EACR;CACD;AACD"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@orkestrel/test",
3
- "version": "0.0.5",
4
- "description": "The test helpers the Orkestrel fleet repeats — a call recorder, a real delay, JSON and async collectors, and an owned scratch directory with a source-file walker. Zero runtime dependencies. Part of the @orkestrel line.",
3
+ "version": "0.0.6",
4
+ "description": "The test helpers the Orkestrel fleet repeats — a call recorder, a real delay, JSON and async collectors, an owned scratch directory with a source-file walker, and a browser journey layer that drives real interfaces by role and accessible name. Zero runtime dependencies. Part of the @orkestrel line.",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/orkestrel/test#readme",
7
7
  "bugs": "https://github.com/orkestrel/test/issues",
@@ -29,6 +29,12 @@
29
29
  "default": "./dist/src/core/index.cjs"
30
30
  }
31
31
  },
32
+ "./browser": {
33
+ "import": {
34
+ "types": "./dist/src/browser/index.d.ts",
35
+ "default": "./dist/src/browser/index.js"
36
+ }
37
+ },
32
38
  "./server": {
33
39
  "import": {
34
40
  "types": "./dist/src/server/index.d.ts",
@@ -52,35 +58,43 @@
52
58
  "lint": "oxlint --config .oxlintrc.json --fix .",
53
59
  "lint:check": "oxlint --config .oxlintrc.json --deny-warnings .",
54
60
  "check": "tsc --noEmit --project tsconfig.json && npm run check:src",
55
- "check:src": "npm run check:src:core && npm run check:src:server",
61
+ "check:src": "npm run check:src:core && npm run check:src:browser && npm run check:src:server",
56
62
  "check:src:core": "tsc --noEmit -p configs/src/tsconfig.core.json",
63
+ "check:src:browser": "tsc --noEmit -p configs/src/tsconfig.browser.json",
57
64
  "check:src:server": "tsc --noEmit -p configs/src/tsconfig.server.json",
58
65
  "test": "npm run test:src && npm run test:policy && npm run test:config && npm run test:guides",
59
- "test:src": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core --project src:server",
66
+ "test:src": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core --project src:browser --project src:server",
60
67
  "test:src:core": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core",
68
+ "test:src:browser": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:browser",
61
69
  "test:src:server": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:server",
62
70
  "test:policy": "vitest run --config vite.config.ts --no-cache --reporter=dot --project policy",
63
71
  "test:config": "vitest run --config vite.config.ts --no-cache --reporter=dot --project config",
64
72
  "test:guides": "vitest run --config vite.config.ts --no-cache --reporter=dot --project guides",
65
73
  "test:probe": "vitest run --config vite.config.ts --no-cache --reporter=verbose --project probe",
66
74
  "build": "npm run clean && npm run build:src",
67
- "build:src": "npm run build:src:core && npm run build:src:server",
75
+ "build:src": "npm run build:src:core && npm run build:src:browser && npm run build:src:server",
68
76
  "build:src:core": "vite build --config configs/src/vite.core.config.ts && npm run copy dist/src/core/index.d.ts dist/src/core/index.d.cts",
77
+ "build:src:browser": "vite build --config configs/src/vite.browser.config.ts",
69
78
  "build:src:server": "vite build --config configs/src/vite.server.config.ts && npm run copy dist/src/server/index.d.ts dist/src/server/index.d.cts",
70
79
  "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test"
71
80
  },
72
81
  "devDependencies": {
73
82
  "@microsoft/api-extractor": "^7.58.12",
74
- "@orkestrel/guide": "^0.0.10",
75
- "@orkestrel/scaffold": "^0.0.30",
83
+ "@orkestrel/guide": "^0.0.11",
84
+ "@orkestrel/scaffold": "^0.0.38",
76
85
  "@types/node": "^26.2.0",
86
+ "@vitest/browser-playwright": "^4.1.10",
77
87
  "oxfmt": "^0.62.0",
78
88
  "oxlint": "^1.77.0",
89
+ "playwright": "^1.62.1",
79
90
  "typescript": "^6.0.3",
80
91
  "vite": "~8.2.0",
81
92
  "vite-plugin-dts": "^5.0.3",
82
93
  "vitest": "^4.1.10"
83
94
  },
95
+ "peerDependencies": {
96
+ "vitest": "^4.1.10"
97
+ },
84
98
  "engines": {
85
99
  "node": ">=22.12.0"
86
100
  }