@orkestrel/test 0.0.7 → 0.0.8

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.
@@ -1,4 +1,4 @@
1
- import { page, userEvent } from "vitest/browser";
1
+ import { commands, page, userEvent } from "vitest/browser";
2
2
  //#region src/browser/constants.ts
3
3
  /**
4
4
  * The interactive ARIA roles a bare accessible name is searched across.
@@ -26,6 +26,151 @@ var ACCESSIBLE_ROLES = Object.freeze([
26
26
  "textbox",
27
27
  "treeitem"
28
28
  ]);
29
+ /**
30
+ * The page a browser paints an unstyled document onto.
31
+ *
32
+ * @remarks
33
+ * This is the floor a backdrop walk ends on wherever the caller wants the browser's own canvas
34
+ * assumed. `readBackdrop` takes its floor as an argument rather than reaching for this one, so a
35
+ * measurement over a surface the canvas never shows through names the color it actually sits on.
36
+ */
37
+ var CANVAS_COLOR = Object.freeze([
38
+ 255,
39
+ 255,
40
+ 255,
41
+ 1
42
+ ]);
43
+ /**
44
+ * The attribute marking the runner's tester pane, and the rule that sizes it, while a frame is
45
+ * staged.
46
+ *
47
+ * @remarks
48
+ * `stagePane` writes it onto the pane and onto the stylesheet it appends, and `releasePane` finds
49
+ * both by it. Nothing else reads it, so a document carrying it after a capture returned is a pane
50
+ * that was never released.
51
+ */
52
+ var CAPTURE_PANE = "data-capture-pane";
53
+ /**
54
+ * The roles whose accessible name is the text a reader can see inside them.
55
+ *
56
+ * @remarks
57
+ * `readName` reads an element in this list from its own rendered text, after every `aria-hidden`
58
+ * descendant is dropped, and falls through to `title` for every other role.
59
+ */
60
+ var CONTENT_ROLES = Object.freeze([
61
+ "button",
62
+ "cell",
63
+ "columnheader",
64
+ "heading",
65
+ "link",
66
+ "listitem",
67
+ "option",
68
+ "row",
69
+ "rowheader",
70
+ "tab"
71
+ ]);
72
+ /**
73
+ * The role each `input` type carries.
74
+ *
75
+ * @remarks
76
+ * Membership is the contract. The map answers for `button`, `checkbox`, `email`, `number`,
77
+ * `password`, `radio`, `range`, `reset`, `search`, `submit`, `tel`, `text`, and `url`. A type the
78
+ * map omits — `color`, `date`, `file`, `hidden`, and the rest — exposes no role of its own, so
79
+ * `readRole` returns `undefined` for it and `describeTree` writes no line for it.
80
+ */
81
+ var FIELD_ROLES = Object.freeze({
82
+ button: "button",
83
+ checkbox: "checkbox",
84
+ email: "textbox",
85
+ number: "spinbutton",
86
+ password: "textbox",
87
+ radio: "radio",
88
+ range: "slider",
89
+ reset: "button",
90
+ search: "searchbox",
91
+ submit: "button",
92
+ tel: "textbox",
93
+ text: "textbox",
94
+ url: "textbox"
95
+ });
96
+ /**
97
+ * What sequential keyboard navigation can reach, before disabled and unrendered elements go.
98
+ *
99
+ * @remarks
100
+ * `describeFocus` queries this selector and then drops what a browser drops: an element the
101
+ * accessibility tree does not present, a disabled control, and one removed from the sequence by
102
+ * `tabindex="-1"`. `traverseAccessible` counts the same population to bound its walk, so this is
103
+ * the one list either one reads.
104
+ */
105
+ var FOCUSABLE_SELECTOR = "a[href], area[href], button, input, select, summary, textarea, [tabindex]";
106
+ /**
107
+ * The role a `th` carries for the header axis its `scope` names.
108
+ *
109
+ * @remarks
110
+ * A header cell heads a column or a row, and this map answers for the `col` and `row` scopes that
111
+ * say which. A `th` declaring no scope keeps {@link IMPLICIT_ROLES}' `columnheader` rather than the
112
+ * ARIA computation that infers the axis from the table's shape.
113
+ */
114
+ var HEADER_ROLES = Object.freeze({
115
+ col: "columnheader",
116
+ row: "rowheader"
117
+ });
118
+ /**
119
+ * The role each listed tag carries in the accessibility tree when it declares none of its own.
120
+ *
121
+ * @remarks
122
+ * Membership is the contract. The map answers for the sectioning elements `ARTICLE`, `ASIDE`,
123
+ * `FOOTER`, `HEADER`, `MAIN`, `NAV`, `SEARCH`, and `SECTION`; the headings `H1` through `H6`; the
124
+ * grouping and list elements `FIELDSET`, `FORM`, `HR`, `LI`, `OL`, and `UL`; the table elements
125
+ * `TABLE`, `TBODY`, `THEAD`, `TR`, `TD`, and `TH`; and the widgets `BUTTON`, `DIALOG`, `IMG`,
126
+ * `OPTION`, `OUTPUT`, `PROGRESS`, `SUMMARY`, and `TEXTAREA`.
127
+ *
128
+ * A tag the map omits carries no implicit role, so `readRole` returns `undefined` for it,
129
+ * `describeTree` writes no line for it, and the walk continues straight into its children at the
130
+ * depth the omitted element sat at. `A`, `INPUT`, and `SELECT` are absent deliberately: each takes
131
+ * its role from an attribute rather than from its tag, and `readRole` answers for them from their
132
+ * own anatomy.
133
+ *
134
+ * `SECTION` maps to `region`, which `readRole` withholds from an unnamed one, because an unnamed
135
+ * section is not a landmark. `TH` maps to `columnheader`, which {@link HEADER_ROLES} replaces when
136
+ * the cell declares a `scope`.
137
+ */
138
+ var IMPLICIT_ROLES = Object.freeze({
139
+ ARTICLE: "article",
140
+ ASIDE: "complementary",
141
+ BUTTON: "button",
142
+ DIALOG: "dialog",
143
+ FIELDSET: "group",
144
+ FOOTER: "contentinfo",
145
+ FORM: "form",
146
+ H1: "heading",
147
+ H2: "heading",
148
+ H3: "heading",
149
+ H4: "heading",
150
+ H5: "heading",
151
+ H6: "heading",
152
+ HEADER: "banner",
153
+ HR: "separator",
154
+ IMG: "img",
155
+ LI: "listitem",
156
+ MAIN: "main",
157
+ NAV: "navigation",
158
+ OL: "list",
159
+ OPTION: "option",
160
+ OUTPUT: "status",
161
+ PROGRESS: "progressbar",
162
+ SEARCH: "search",
163
+ SECTION: "region",
164
+ SUMMARY: "button",
165
+ TABLE: "table",
166
+ TBODY: "rowgroup",
167
+ TD: "cell",
168
+ TEXTAREA: "textbox",
169
+ TH: "columnheader",
170
+ THEAD: "rowgroup",
171
+ TR: "row",
172
+ UL: "list"
173
+ });
29
174
  //#endregion
30
175
  //#region src/browser/helpers.ts
31
176
  /**
@@ -43,6 +188,69 @@ function isOutsideViewport(rectangle) {
43
188
  return rectangle.bottom <= 0 || rectangle.right <= 0 || rectangle.top >= window.innerHeight || rectangle.left >= window.innerWidth;
44
189
  }
45
190
  /**
191
+ * Determines whether a person can click one element where it currently sits.
192
+ *
193
+ * @param element - The element to judge.
194
+ * @returns `true` when the element is connected, visible, laid out with a non-zero box, in the
195
+ * sequential focus order, neither disabled nor marked `aria-disabled="true"`, and outside every
196
+ * `[inert]` subtree; `false` otherwise.
197
+ *
198
+ * @remarks
199
+ * This is the one reachability filter the layer applies. `resolveRendered`, `clickAccessibleWithin`,
200
+ * and `clickDisclosure` each narrow their own candidates and then keep the ones this accepts, so a
201
+ * journey meets one rule rather than three near-copies of it.
202
+ *
203
+ * It measures geometry, which is what separates it from {@link isRendered}. A control clipped to a
204
+ * zero-size rectangle is announced and is not clickable, so `isRendered` accepts it and this
205
+ * refuses it. Nothing here asks about the viewport: `resolveAccessible` scrolls a wholly
206
+ * off-viewport target into view and measures that separately with {@link isOutsideViewport}.
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * isReachable(requireValue(container.querySelector('button')))
211
+ * ```
212
+ */
213
+ function isReachable(element) {
214
+ if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) return false;
215
+ const rectangle = element.getBoundingClientRect();
216
+ return element.isConnected && element.checkVisibility({
217
+ checkOpacity: true,
218
+ checkVisibilityCSS: true
219
+ }) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
220
+ }
221
+ /**
222
+ * Determines whether the accessibility tree presents one element at all.
223
+ *
224
+ * @param element - The element to judge.
225
+ * @returns `false` when the element is hidden from assistive technology, from sight, or from both;
226
+ * `true` otherwise.
227
+ *
228
+ * @remarks
229
+ * A control clipped to a zero-size rectangle is still announced, which is the whole point of that
230
+ * idiom, so nothing here reads geometry: only the removals a browser honours — `aria-hidden`
231
+ * anywhere above it, the `hidden` attribute, a hidden input, and a `display` or `visibility` that
232
+ * takes it off the page. {@link isReachable} is the clickable half of the pair and does read
233
+ * geometry.
234
+ *
235
+ * The last two are asked about the element's ancestors as well as itself, which reading a computed
236
+ * `display` cannot do: the computed value of a child of a `display: none` container is the child's
237
+ * own, so a control inside a closed drawer reports itself as laid out. `checkVisibility` answers
238
+ * for the box tree, and `visibility` inherits, so between them an ancestor cannot hide a control
239
+ * from a reader and leave it standing in a description.
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * isRendered(requireValue(container.querySelector('[aria-hidden="true"] button'))) // false
244
+ * ```
245
+ */
246
+ function isRendered(element) {
247
+ if (element.closest("[aria-hidden=\"true\"]") !== null) return false;
248
+ if (element instanceof HTMLElement && element.hidden) return false;
249
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
250
+ if (!element.checkVisibility()) return false;
251
+ return getComputedStyle(element).visibility !== "hidden";
252
+ }
253
+ /**
46
254
  * Resolves one rendered, focus-reachable interactive element without requiring it to intersect the
47
255
  * viewport yet.
48
256
  *
@@ -71,13 +279,7 @@ function resolveRendered(first, second) {
71
279
  includeHidden: true
72
280
  }).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
73
281
  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
- });
282
+ const reachable = matches.filter((element) => isReachable(element));
81
283
  if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not visible and focus-reachable`);
82
284
  if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements`);
83
285
  const [target] = reachable;
@@ -127,14 +329,7 @@ async function clickAccessibleWithin(region, role, name) {
127
329
  name,
128
330
  exact: false,
129
331
  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
- });
332
+ }).elements().filter((element) => element instanceof HTMLElement && isReachable(element));
138
333
  if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not reachable inside "${region}"`);
139
334
  if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements inside "${region}"`);
140
335
  const [target] = reachable;
@@ -146,25 +341,22 @@ async function clickAccessibleWithin(region, role, name) {
146
341
  *
147
342
  * @param name - The summary text a person reads.
148
343
  * @returns A promise resolving after trusted activation completes.
149
- * @throws When no visible, focus-reachable native summary has that rendered name, or several do.
344
+ * @throws When no native summary with that rendered name passes {@link isReachable}, or several do.
150
345
  *
151
346
  * @remarks
152
347
  * Chromium exposes `<summary>` as a native disclosure rather than through an ARIA role accepted by
153
348
  * `getByRole`, so this resolver names the platform element and its rendered text directly.
154
349
  *
350
+ * It applies the same {@link isReachable} filter the other acting verbs apply, so a summary marked
351
+ * `aria-disabled="true"` is refused here exactly as a button marked that way is refused there.
352
+ *
155
353
  * @example
156
354
  * ```ts
157
355
  * await clickDisclosure('Advanced')
158
356
  * ```
159
357
  */
160
358
  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
- });
359
+ const reachable = [...document.querySelectorAll("summary")].filter((element) => element.innerText.replaceAll(/\s+/g, " ").trim() === name).filter((element) => isReachable(element));
168
360
  if (reachable.length === 0) throw new Error(`Native disclosure "${name}" is not visible and focus-reachable`);
169
361
  if (reachable.length > 1) throw new Error(`Native disclosure "${name}" is ambiguous across ${reachable.length} elements`);
170
362
  const [target] = reachable;
@@ -236,7 +428,7 @@ async function pressKeys(keys) {
236
428
  */
237
429
  async function traverseAccessible(name) {
238
430
  resolveRendered(name);
239
- const cap = document.querySelectorAll("a[href], button, input, select, textarea, [tabindex]").length * 3 + 10;
431
+ const cap = document.querySelectorAll(FOCUSABLE_SELECTOR).length * 3 + 10;
240
432
  const visited = /* @__PURE__ */ new Set();
241
433
  const trail = [];
242
434
  for (let attempt = 0; attempt < cap; attempt += 1) {
@@ -356,6 +548,244 @@ function readValue(role, name) {
356
548
  return control.value;
357
549
  }
358
550
  /**
551
+ * Reads one element's rendered text the way a name computation reads it.
552
+ *
553
+ * @param element - The element whose announced words are wanted.
554
+ * @returns The text with every `aria-hidden` descendant dropped and whitespace runs collapsed.
555
+ *
556
+ * @remarks
557
+ * A glyph marked `aria-hidden` contributes nothing to a name, so a control captioned by an icon
558
+ * plus a word reads as the word alone — which is what a reader hears, and what a verdict citing a
559
+ * description has to compare against the copy a template writes. Reach for `readRows` wherever the
560
+ * subject is what the page paints rather than what it announces: that one keeps the glyph.
561
+ *
562
+ * @example
563
+ * ```ts
564
+ * readText(requireValue(container.querySelector('button'))) // 'Save'
565
+ * ```
566
+ */
567
+ function readText(element) {
568
+ const parts = [];
569
+ const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
570
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
571
+ const owner = node.parentElement;
572
+ if (owner === null || owner.closest("[aria-hidden=\"true\"]") !== null) continue;
573
+ parts.push(node.textContent ?? "");
574
+ }
575
+ return parts.join(" ").replaceAll(/\s+/g, " ").trim();
576
+ }
577
+ /**
578
+ * Reads the role one element carries in the accessibility tree.
579
+ *
580
+ * @param element - The element to classify.
581
+ * @returns The declared role, the implicit one, or `undefined` when the element carries none.
582
+ *
583
+ * @remarks
584
+ * A declared `role` wins outright, and its first token is the answer when several are listed.
585
+ * Otherwise the element's own anatomy decides: an anchor is a link only while it holds an `href`,
586
+ * an `input` takes the role {@link FIELD_ROLES} gives its type, a `select` is a combobox until it
587
+ * offers several rows at once, a `section` is a region only once something names it, and a `th`
588
+ * heads whichever axis its `scope` names. Every other tag answers from {@link IMPLICIT_ROLES},
589
+ * whose membership is the contract for what this can answer at all.
590
+ *
591
+ * @example
592
+ * ```ts
593
+ * readRole(requireValue(container.querySelector('a[href]'))) // 'link'
594
+ * ```
595
+ */
596
+ function readRole(element) {
597
+ const declared = element.getAttribute("role")?.trim();
598
+ if (declared !== void 0 && declared.length > 0) return declared.split(/\s+/)[0];
599
+ if (element instanceof HTMLAnchorElement) return element.href.length > 0 ? "link" : void 0;
600
+ if (element instanceof HTMLInputElement) return FIELD_ROLES[element.type];
601
+ if (element instanceof HTMLSelectElement) return element.multiple || element.size > 1 ? "listbox" : "combobox";
602
+ const implicit = IMPLICIT_ROLES[element.tagName];
603
+ const scope = element.tagName === "TH" ? element.getAttribute("scope")?.trim() : void 0;
604
+ if (scope !== void 0) return HEADER_ROLES[scope] ?? implicit;
605
+ if (implicit === "region" && !element.hasAttribute("aria-label") && !element.hasAttribute("aria-labelledby")) return;
606
+ return implicit;
607
+ }
608
+ /**
609
+ * Reads the accessible name one element is announced under.
610
+ *
611
+ * @param element - The element to name.
612
+ * @returns The computed name, or an empty string when the element carries none.
613
+ *
614
+ * @remarks
615
+ * The order is the one a browser follows: `aria-labelledby`, then `aria-label`, then a form
616
+ * control's own labels, then an image's `alt`, then the text inside a role {@link CONTENT_ROLES}
617
+ * names, then `title`. A submit, reset, or button input is named by its value, because it renders
618
+ * no text to read. An `aria-labelledby` naming several ids joins their texts in the order the
619
+ * attribute lists them, and an id nothing answers for is skipped rather than fatal.
620
+ *
621
+ * Each step answers only when it has something to say, so a step that carries nothing hands the
622
+ * element to the next one. An image whose `alt` is absent or blank is the case that shows it:
623
+ * `<img title="Chart">` is named `Chart` rather than the empty string its own `alt` step would
624
+ * have returned, and an image carrying both keeps answering `alt`.
625
+ *
626
+ * @example
627
+ * ```ts
628
+ * readName(requireValue(container.querySelector('button'))) // 'Save changes'
629
+ * ```
630
+ */
631
+ function readName(element) {
632
+ const referenced = element.getAttribute("aria-labelledby");
633
+ if (referenced !== null) {
634
+ const named = referenced.split(/\s+/).map((id) => element.ownerDocument.getElementById(id)).filter((node) => node !== null).map((node) => readText(node)).filter((text) => text.length > 0);
635
+ if (named.length > 0) return named.join(" ");
636
+ }
637
+ const labelled = element.getAttribute("aria-label")?.trim();
638
+ if (labelled !== void 0 && labelled.length > 0) return labelled;
639
+ if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
640
+ const labels = [...element.labels ?? []].map((label) => readText(label)).filter((text) => text.length > 0);
641
+ if (labels.length > 0) return labels.join(" ");
642
+ if (element instanceof HTMLInputElement && element.value.length > 0) {
643
+ if (FIELD_ROLES[element.type] === "button") return element.value;
644
+ }
645
+ }
646
+ if (element instanceof HTMLImageElement) {
647
+ const alternative = element.alt.trim();
648
+ if (alternative.length > 0) return alternative;
649
+ }
650
+ const role = readRole(element);
651
+ if (role !== void 0 && CONTENT_ROLES.includes(role)) {
652
+ const text = readText(element);
653
+ if (text.length > 0) return text;
654
+ }
655
+ return element.getAttribute("title")?.trim() ?? "";
656
+ }
657
+ /**
658
+ * Reads the states one element is announced in.
659
+ *
660
+ * @param element - The element to read.
661
+ * @returns Every state the element declares, in one fixed order.
662
+ *
663
+ * @remarks
664
+ * A state a reader is told about is one this records: what is unavailable, disclosed, pressed,
665
+ * current, refused, chosen, announcing itself, demanded, uneditable, described, or busy. The order
666
+ * is fixed, so two descriptions of the same surface are comparable line for line.
667
+ *
668
+ * A native disclosure states its expansion on the parent `details` element's own `open` rather than
669
+ * on an ARIA attribute, so a summary that declares no `aria-expanded` is read from the platform's
670
+ * one copy of that fact.
671
+ *
672
+ * @example
673
+ * ```ts
674
+ * readStates(requireValue(container.querySelector('summary'))) // ['collapsed']
675
+ * ```
676
+ */
677
+ function readStates(element) {
678
+ const states = [];
679
+ if (element.matches(":disabled") || element.getAttribute("aria-disabled") === "true") states.push("disabled");
680
+ const expanded = element.getAttribute("aria-expanded");
681
+ if (expanded === "true") states.push("expanded");
682
+ if (expanded === "false") states.push("collapsed");
683
+ if (expanded === null && element.tagName === "SUMMARY" && element.parentElement instanceof HTMLDetailsElement) states.push(element.parentElement.open ? "expanded" : "collapsed");
684
+ const pressed = element.getAttribute("aria-pressed");
685
+ if (pressed !== null) states.push(`pressed=${pressed}`);
686
+ const current = element.getAttribute("aria-current");
687
+ if (current !== null && current !== "false") states.push("current");
688
+ if (element.getAttribute("aria-invalid") === "true") states.push("invalid");
689
+ if (element instanceof HTMLInputElement ? element.checked : element.getAttribute("aria-checked") === "true") states.push("checked");
690
+ const selected = element.getAttribute("aria-selected");
691
+ if (selected !== null) states.push(`selected=${selected}`);
692
+ const live = element.getAttribute("aria-live");
693
+ if (live !== null) states.push(`live=${live}`);
694
+ if (element.matches(":required")) states.push("required");
695
+ if (element instanceof HTMLInputElement && element.readOnly) states.push("readonly");
696
+ if (element.hasAttribute("aria-describedby")) states.push("described");
697
+ if (element.getAttribute("aria-busy") === "true") states.push("busy");
698
+ return Object.freeze(states);
699
+ }
700
+ /**
701
+ * Describes the accessible tree one rendered element presents.
702
+ *
703
+ * @param element - The host to walk, which is described first when it carries a role of its own.
704
+ * @returns One indented line per element carrying a role, naming its role, its name, and its
705
+ * states, in document order; an empty string when nothing in the subtree carries one.
706
+ *
707
+ * @remarks
708
+ * The walk is over the real rendered DOM, so what it reports is the tree the shipped markup and the
709
+ * shipped cascade produce together — a landmark lost to a hidden ancestor is missing here exactly as
710
+ * it is missing for a reader. An element {@link isRendered} refuses is dropped with its whole
711
+ * subtree.
712
+ *
713
+ * Depth follows the roles rather than the elements, so the indentation reads as the structure a
714
+ * screen reader announces instead of as the markup's nesting. An element {@link readRole} answers
715
+ * `undefined` for writes no line and adds no depth, so its children sit where it sat. That is how
716
+ * a wrapper `div` disappears, and it is also how an element {@link IMPLICIT_ROLES} does not answer
717
+ * for disappears — visibly, because its roled children stay at the depth it occupied.
718
+ *
719
+ * @example
720
+ * ```ts
721
+ * describeTree(container)
722
+ * // main "Board"
723
+ * // heading "Totals"
724
+ * ```
725
+ */
726
+ function describeTree(element) {
727
+ const lines = [];
728
+ const pending = [{
729
+ node: element,
730
+ depth: 0
731
+ }];
732
+ while (pending.length > 0) {
733
+ const entry = pending.pop();
734
+ if (entry === void 0) break;
735
+ if (!isRendered(entry.node)) continue;
736
+ const role = readRole(entry.node);
737
+ let depth = entry.depth;
738
+ if (role !== void 0) {
739
+ const name = readName(entry.node);
740
+ const states = readStates(entry.node);
741
+ lines.push(`${" ".repeat(depth)}${role}${name.length > 0 ? ` "${name}"` : ""}${states.length > 0 ? ` [${states.join(", ")}]` : ""}`);
742
+ depth += 1;
743
+ }
744
+ for (let index = entry.node.children.length - 1; index >= 0; index -= 1) {
745
+ const child = entry.node.children[index];
746
+ if (child !== void 0) pending.push({
747
+ node: child,
748
+ depth
749
+ });
750
+ }
751
+ }
752
+ return lines.join("\n");
753
+ }
754
+ /**
755
+ * Describes the order sequential keyboard navigation visits one element's controls in.
756
+ *
757
+ * @param element - The host to walk; its own controls are described, and it is not itself one.
758
+ * @returns One numbered line per reachable control, naming its role and its name.
759
+ *
760
+ * @remarks
761
+ * A positive `tabindex` is honoured, because a browser honours it: those controls come first in
762
+ * ascending order and everything else follows in document order. A control removed from the
763
+ * sequence by `tabindex="-1"`, by being disabled, or by not being rendered at all is absent here,
764
+ * which is the fact a focus-order verdict is about. A control {@link readRole} answers `undefined`
765
+ * for is named by its lowercased tag, so it is still counted rather than silently dropped.
766
+ *
767
+ * @example
768
+ * ```ts
769
+ * describeFocus(container)
770
+ * // 1. button "Save"
771
+ * // 2. link "Cancel"
772
+ * ```
773
+ */
774
+ function describeFocus(element) {
775
+ return [...element.querySelectorAll(FOCUSABLE_SELECTOR)].filter((node) => isRendered(node) && !node.matches(":disabled") && node.getAttribute("tabindex") !== "-1").sort((first, second) => {
776
+ const left = Number.parseInt(first.getAttribute("tabindex") ?? "0", 10);
777
+ const right = Number.parseInt(second.getAttribute("tabindex") ?? "0", 10);
778
+ if (left > 0 && right > 0) return left - right;
779
+ if (left > 0) return -1;
780
+ if (right > 0) return 1;
781
+ return 0;
782
+ }).map((node, index) => {
783
+ const role = readRole(node) ?? node.tagName.toLowerCase();
784
+ const name = readName(node);
785
+ return `${String(index + 1)}. ${role}${name.length > 0 ? ` "${name}"` : ""}`;
786
+ }).join("\n");
787
+ }
788
+ /**
359
789
  * Waits for one animation frame to settle pending browser paint work.
360
790
  *
361
791
  * @returns A promise resolving after one `requestAnimationFrame`.
@@ -387,11 +817,200 @@ function render(markup) {
387
817
  return container;
388
818
  }
389
819
  /**
820
+ * Clears both browser storage surfaces.
821
+ *
822
+ * @remarks
823
+ * A browser test file shares one page, so a key written by one test is read by the next one that
824
+ * looks for it. Call this from an `afterEach` hook, which runs after a failed test as well as a
825
+ * passing one, rather than at the end of each test that happens to write a key.
826
+ *
827
+ * @example
828
+ * ```ts
829
+ * afterEach(clearStorage)
830
+ * ```
831
+ */
832
+ function clearStorage() {
833
+ localStorage.clear();
834
+ sessionStorage.clear();
835
+ }
836
+ /**
837
+ * Parses one computed CSS color value into straight sRGB channels.
838
+ *
839
+ * @param value - A computed `rgb()`, `rgba()`, or `color(srgb …)` value.
840
+ * @returns The color's channels, or `undefined` when the value names no color this reader speaks.
841
+ *
842
+ * @remarks
843
+ * A computed color resolves to `rgb()` or `rgba()` for every legacy source, and a `color-mix()`
844
+ * declaration resolves to `color(srgb r g b [/ a])` with channels on the 0–1 scale. Both forms are
845
+ * read here and nothing else is: a keyword, a hex triple, an empty string from a detached element,
846
+ * and a color space the cascade never hands back all return `undefined`. Absence is the answer
847
+ * rather than a transparent color, so a caller decides what an unreadable value means instead of
848
+ * measuring a black it never saw.
849
+ *
850
+ * @example
851
+ * ```ts
852
+ * parseColor('rgba(255, 255, 255, 0.5)') // [255, 255, 255, 0.5]
853
+ * parseColor('rebeccapurple') // undefined
854
+ * ```
855
+ */
856
+ function parseColor(value) {
857
+ const modern = /^color\(srgb\s+(?<red>[\d.]+)\s+(?<green>[\d.]+)\s+(?<blue>[\d.]+)(?:\s*\/\s*(?<alpha>[\d.]+))?\)$/u.exec(value);
858
+ const legacy = /^rgba?\((?<channels>[^)]*)\)$/u.exec(value);
859
+ const [red, green, blue, alpha = 1] = modern?.groups === void 0 ? (legacy?.groups?.channels ?? "").split(/[\s,/]+/u).filter((part) => part.length > 0).map((part) => Number.parseFloat(part)) : [
860
+ Number.parseFloat(modern.groups.red ?? "") * 255,
861
+ Number.parseFloat(modern.groups.green ?? "") * 255,
862
+ Number.parseFloat(modern.groups.blue ?? "") * 255,
863
+ modern.groups.alpha === void 0 ? 1 : Number.parseFloat(modern.groups.alpha)
864
+ ];
865
+ if (red === void 0 || green === void 0 || blue === void 0) return void 0;
866
+ if (![
867
+ red,
868
+ green,
869
+ blue,
870
+ alpha
871
+ ].every((channel) => Number.isFinite(channel))) return void 0;
872
+ return Object.freeze([
873
+ red,
874
+ green,
875
+ blue,
876
+ alpha
877
+ ]);
878
+ }
879
+ /**
880
+ * Composites one color over another.
881
+ *
882
+ * @param front - The color painted on top.
883
+ * @param back - The color already on the surface.
884
+ * @returns The opaque result a reader sees, its alpha always `1`.
885
+ *
886
+ * @example
887
+ * ```ts
888
+ * blendColor([255, 255, 255, 0.5], [0, 0, 0, 1]) // [127.5, 127.5, 127.5, 1]
889
+ * ```
890
+ */
891
+ function blendColor(front, back) {
892
+ const [red, green, blue, alpha] = front;
893
+ const [under, over, beneath] = back;
894
+ return Object.freeze([
895
+ red * alpha + under * (1 - alpha),
896
+ green * alpha + over * (1 - alpha),
897
+ blue * alpha + beneath * (1 - alpha),
898
+ 1
899
+ ]);
900
+ }
901
+ /**
902
+ * Measures one opaque color's WCAG relative luminance.
903
+ *
904
+ * @param color - The color to weigh. Its alpha is ignored, so composite before calling.
905
+ * @returns The relative luminance, from `0` for black to `1` for white.
906
+ *
907
+ * @example
908
+ * ```ts
909
+ * measureLuminance([255, 255, 255, 1]) // 1
910
+ * ```
911
+ */
912
+ function measureLuminance(color) {
913
+ const [red, green, blue] = color;
914
+ const [first = 0, second = 0, third = 0] = [
915
+ red,
916
+ green,
917
+ blue
918
+ ].map((channel) => {
919
+ const part = channel / 255;
920
+ return part <= .04045 ? part / 12.92 : ((part + .055) / 1.055) ** 2.4;
921
+ });
922
+ return .2126 * first + .7152 * second + .0722 * third;
923
+ }
924
+ /**
925
+ * Measures the WCAG 2.x contrast ratio between two opaque colors.
926
+ *
927
+ * @param front - The foreground color, already composited.
928
+ * @param back - The opaque backdrop.
929
+ * @returns The ratio, from `1` for two identical colors to `21` for black against white.
930
+ *
931
+ * @remarks
932
+ * The ratio is symmetric: the brighter of the two luminances is always the numerator, so swapping
933
+ * the arguments returns the same number.
934
+ *
935
+ * @example
936
+ * ```ts
937
+ * measureContrast([0, 0, 0, 1], [255, 255, 255, 1]) // 21
938
+ * ```
939
+ */
940
+ function measureContrast(front, back) {
941
+ const bright = Math.max(measureLuminance(front), measureLuminance(back));
942
+ const dark = Math.min(measureLuminance(front), measureLuminance(back));
943
+ return (bright + .05) / (dark + .05);
944
+ }
945
+ /**
946
+ * Collects the painted layers standing between one element and the surface it sits on.
947
+ *
948
+ * @param element - The element to walk up from.
949
+ * @returns Every layer the walk paints, the element's own first and the deepest last.
950
+ *
951
+ * @remarks
952
+ * A surface token paints one ancestor while every element between it and the text paints nothing,
953
+ * so a backdrop is found by walking up rather than by reading the element's own `background-color`,
954
+ * which is almost always transparent. A fully transparent layer paints nothing and is left out, and
955
+ * the walk stops at the first fully opaque layer, because nothing above that layer is visible.
956
+ *
957
+ * The stack is what tells a resolved backdrop from an assumed one: the walk reached an opaque
958
+ * surface exactly when its last layer's alpha is `1`. {@link contrast} refuses on that reading,
959
+ * which no comparison of composited colors can replace — 64 half-transparent layers composite to
960
+ * the same channels over opposite floors, because the floor's remaining share falls below the last
961
+ * bit a channel carries.
962
+ *
963
+ * @example
964
+ * ```ts
965
+ * readLayers(requireValue(container.querySelector('p')))
966
+ * ```
967
+ */
968
+ function readLayers(element) {
969
+ const layers = [];
970
+ for (let node = element; node !== null; node = node.parentElement) {
971
+ const layer = parseColor(getComputedStyle(node).backgroundColor);
972
+ if (layer === void 0 || layer[3] === 0) continue;
973
+ layers.push(layer);
974
+ if (layer[3] >= 1) break;
975
+ }
976
+ return Object.freeze(layers);
977
+ }
978
+ /**
979
+ * Resolves the opaque color standing behind one element.
980
+ *
981
+ * @param element - The element whose backdrop to resolve.
982
+ * @param floor - The opaque color the walk ends on when nothing above it paints.
983
+ * @returns The composited color a reader sees behind the element.
984
+ *
985
+ * @remarks
986
+ * The layers {@link readLayers} collects composite top-over-bottom onto the floor, so a 3% surface
987
+ * tint reads as a tint over what shows through it rather than as a full-strength paint.
988
+ *
989
+ * The floor is required, because this leaf never guesses what a document sits on. Pass
990
+ * {@link CANVAS_COLOR} for the page a browser paints behind an unstyled document, or the color of
991
+ * the surface a fragment is really rendered into. When no layer paints, the floor is returned by
992
+ * identity.
993
+ *
994
+ * The composite alone never says whether the floor is part of the answer. A caller that must know
995
+ * reads the stack instead.
996
+ *
997
+ * @example
998
+ * ```ts
999
+ * readBackdrop(requireValue(container.querySelector('p')), CANVAS_COLOR)
1000
+ * ```
1001
+ */
1002
+ function readBackdrop(element, floor) {
1003
+ return readLayers(element).reduceRight((back, front) => blendColor(front, back), floor);
1004
+ }
1005
+ /**
390
1006
  * Measures the WCAG 2.x contrast ratio between an element's computed text and background colors.
391
1007
  *
392
1008
  * @param element - The element whose rendered text contrast to measure.
1009
+ * @param floor - The opaque color the backdrop walk ends on. Omit it to refuse a stack the floor
1010
+ * would show through instead of assuming one.
393
1011
  * @returns The relative-luminance contrast ratio.
394
- * @throws When the browser does not expose parseable computed colors.
1012
+ * @throws Thrown when the element exposes no computed foreground color, and — with `floor` omitted
1013
+ * — when the walk from the element upwards reaches no opaque layer.
395
1014
  *
396
1015
  * @remarks
397
1016
  * A transparent or translucent background resolves through the element's ancestors: every painted
@@ -400,66 +1019,85 @@ function render(markup) {
400
1019
  * full-strength paint. A translucent foreground then resolves against that effective background
401
1020
  * before luminance is measured.
402
1021
  *
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.
1022
+ * With `floor` omitted, the walk from the target upwards must reach a fully opaque layer: the
1023
+ * measurement throws rather than assuming a white canvas wherever that canvas would still be part
1024
+ * of the answer. The refusal reads the alpha of the deepest layer {@link readLayers} collected, so
1025
+ * a chain that declares no background color at all, a chain painting only translucent layers, and a
1026
+ * chain deep enough for its composite to round to the canvas's own channels are refused alike,
1027
+ * because the number any of them produces is as much a report of the assumption as of the page.
1028
+ * Supply a floor wherever the caller knows what the stack sits on — a fragment mounted into a
1029
+ * painted host, or a document whose canvas is {@link CANVAS_COLOR} — and the composite is taken
1030
+ * over it rather than refused.
1031
+ *
1032
+ * The element itself must expose a computed foreground color either way. A detached element exposes
1033
+ * none, and the measurement throws rather than guessing one.
407
1034
  *
408
1035
  * @example
409
1036
  * ```ts
410
1037
  * const container = render('<p style="background: #000; color: #fff">Ready</p>')
411
1038
  * contrast(requireValue(container.firstElementChild)) // 21
1039
+ * contrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
412
1040
  * ```
413
1041
  */
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
- });
1042
+ function contrast(element, floor) {
1043
+ const foreground = parseColor(getComputedStyle(element).color);
1044
+ if (foreground === void 0) throw new Error("Computed foreground color is unavailable");
1045
+ const layers = readLayers(element);
1046
+ const deepest = layers.at(-1);
1047
+ if (floor === void 0 && (deepest === void 0 || deepest[3] < 1)) throw new Error("Computed background color is unavailable");
1048
+ const backdrop = layers.reduceRight((back, front) => blendColor(front, back), floor ?? CANVAS_COLOR);
1049
+ return measureContrast(blendColor(foreground, backdrop), backdrop);
1050
+ }
1051
+ /**
1052
+ * Measures the contrast the focus chrome painted on one control reaches against its own backdrop.
1053
+ *
1054
+ * @param control - The control that holds the focus.
1055
+ * @param worn - The element the control's focus chrome is painted onto. Default: `control`.
1056
+ * @returns The strongest ratio the painted focus chrome reaches, or `undefined` when the control is
1057
+ * not showing `:focus-visible` or the cascade paints no chrome of its own.
1058
+ *
1059
+ * @remarks
1060
+ * This reads and never acts. Focus arrives through the published verbs `traverseAccessible`,
1061
+ * `pressKeys`, a real click — and this measures what the browser painted once it landed. A control
1062
+ * that is not matching `:focus-visible` when the call is made reports nothing, because no
1063
+ * measurement taken then would be about focus.
1064
+ *
1065
+ * Some controls are two elements: one that takes the focus and one a reader can see. A hidden radio
1066
+ * beside the label that carries every pixel of its chrome is the case `worn` exists for, so a
1067
+ * measurement is not taken on a rectangle nobody is looking at. The focus state is still read off
1068
+ * `control`, because that is what holds it.
1069
+ *
1070
+ * The backdrop is the surface behind the element the chrome is worn on, resolved from that element's
1071
+ * parent through {@link readBackdrop} onto {@link CANVAS_COLOR}. A control whose ancestry paints
1072
+ * nothing is therefore measured against the browser's own canvas, which is what a reader looking at
1073
+ * an unstyled document sees.
1074
+ *
1075
+ * Only chrome the cascade paints is measured an `outline` with a real style and width, and the
1076
+ * first color in a `box-shadow`. A control left the browser's own `outline-style: auto` ring reports
1077
+ * `undefined`, because that ring's two tones are guaranteed against any backdrop and its computed
1078
+ * color names neither. A focus style that only changes the control's own fill reports `undefined`
1079
+ * too: the resting fill is gone by the time focus is on the control, and this never moves focus to
1080
+ * go and read it.
1081
+ *
1082
+ * @example
1083
+ * ```ts
1084
+ * await traverseAccessible('Evaluate')
1085
+ * readRing(resolveRendered('Evaluate')) // the ratio the painted ring reaches
1086
+ * ```
1087
+ */
1088
+ function readRing(control, worn) {
1089
+ if (!control.matches(":focus-visible")) return void 0;
1090
+ const target = worn ?? control;
1091
+ const declared = getComputedStyle(target);
1092
+ const backdrop = readBackdrop(target.parentElement ?? target, CANVAS_COLOR);
1093
+ const outline = declared.outlineStyle === "none" || declared.outlineStyle === "auto" || Number.parseFloat(declared.outlineWidth) === 0 ? void 0 : parseColor(declared.outlineColor);
1094
+ const shadow = parseColor(/(?:rgba?|color)\([^)]*\)/u.exec(declared.boxShadow)?.[0] ?? "");
1095
+ const ratios = [];
1096
+ for (const painted of [outline, shadow]) {
1097
+ if (painted === void 0) continue;
1098
+ ratios.push(measureContrast(blendColor(painted, backdrop), backdrop));
449
1099
  }
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);
1100
+ return ratios.length === 0 ? void 0 : Math.max(...ratios);
463
1101
  }
464
1102
  /**
465
1103
  * Collects every class token the stylesheets loaded into this document actually define.
@@ -518,6 +1156,32 @@ function readRows(root, selector) {
518
1156
  return rows;
519
1157
  }
520
1158
  /**
1159
+ * Collects every element carrying a component class rendered outside the container it belongs to.
1160
+ *
1161
+ * @param root - The subtree to sweep.
1162
+ * @param child - The component class whose anatomy requires a container, such as `list-group-item`.
1163
+ * @param parent - The container class that child class must render inside, such as `list-group`.
1164
+ * @returns The markup of every element carrying `child` with no `parent` above it, in document
1165
+ * order; an empty list when every one of them is nested correctly.
1166
+ *
1167
+ * @remarks
1168
+ * A component keeps its padding, borders, and radii on the container, so a child class rendered
1169
+ * outside one is an unstyled box wearing a component's name, and the interface has to hand-roll the
1170
+ * chrome back. The search for the container starts at the element's parent, so an element can never
1171
+ * answer the invariant by carrying both classes itself.
1172
+ *
1173
+ * The class names are arguments, so the check belongs to no framework: name the pair your own
1174
+ * cascade defines.
1175
+ *
1176
+ * @example
1177
+ * ```ts
1178
+ * extractOrphans(container, 'list-group-item', 'list-group') // []
1179
+ * ```
1180
+ */
1181
+ function extractOrphans(root, child, parent) {
1182
+ return [...root.querySelectorAll(`.${child}`)].filter((node) => (node.parentElement?.closest(`.${parent}`) ?? null) === null).map((node) => node.outerHTML);
1183
+ }
1184
+ /**
521
1185
  * Reads one resolved CSS property from a real browser element.
522
1186
  *
523
1187
  * @param element - The element whose resolved style to inspect.
@@ -533,6 +1197,139 @@ function style(element, property) {
533
1197
  return getComputedStyle(element).getPropertyValue(property);
534
1198
  }
535
1199
  /**
1200
+ * Sets the tester's viewport and renders the runner's pane at the size that viewport claims.
1201
+ *
1202
+ * @param width - The viewport width in CSS pixels.
1203
+ * @param height - The viewport height in CSS pixels.
1204
+ * @returns A promise resolving after the resized pane has been painted.
1205
+ * @throws Thrown when the tester sits inside no pane a capture can size, and when the staged pane
1206
+ * does not render at the viewport it was given.
1207
+ *
1208
+ * @remarks
1209
+ * This depends on the runner's own tester layout, and that dependency is contract rather than an
1210
+ * accident: `vitest@4.1.11` lays its tester out inside a smaller page, fits it by scaling the pane
1211
+ * the tester sits in, and clips whatever overflows that pane. Layout inside the tester is
1212
+ * unaffected — the tester reports the viewport it was given and every breakpoint answers to it —
1213
+ * but a screenshot is taken off the page the runner painted, so a frame shot through that scale is
1214
+ * a thumbnail of the surface and a frame shot after only unscaling it is a sliver. The tester is
1215
+ * therefore unscaled and lifted to the window's own origin for the shot. The `iframe[data-vitest]`
1216
+ * selector and the `--tester-transform`, `--tester-margin-left`, `--viewport-width`, and
1217
+ * `--viewport-height` custom properties are the runner's, so a Vitest release that renames any of
1218
+ * them reddens the size check below rather than writing a wrong frame.
1219
+ *
1220
+ * Hand the pane straight back with {@link releasePane}. A tester pinned at a viewport taller than
1221
+ * the window puts its lower half beyond what a pointer can reach, so an ordinary press then fails
1222
+ * as a control outside the viewport, in a test that took no picture at all.
1223
+ *
1224
+ * The rule is declared rather than written inline, because the runner writes its own scale onto the
1225
+ * pane as inline custom properties and rewrites them whenever the tester resizes. A declared rule
1226
+ * marked important outranks an inline value and survives every rewrite. It finds the pane by the
1227
+ * tester it contains as well as by {@link CAPTURE_PANE}, because a re-render between the staging and
1228
+ * the shot replaces the node and takes any attribute of ours with it.
1229
+ *
1230
+ * The wait is two frames rather than a delay: the first carries the resize into layout and the
1231
+ * second is the paint a screenshot reads.
1232
+ *
1233
+ * @example
1234
+ * ```ts
1235
+ * await stagePane(390, 844)
1236
+ * ```
1237
+ */
1238
+ async function stagePane(width, height) {
1239
+ await page.viewport(width, height);
1240
+ const frame = window.frameElement;
1241
+ const pane = frame?.parentElement;
1242
+ const owner = pane?.ownerDocument;
1243
+ if (frame === null || pane === null || pane === void 0 || owner === void 0) throw new Error("Tester pane is unavailable for a capture");
1244
+ pane.setAttribute(CAPTURE_PANE, "");
1245
+ if (owner.querySelector(`style[data-capture-pane]`) === null) {
1246
+ const rule = owner.createElement("style");
1247
+ rule.setAttribute(CAPTURE_PANE, "");
1248
+ rule.textContent = [
1249
+ `[${CAPTURE_PANE}],:has(>iframe[data-vitest])`,
1250
+ "{--tester-transform:none !important;--tester-margin-left:0px !important}",
1251
+ "iframe[data-vitest]",
1252
+ "{position:fixed !important;left:0 !important;top:0 !important;right:auto !important;",
1253
+ "bottom:auto !important;width:var(--viewport-width) !important;",
1254
+ "height:var(--viewport-height) !important;z-index:2147483647 !important}"
1255
+ ].join("");
1256
+ owner.head.append(rule);
1257
+ }
1258
+ await waitForFrame();
1259
+ await waitForFrame();
1260
+ const box = frame.getBoundingClientRect();
1261
+ if (Math.round(box.width) !== width || Math.round(box.height) !== height) throw new Error(`Tester pane rendered ${String(Math.round(box.width))}x${String(Math.round(box.height))} for a ${String(width)}x${String(height)} viewport`);
1262
+ }
1263
+ /**
1264
+ * Hands the tester pane back to the runner's own layout.
1265
+ *
1266
+ * @remarks
1267
+ * A staged pane is the runner's fitting scale suppressed, so a pane left staged outlives the capture
1268
+ * that needed it and every later act in the file happens on a surface the runner is no longer
1269
+ * fitting to its window. What that costs is not a wrong picture: it is a control whose page
1270
+ * coordinates fall outside the pane, which the runner's own layout then intercepts, so an ordinary
1271
+ * press fails with the voice of a control that is covered. Calling this on an unstaged pane does
1272
+ * nothing.
1273
+ *
1274
+ * @example
1275
+ * ```ts
1276
+ * releasePane()
1277
+ * ```
1278
+ */
1279
+ function releasePane() {
1280
+ const pane = window.frameElement?.parentElement;
1281
+ pane?.removeAttribute(CAPTURE_PANE);
1282
+ pane?.ownerDocument.querySelector(`style[${CAPTURE_PANE}]`)?.remove();
1283
+ }
1284
+ /**
1285
+ * Shoots one frame at one viewport size and proves the file on disk holds this run's bytes.
1286
+ *
1287
+ * @param options - The path to write, the viewport to shoot at, and the element to shoot.
1288
+ * @returns The absolute path of the written frame, after it has been read back and matched.
1289
+ * @throws Thrown when the pane cannot be staged, when the provider wrote the frame somewhere else,
1290
+ * and when the bytes on disk are not the ones this shot produced.
1291
+ *
1292
+ * @remarks
1293
+ * The path a screenshot call returns is the path it meant to write, so it is not evidence a file
1294
+ * exists. The file is read back through the runner's built-in `readFile` command and compared with
1295
+ * the shot itself, which is what separates a frame this run wrote from one an earlier run left
1296
+ * behind. The provider resolves `options.path` against the calling test file and returns an absolute
1297
+ * path, so the two are compared by the segments that survive resolving `.` and `..` lexically — the
1298
+ * refusal is what a provider resolving that path against a different base would trip.
1299
+ *
1300
+ * Omit `options.element` to shoot the whole page. The pane is staged for the frame and released
1301
+ * before this returns, on the failing path as well as the passing one.
1302
+ *
1303
+ * @example
1304
+ * ```ts
1305
+ * await captureFrame({ path: '../../tmp/capture/start.png', width: 390, height: 844 })
1306
+ * ```
1307
+ */
1308
+ async function captureFrame(options) {
1309
+ try {
1310
+ await stagePane(options.width, options.height);
1311
+ const shot = options.element === void 0 ? await page.screenshot({
1312
+ path: options.path,
1313
+ base64: true
1314
+ }) : await page.screenshot({
1315
+ element: options.element,
1316
+ path: options.path,
1317
+ base64: true
1318
+ });
1319
+ const segments = [];
1320
+ for (const segment of options.path.replaceAll("\\", "/").split("/")) {
1321
+ if (segment === "" || segment === ".") continue;
1322
+ if (segment === "..") segments.pop();
1323
+ else segments.push(segment);
1324
+ }
1325
+ if (!shot.path.replaceAll("\\", "/").endsWith(segments.join("/"))) throw new Error(`Capture frame was written to ${shot.path} where ${options.path} was asked for`);
1326
+ if (await commands.readFile(shot.path, "base64") !== shot.base64) throw new Error(`Capture frame at ${options.path} is not the one this run shot`);
1327
+ return shot.path;
1328
+ } finally {
1329
+ releasePane();
1330
+ }
1331
+ }
1332
+ /**
536
1333
  * Expands a capture registry across every variant into the filenames a complete portfolio holds.
537
1334
  *
538
1335
  * @param states - The registered state names.
@@ -571,6 +1368,10 @@ function expandCaptures(states, variants) {
571
1368
  * none of it. The portfolio refuses an unregistered variant at creation. An enabled run refuses an
572
1369
  * unregistered state name and a second placement of one state.
573
1370
  *
1371
+ * An enabled `place` writes through `captureFrame`, so a placed state carries that helper's staged
1372
+ * pane and its byte readback: a path is recorded only after the file on disk has been proved to hold
1373
+ * this run's own frame.
1374
+ *
574
1375
  * @example
575
1376
  * ```ts
576
1377
  * const portfolio = createPortfolio({
@@ -599,21 +1400,109 @@ function createPortfolio(options) {
599
1400
  get paths() {
600
1401
  return [...paths];
601
1402
  },
602
- async place(state) {
1403
+ async place(state, element) {
603
1404
  if (!enabled) return void 0;
604
1405
  if (!registry.includes(state)) throw new Error(`Capture state "${state}" is not registered`);
605
1406
  if (placed.includes(state)) throw new Error(`Capture state "${state}" is already placed`);
606
1407
  const file = `${state}--${options.variant}.png`;
607
1408
  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}` });
1409
+ const written = await captureFrame({
1410
+ path: `${options.directory}/${file}`,
1411
+ width: selected.width,
1412
+ height: selected.height,
1413
+ element
1414
+ });
610
1415
  placed.push(state);
611
1416
  paths.push(written);
612
1417
  return written;
613
1418
  }
614
1419
  };
615
1420
  }
1421
+ /**
1422
+ * Creates the journal one scenario records its steps and the page's own output into.
1423
+ *
1424
+ * @returns A journal that records nothing until it is started.
1425
+ *
1426
+ * @remarks
1427
+ * The console is recorded rather than replaced: every intercepted call is forwarded to the channel
1428
+ * that was there when the journal started, so a run under a journal prints exactly what it printed
1429
+ * without one. `stop` puts those same function references back by identity.
1430
+ *
1431
+ * Uncaught errors and unhandled rejections are recorded too, through listeners the journal drops
1432
+ * when it stops. `steps` and `output` hand out snapshots, so a list read mid-scenario stays what it
1433
+ * was. Each journal owns its own recording, so a file that needs one per scenario creates one per
1434
+ * scenario.
1435
+ *
1436
+ * @example
1437
+ * ```ts
1438
+ * const journal = createJournal()
1439
+ * journal.start()
1440
+ * journal.record('click', 'Evaluate', 'alerts=0')
1441
+ * journal.stop()
1442
+ * journal.steps // [{ action: 'click', trigger: 'Evaluate', result: 'alerts=0' }]
1443
+ * ```
1444
+ */
1445
+ function createJournal() {
1446
+ const steps = [];
1447
+ const output = [];
1448
+ let intercepted;
1449
+ let listeners;
1450
+ return {
1451
+ get steps() {
1452
+ return [...steps];
1453
+ },
1454
+ get output() {
1455
+ return [...output];
1456
+ },
1457
+ start() {
1458
+ steps.length = 0;
1459
+ output.length = 0;
1460
+ if (intercepted !== void 0) return;
1461
+ const forwarded = {
1462
+ debug: console.debug,
1463
+ error: console.error,
1464
+ info: console.info,
1465
+ log: console.log,
1466
+ warn: console.warn
1467
+ };
1468
+ intercepted = forwarded;
1469
+ for (const channel of [
1470
+ "debug",
1471
+ "error",
1472
+ "info",
1473
+ "log",
1474
+ "warn"
1475
+ ]) console[channel] = (...data) => {
1476
+ output.push(`${channel}: ${data.map((value) => String(value)).join(" ")}`);
1477
+ forwarded[channel](...data);
1478
+ };
1479
+ const dropped = new AbortController();
1480
+ listeners = dropped;
1481
+ window.addEventListener("error", (event) => {
1482
+ output.push(`error: ${event.message}`);
1483
+ }, { signal: dropped.signal });
1484
+ window.addEventListener("unhandledrejection", (event) => {
1485
+ output.push(`rejection: ${String(event.reason)}`);
1486
+ }, { signal: dropped.signal });
1487
+ },
1488
+ stop() {
1489
+ if (intercepted === void 0) return;
1490
+ Object.assign(console, intercepted);
1491
+ intercepted = void 0;
1492
+ listeners?.abort();
1493
+ listeners = void 0;
1494
+ },
1495
+ record(action, trigger, result) {
1496
+ if (intercepted === void 0) return;
1497
+ steps.push(Object.freeze({
1498
+ action,
1499
+ trigger,
1500
+ result
1501
+ }));
1502
+ }
1503
+ };
1504
+ }
616
1505
  //#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 };
1506
+ export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, contrast, createJournal, createPortfolio, describeFocus, describeTree, expandCaptures, extractOrphans, fillAccessible, isOutsideViewport, isReachable, isRendered, measureContrast, measureLuminance, parseColor, pressKeys, readBackdrop, readCascade, readFocus, readLayers, readName, readPage, readPerception, readRing, readRole, readRows, readStates, readText, readValue, releasePane, render, resolveAccessible, resolveRendered, stagePane, style, traverseAccessible, typeAccessible, waitForFrame };
618
1507
 
619
1508
  //# sourceMappingURL=index.js.map