@orkestrel/test 0.0.11 → 0.0.13

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,7 +1,7 @@
1
1
  import { commands, page, userEvent } from "vitest/browser";
2
2
  //#region src/browser/constants.ts
3
3
  /**
4
- * The interactive ARIA roles a bare accessible name is searched across.
4
+ * Names the interactive ARIA roles a bare accessible name is searched across.
5
5
  *
6
6
  * @remarks
7
7
  * A person names a control, not a role, so the one-argument resolver searches every role a control
@@ -27,7 +27,7 @@ var ACCESSIBLE_ROLES = Object.freeze([
27
27
  "treeitem"
28
28
  ]);
29
29
  /**
30
- * The page a browser paints an unstyled document onto.
30
+ * Names the color a browser paints an unstyled document with.
31
31
  *
32
32
  * @remarks
33
33
  * This is the floor a backdrop walk ends on wherever the caller wants the browser's own canvas
@@ -41,17 +41,35 @@ var CANVAS_COLOR = Object.freeze([
41
41
  1
42
42
  ]);
43
43
  /**
44
- * The attribute marking the runner's tester pane, and the rule that sizes it, while a frame is
45
- * staged.
44
+ * Names the attribute marking the runner's tester pane, and the rule that sizes it, while a frame
45
+ * is staged.
46
46
  *
47
47
  * @remarks
48
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.
49
+ * both by it. The stylesheet's value is the viewport the tester had before the first staging, in
50
+ * `<width>x<height>` form, which is what `releasePane` hands back. Nothing else reads it, so a
51
+ * document carrying it after a capture returned is a pane that was never released.
51
52
  */
52
53
  var CAPTURE_PANE = "data-capture-pane";
53
54
  /**
54
- * The roles whose accessible name is the text a reader can see inside them.
55
+ * Bounds the restagings one capture takes before it refuses a document whose height never settles.
56
+ *
57
+ * @remarks
58
+ * `captureFrame` stages the pane at the content edge `measureContent` reads, and a rule bound to
59
+ * the viewport height lays that document out taller against the taller pane, so the edge has to be
60
+ * read again after every staging. The re-reading stops when the pane and the edge agree, and a rule
61
+ * that adds height with every pane never reaches that point, so the re-reading is bounded here and
62
+ * the shot is refused rather than taken at a height that is already stale.
63
+ *
64
+ * The bound is the measured need plus one. A document holding half the pane plus a fixed block
65
+ * settles in two restagings, because the second carries the growth the first produced and lands on
66
+ * the fixed point; a document whose growth is capped part way settles in three, because it takes
67
+ * one restaging past the cap before it comes back down to the edge. Nothing measured needs a
68
+ * fourth, so a fourth is the headroom that keeps a settling document off the refusal.
69
+ */
70
+ var CAPTURE_STAGINGS = 4;
71
+ /**
72
+ * Names the roles whose accessible name is the text a reader can see inside them.
55
73
  *
56
74
  * @remarks
57
75
  * `readName` reads an element in this list from its own rendered text, after every `aria-hidden`
@@ -70,7 +88,7 @@ var CONTENT_ROLES = Object.freeze([
70
88
  "tab"
71
89
  ]);
72
90
  /**
73
- * The role each `input` type carries.
91
+ * Names the role each `input` type carries.
74
92
  *
75
93
  * @remarks
76
94
  * Membership is the contract. The map answers for `button`, `checkbox`, `email`, `number`,
@@ -94,7 +112,7 @@ var FIELD_ROLES = Object.freeze({
94
112
  url: "textbox"
95
113
  });
96
114
  /**
97
- * What sequential keyboard navigation can reach, before disabled and unrendered elements go.
115
+ * Names what sequential keyboard navigation can reach, before disabled and unrendered elements go.
98
116
  *
99
117
  * @remarks
100
118
  * `describeFocus` queries this selector and then drops what a browser drops: an element the
@@ -104,7 +122,7 @@ var FIELD_ROLES = Object.freeze({
104
122
  */
105
123
  var FOCUSABLE_SELECTOR = "a[href], area[href], button, input, select, summary, textarea, [tabindex]";
106
124
  /**
107
- * The role a `th` carries for the header axis its `scope` names.
125
+ * Names the role a `th` carries for the header axis its `scope` names.
108
126
  *
109
127
  * @remarks
110
128
  * A header cell heads a column or a row, and this map answers for the `col` and `row` scopes that
@@ -116,7 +134,8 @@ var HEADER_ROLES = Object.freeze({
116
134
  row: "rowheader"
117
135
  });
118
136
  /**
119
- * The role each listed tag carries in the accessibility tree when it declares none of its own.
137
+ * Names the role each listed tag carries in the accessibility tree when it declares none of its
138
+ * own.
120
139
  *
121
140
  * @remarks
122
141
  * Membership is the contract. The map answers for the sectioning elements `ARTICLE`, `ASIDE`,
@@ -177,7 +196,7 @@ var IMPLICIT_ROLES = Object.freeze({
177
196
  * Determines whether a rectangle lies wholly outside the browser viewport.
178
197
  *
179
198
  * @param rectangle - The measured client rectangle to inspect.
180
- * @returns `true` when no part of the rectangle intersects the viewport.
199
+ * @returns True if no part of the rectangle intersects the viewport; false otherwise.
181
200
  *
182
201
  * @example
183
202
  * ```ts
@@ -188,12 +207,12 @@ function isOutsideViewport(rectangle) {
188
207
  return rectangle.bottom <= 0 || rectangle.right <= 0 || rectangle.top >= window.innerHeight || rectangle.left >= window.innerWidth;
189
208
  }
190
209
  /**
191
- * Determines whether a person can click one element where it currently sits.
210
+ * Determines whether a person can click one element where it sits.
192
211
  *
193
212
  * @param element - The element to judge.
194
- * @returns `true` when the element is connected, visible, laid out with a non-zero box, in the
213
+ * @returns True if the element is connected, visible, laid out with a non-zero box, in the
195
214
  * sequential focus order, neither disabled nor marked `aria-disabled="true"`, and outside every
196
- * `[inert]` subtree; `false` otherwise.
215
+ * `[inert]` subtree; false otherwise.
197
216
  *
198
217
  * @remarks
199
218
  * This is the one reachability filter the layer applies. `resolveRendered`, `clickAccessibleWithin`,
@@ -222,8 +241,7 @@ function isReachable(element) {
222
241
  * Determines whether the accessibility tree presents one element at all.
223
242
  *
224
243
  * @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.
244
+ * @returns True if the element is presented to assistive technology and to sight; false otherwise.
227
245
  *
228
246
  * @remarks
229
247
  * A control clipped to a zero-size rectangle is still announced, which is the whole point of that
@@ -251,6 +269,38 @@ function isRendered(element) {
251
269
  return getComputedStyle(element).visibility !== "hidden";
252
270
  }
253
271
  /**
272
+ * Computes the pattern that matches one accessible name a decorative glyph may sit beside.
273
+ *
274
+ * @param name - The exact accessible name a person reads, whitespace runs collapsed on the way in.
275
+ * @returns A pattern anchored at both ends, admitting a run of characters that are neither letters
276
+ * nor digits before the name and after it.
277
+ *
278
+ * @remarks
279
+ * A role query that includes hidden elements computes a name from the `aria-hidden` subtrees too,
280
+ * so an icon font's `::before` glyph joins the name a person never hears and an exact string never
281
+ * matches again. This pattern is what {@link resolveRendered} asks the hidden pass with, and its
282
+ * tolerance is bounded to what a glyph can be: a leading or trailing run carrying no letter and no
283
+ * digit. A hidden icon whose own content is a word still defeats it, and a name differing from the
284
+ * requested one by punctuation alone still satisfies it.
285
+ *
286
+ * That bound is affordable because the hidden pass chooses between two refusal voices and returns
287
+ * nothing. The visible pass decides which element a resolver returns, and it matches the exact
288
+ * string against the name the accessibility tree actually publishes.
289
+ *
290
+ * Pass this to a role query with `exact: true`. That flag is the engine's case-sensitivity switch
291
+ * as well as its exactness one, so a query carrying `exact: false` uppercases the computed name
292
+ * before testing a pattern against it and a lowercase letter in the requested name never matches.
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * computeNamePattern('Add building').test('\uF4FE Add building') // true
297
+ * ```
298
+ */
299
+ function computeNamePattern(name) {
300
+ const wanted = name.replaceAll(/\s+/g, " ").trim().replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
301
+ return new RegExp(`^[^\\p{L}\\p{N}]*${wanted}[^\\p{L}\\p{N}]*$`, "u");
302
+ }
303
+ /**
254
304
  * Resolves one rendered, focus-reachable interactive element without requiring it to intersect the
255
305
  * viewport yet.
256
306
  *
@@ -264,6 +314,14 @@ function isRendered(element) {
264
314
  * This is the resolver the acting verbs use, so a click does not fail on a target the act itself
265
315
  * scrolls into view. Use {@link resolveAccessible} wherever the target must already be on screen.
266
316
  *
317
+ * It runs two passes, and only the first one can return an element. The visible pass asks the role
318
+ * engine for the exact name over the elements the accessibility tree presents, which is the name a
319
+ * screen reader announces: an `aria-hidden` icon beside the text contributes nothing to it. The
320
+ * hidden pass runs only when the visible pass found nothing at all, and it decides which refusal
321
+ * the caller hears — a name the page carries nowhere, or a target that is there and out of reach.
322
+ * That pass must include hidden elements to see a folded control, which is what puts a glyph back
323
+ * into the computed name, so it asks with {@link computeNamePattern} rather than the exact string.
324
+ *
267
325
  * @example
268
326
  * ```ts
269
327
  * resolveRendered('tab', 'Drafts')
@@ -275,10 +333,17 @@ function resolveRendered(first, second) {
275
333
  const matches = [];
276
334
  for (const role of roles) for (const element of page.getByRole(role, {
277
335
  name,
278
- exact: true,
279
- includeHidden: true
336
+ exact: true
280
337
  }).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
281
- if (matches.length === 0) throw new Error(`No interactive element has the accessible name "${name}"`);
338
+ if (matches.length === 0) {
339
+ const pattern = computeNamePattern(name);
340
+ if (!roles.some((role) => page.getByRole(role, {
341
+ name: pattern,
342
+ exact: true,
343
+ includeHidden: true
344
+ }).elements().length > 0)) throw new Error(`No interactive element has the accessible name "${name}"`);
345
+ throw new Error(`Interactive target "${name}" is not visible and focus-reachable`);
346
+ }
282
347
  const reachable = matches.filter((element) => isReachable(element));
283
348
  if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not visible and focus-reachable`);
284
349
  if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements`);
@@ -401,20 +466,6 @@ async function fillAccessible(name, text) {
401
466
  await userEvent.fill(resolveRendered(name), text);
402
467
  }
403
468
  /**
404
- * Presses a browser-keyboard sequence using Vitest's installed user-event syntax.
405
- *
406
- * @param keys - The keys or key descriptors to press.
407
- * @returns A promise resolving after the sequence completes.
408
- *
409
- * @example
410
- * ```ts
411
- * await pressKeys('{ArrowRight}{Enter}')
412
- * ```
413
- */
414
- async function pressKeys(keys) {
415
- await userEvent.keyboard(keys);
416
- }
417
- /**
418
469
  * Reaches a named control only through natural forward Tab traversal from the current focus.
419
470
  *
420
471
  * @param name - The target's exact accessible name.
@@ -456,6 +507,13 @@ async function traverseAccessible(name) {
456
507
  * visually-hidden content.
457
508
  * @throws When the named region is absent, hidden, or ambiguous.
458
509
  *
510
+ * @remarks
511
+ * One pass answers this, because absence and concealment share the refusal. The pass asks the role
512
+ * engine over the elements the accessibility tree presents, so the name matched is the one a screen
513
+ * reader announces and an `aria-hidden` glyph in a heading a region points at contributes nothing
514
+ * to it. A region the tree does not present is refused as not visible, which is what a reader
515
+ * perceiving nothing there means.
516
+ *
459
517
  * @example
460
518
  * ```ts
461
519
  * readPerception('Run')
@@ -473,8 +531,7 @@ function readPerception(name) {
473
531
  "tabpanel"
474
532
  ]) for (const element of page.getByRole(role, {
475
533
  name,
476
- exact: true,
477
- includeHidden: true
534
+ exact: true
478
535
  }).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
479
536
  const visible = matches.filter((element) => {
480
537
  const rectangle = element.getBoundingClientRect();
@@ -509,7 +566,7 @@ function readPage() {
509
566
  return document.body.innerText.replaceAll(/\s+/g, " ").trim();
510
567
  }
511
568
  /**
512
- * Reads the rendered text of the element that currently holds focus.
569
+ * Reads the rendered text of the element that holds focus.
513
570
  *
514
571
  * @returns The focused HTML element's trimmed rendered text, including an empty string, or
515
572
  * `undefined` when focus rests on a non-HTML element. When nothing holds focus, the browser
@@ -834,8 +891,8 @@ function build(tag, options) {
834
891
  * @remarks
835
892
  * What this buys is the composition, not the attachment: the `append` method returns `void`, and
836
893
  * this hands the element back, so it fits where an expression is expected. The {@link render} helper
837
- * returns its fixture through it, and the {@link rgba} helper probes through `mount(build('span'))`.
838
- * A bare `append` call breaks each of those call sites.
894
+ * returns its fixture through it, and the {@link parseCSSColor} helper probes through
895
+ * `mount(build('span'))`. A bare `append` call breaks each of those call sites.
839
896
  *
840
897
  * Being connected is what the attachment then buys: `getComputedStyle` resolves against the shipped
841
898
  * cascade, custom properties inherit from `:root`, and the element lays out a real box. A detached
@@ -1024,20 +1081,20 @@ function parseColor(value) {
1024
1081
  * Refusal is the CSSOM's: an expression it will not parse leaves the probe's inline `color` empty
1025
1082
  * and this returns `undefined`. A `var()` naming an undeclared custom property is not refused,
1026
1083
  * because the cascade accepts it and computes the inherited color, so a test that means to catch a
1027
- * missing token asserts on {@link token} rather than on this.
1084
+ * missing token asserts on {@link readToken} rather than on this.
1028
1085
  *
1029
1086
  * @example
1030
1087
  * ```ts
1031
- * rgba('rebeccapurple') // [102, 51, 153, 1]
1032
- * rgba('not-a-color') // undefined
1088
+ * parseCSSColor('rebeccapurple') // [102, 51, 153, 1]
1089
+ * parseCSSColor('not-a-color') // undefined
1033
1090
  * ```
1034
1091
  */
1035
- function rgba(value) {
1092
+ function parseCSSColor(value) {
1036
1093
  const probe = mount(build("span"));
1037
1094
  try {
1038
1095
  probe.style.color = value;
1039
1096
  if (probe.style.color === "") return void 0;
1040
- return parseColor(style(probe, "color"));
1097
+ return parseColor(readStyle(probe, "color"));
1041
1098
  } finally {
1042
1099
  probe.remove();
1043
1100
  }
@@ -1047,11 +1104,11 @@ function rgba(value) {
1047
1104
  *
1048
1105
  * @param first - A CSS color expression or an already-parsed color.
1049
1106
  * @param second - A CSS color expression or an already-parsed color.
1050
- * @returns `true` when every channel and the alpha agree within the tolerance; `false` otherwise,
1107
+ * @returns True if every channel and the alpha agree within the tolerance; false otherwise,
1051
1108
  * including when either side names no readable color.
1052
1109
  *
1053
1110
  * @remarks
1054
- * Each string side is resolved through {@link rgba}, so a keyword, a token reference, and the
1111
+ * Each string side is resolved through {@link parseCSSColor}, so a keyword, a token reference, and the
1055
1112
  * `rgb()` the engine computes for either of them compare equal without a test converting anything
1056
1113
  * first. A side that resolves to nothing makes the answer `false` rather than a throw, because this
1057
1114
  * is a predicate.
@@ -1063,13 +1120,13 @@ function rgba(value) {
1063
1120
  *
1064
1121
  * @example
1065
1122
  * ```ts
1066
- * colorEqual('rebeccapurple', 'rgb(102, 51, 153)') // true
1067
- * colorEqual('red', [0, 0, 255, 1]) // false
1123
+ * matchesColor('rebeccapurple', 'rgb(102, 51, 153)') // true
1124
+ * matchesColor('red', [0, 0, 255, 1]) // false
1068
1125
  * ```
1069
1126
  */
1070
- function colorEqual(first, second) {
1071
- const left = typeof first === "string" ? rgba(first) : first;
1072
- const right = typeof second === "string" ? rgba(second) : second;
1127
+ function matchesColor(first, second) {
1128
+ const left = typeof first === "string" ? parseCSSColor(first) : first;
1129
+ const right = typeof second === "string" ? parseCSSColor(second) : second;
1073
1130
  if (left === void 0 || right === void 0) return false;
1074
1131
  const tolerance = .5;
1075
1132
  const [leftRed, leftGreen, leftBlue, leftAlpha] = left;
@@ -1155,7 +1212,7 @@ function measureContrast(front, back) {
1155
1212
  * the walk stops at the first fully opaque layer, because nothing above that layer is visible.
1156
1213
  *
1157
1214
  * The stack is what tells a resolved backdrop from an assumed one: the walk reached an opaque
1158
- * surface exactly when its last layer's alpha is `1`. {@link contrast} refuses on that reading,
1215
+ * surface exactly when its last layer's alpha is `1`. {@link readContrast} refuses on that reading,
1159
1216
  * which no comparison of composited colors can replace — 64 half-transparent layers composite to
1160
1217
  * the same channels over opposite floors, because the floor's remaining share falls below the last
1161
1218
  * bit a channel carries.
@@ -1235,11 +1292,11 @@ function readBackdrop(element, floor) {
1235
1292
  * @example
1236
1293
  * ```ts
1237
1294
  * const container = render('<p style="background: #000; color: #fff">Ready</p>')
1238
- * contrast(requireValue(container.firstElementChild)) // 21
1239
- * contrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
1295
+ * readContrast(requireValue(container.firstElementChild)) // 21
1296
+ * readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
1240
1297
  * ```
1241
1298
  */
1242
- function contrast(element, floor) {
1299
+ function readContrast(element, floor) {
1243
1300
  const foreground = parseColor(getComputedStyle(element).color);
1244
1301
  if (foreground === void 0) throw new Error("Computed foreground color is unavailable");
1245
1302
  const layers = readLayers(element);
@@ -1258,9 +1315,9 @@ function contrast(element, floor) {
1258
1315
  *
1259
1316
  * @remarks
1260
1317
  * This reads and never acts. Focus arrives through the published verbs — `traverseAccessible`,
1261
- * `pressKeys`, a real click — and this measures what the browser painted once it landed. A control
1262
- * that is not matching `:focus-visible` when the call is made reports nothing, because no
1263
- * measurement taken then would be about focus.
1318
+ * `userEvent.keyboard` from `vitest/browser`, a real click — and this measures what the browser
1319
+ * painted once it landed. A control that is not matching `:focus-visible` when the call is made
1320
+ * reports nothing, because no measurement taken then would be about focus.
1264
1321
  *
1265
1322
  * Some controls are two elements: one that takes the focus and one a reader can see. A hidden radio
1266
1323
  * beside the label that carries every pixel of its chrome is the case `worn` exists for, so a
@@ -1334,6 +1391,34 @@ function readCascade() {
1334
1391
  return known;
1335
1392
  }
1336
1393
  /**
1394
+ * Collects every class token the markup under one root carries.
1395
+ *
1396
+ * @param root - The subtree to sweep. A detached element and a `DocumentFragment` both work.
1397
+ * @returns The class tokens in document order of first sighting; an empty set for markup carrying no
1398
+ * class at all.
1399
+ *
1400
+ * @remarks
1401
+ * The root's own classes count when the root is an `Element`, so a `DocumentFragment` contributes
1402
+ * its descendants alone. Every element is read through `classList`, which is what makes an SVG
1403
+ * element count the same as an HTML one: `className` on an SVG element is an `SVGAnimatedString`
1404
+ * rather than a string, and a reader splitting that value finds nothing.
1405
+ *
1406
+ * This is the authored half of a class conformance check and {@link readCascade} is the defined
1407
+ * half, so the difference between them is the set of classes the markup uses and no loaded
1408
+ * stylesheet declares.
1409
+ *
1410
+ * @example
1411
+ * ```ts
1412
+ * [...readClasses(container)].filter((name) => !readCascade().has(name))
1413
+ * ```
1414
+ */
1415
+ function readClasses(root) {
1416
+ const authored = /* @__PURE__ */ new Set();
1417
+ if (root instanceof Element) for (const name of root.classList) authored.add(name);
1418
+ for (const element of root.querySelectorAll("*")) for (const name of element.classList) authored.add(name);
1419
+ return authored;
1420
+ }
1421
+ /**
1337
1422
  * Collects every rule the stylesheets loaded into this document hold, nested grouping rules
1338
1423
  * included.
1339
1424
  *
@@ -1383,8 +1468,8 @@ function readRules() {
1383
1468
  *
1384
1469
  * @remarks
1385
1470
  * This proves a declaration exists in the cascade at all, which is a different question from what an
1386
- * element resolves to: {@link style} reads the winner, and a rule this finds may be overridden by
1387
- * another. Assert on this where the subject is the stylesheet, and on `style` where the subject is
1471
+ * element resolves to: {@link readStyle} reads the winner, and a rule this finds may be overridden by
1472
+ * another. Assert on this where the subject is the stylesheet, and on `readStyle` where the subject is
1388
1473
  * the rendered result.
1389
1474
  *
1390
1475
  * The match is a substring, so `findRule('.card')` finds `.card`, `.card:hover`, and
@@ -1475,6 +1560,38 @@ function extractOrphans(root, child, parent) {
1475
1560
  return [...root.querySelectorAll(`.${child}`)].filter((node) => (node.parentElement?.closest(`.${parent}`) ?? null) === null).map((node) => node.outerHTML);
1476
1561
  }
1477
1562
  /**
1563
+ * Collects the markup of every element carrying a non-empty `style` attribute and of every `<style>`
1564
+ * element, in document order, `root` included in both populations when it is an `Element`.
1565
+ *
1566
+ * @param root - The subtree to sweep. A detached element and a `DocumentFragment` both work.
1567
+ * @returns The `outerHTML` of each such element, in document order; an empty list when the markup
1568
+ * declares no style of its own.
1569
+ *
1570
+ * @remarks
1571
+ * These are the declarations the stylesheet never sees: an inline `style` attribute, wherever it
1572
+ * sits, and a `<style>` element, whatever it holds. Nothing else counts. A class and a `data-*`
1573
+ * attribute name something the cascade resolves, so neither is reported however unusual it looks;
1574
+ * an inline `style` on a `<path>` inside an SVG is reported, because a namespace changes nothing
1575
+ * about what an inline declaration is.
1576
+ *
1577
+ * A `style` attribute holding nothing but whitespace declares nothing, so it is not reported. A
1578
+ * `DocumentFragment` root contributes its descendants alone, because it is not an `Element`; a
1579
+ * `<style>` root and a root carrying an inline attribute are each reported, and an element that is a
1580
+ * `<style>` element and carries an inline attribute too is reported once.
1581
+ *
1582
+ * @example
1583
+ * ```ts
1584
+ * extractStyles(container) // []
1585
+ * ```
1586
+ */
1587
+ function extractStyles(root) {
1588
+ const elements = root instanceof Element ? [root] : [];
1589
+ elements.push(...root.querySelectorAll("*"));
1590
+ const styled = [];
1591
+ for (const element of elements) if ((element.getAttribute("style") ?? "").trim() !== "" || element.localName === "style") styled.push(element.outerHTML);
1592
+ return styled;
1593
+ }
1594
+ /**
1478
1595
  * Reads one resolved CSS property from a real browser element.
1479
1596
  *
1480
1597
  * @param element - The element whose resolved style to inspect.
@@ -1488,10 +1605,10 @@ function extractOrphans(root, child, parent) {
1488
1605
  *
1489
1606
  * @example
1490
1607
  * ```ts
1491
- * style(button, 'padding-left')
1608
+ * readStyle(button, 'padding-left')
1492
1609
  * ```
1493
1610
  */
1494
- function style(element, property) {
1611
+ function readStyle(element, property) {
1495
1612
  return getComputedStyle(element).getPropertyValue(property).trim();
1496
1613
  }
1497
1614
  /**
@@ -1509,17 +1626,17 @@ function style(element, property) {
1509
1626
  * presence.
1510
1627
  *
1511
1628
  * Resolution is inheritance, so a token declared on `:root` reads from any mounted descendant and
1512
- * from an unmounted element reads as `''`. Use {@link rootToken} where the declaration is the
1629
+ * from an unmounted element reads as `''`. Use {@link readRootToken} where the declaration is the
1513
1630
  * document's.
1514
1631
  *
1515
1632
  * @example
1516
1633
  * ```ts
1517
- * token(panel, 'surface') // '#ffffff'
1518
- * token(panel, '--surface') // '#ffffff'
1634
+ * readToken(panel, 'surface') // '#ffffff'
1635
+ * readToken(panel, '--surface') // '#ffffff'
1519
1636
  * ```
1520
1637
  */
1521
- function token(element, name) {
1522
- return style(element, name.startsWith("--") ? name : `--${name}`);
1638
+ function readToken(element, name) {
1639
+ return readStyle(element, name.startsWith("--") ? name : `--${name}`);
1523
1640
  }
1524
1641
  /**
1525
1642
  * Reads one custom property from the document element.
@@ -1528,18 +1645,18 @@ function token(element, name) {
1528
1645
  * @returns The resolved value, trimmed; an empty string when the document declares no such property.
1529
1646
  *
1530
1647
  * @remarks
1531
- * This is {@link token} against `document.documentElement`, which is where a theme declares its
1648
+ * This is {@link readToken} against `document.documentElement`, which is where a theme declares its
1532
1649
  * tokens and where a `[data-theme]` switch retunes them. It exists as its own name because that
1533
1650
  * element is the one a token question is nearly always about, and naming it at every call site
1534
1651
  * buries the question.
1535
1652
  *
1536
1653
  * @example
1537
1654
  * ```ts
1538
- * rootToken('surface')
1655
+ * readRootToken('surface')
1539
1656
  * ```
1540
1657
  */
1541
- function rootToken(name) {
1542
- return token(document.documentElement, name);
1658
+ function readRootToken(name) {
1659
+ return readToken(document.documentElement, name);
1543
1660
  }
1544
1661
  /**
1545
1662
  * Reads one resolved CSS length as a number of pixels.
@@ -1556,19 +1673,57 @@ function rootToken(name) {
1556
1673
  *
1557
1674
  * An unparsable value reads as `0` rather than as absence, because every caller of this is measuring
1558
1675
  * and `'auto'`, `'none'`, and `''` each contribute no pixels to what a reader sees. Where the
1559
- * distinction matters, read the text with {@link style} instead.
1676
+ * distinction matters, read the text with {@link readStyle} instead.
1560
1677
  *
1561
1678
  * @example
1562
1679
  * ```ts
1563
- * pixels(button, 'padding-left') // 12
1564
- * pixels(button, 'width') // 0 when the width resolves to `auto`
1680
+ * readPixels(button, 'padding-left') // 12
1681
+ * readPixels(button, 'width') // 0 when the width resolves to `auto`
1565
1682
  * ```
1566
1683
  */
1567
- function pixels(element, property) {
1568
- const measured = Number.parseFloat(style(element, property));
1684
+ function readPixels(element, property) {
1685
+ const measured = Number.parseFloat(readStyle(element, property));
1569
1686
  return Number.isFinite(measured) ? measured : 0;
1570
1687
  }
1571
1688
  /**
1689
+ * Measures the row the document's own content ends on, in document coordinates.
1690
+ *
1691
+ * @returns The content edge, rounded up to a whole row.
1692
+ *
1693
+ * @remarks
1694
+ * The body's box is not the document's height: it is the larger of the content and the pane. A pane
1695
+ * taller than the document stretches it, and `document.body.getBoundingClientRect()`,
1696
+ * `body.scrollHeight`, `body.offsetHeight`, and `documentElement.scrollHeight` all read that pane
1697
+ * back rather than the content under it. So a caller that has staged a pane taller than the
1698
+ * document cannot find its way down again from any of them. This reading can, because it is taken
1699
+ * over the elements inside the body rather than over the box around them: a document of fixed
1700
+ * content answers the same number under a short pane and a tall one, and a document laid out
1701
+ * against the viewport answers what that viewport actually laid out.
1702
+ *
1703
+ * Each element contributes its client rectangle's bottom edge in document coordinates plus its own
1704
+ * bottom margin, which sits outside that rectangle, and the largest contribution wins. Taking the
1705
+ * largest is what handles a collapsed margin without asking whether it collapsed: a child margin
1706
+ * that collapses out through its parent is counted once, at the child, and one the parent's padding
1707
+ * holds in is counted once, at the parent. The body's and the root's own bottom padding and margin
1708
+ * sit under every child rather than beside them, so they are added after the walk.
1709
+ *
1710
+ * The sum is rounded up because a box can end part way through a row and a frame cannot hold part
1711
+ * of one.
1712
+ *
1713
+ * @example
1714
+ * ```ts
1715
+ * const covered = measureContent()
1716
+ * ```
1717
+ */
1718
+ function measureContent() {
1719
+ let edge = 0;
1720
+ for (const element of document.body.querySelectorAll("*")) {
1721
+ const bottom = element.getBoundingClientRect().bottom + window.scrollY + readPixels(element, "margin-bottom");
1722
+ if (bottom > edge) edge = bottom;
1723
+ }
1724
+ return Math.ceil(edge + readPixels(document.body, "padding-bottom") + readPixels(document.body, "margin-bottom") + readPixels(document.documentElement, "padding-bottom") + readPixels(document.documentElement, "margin-bottom"));
1725
+ }
1726
+ /**
1572
1727
  * Sets the tester's viewport and renders the runner's pane at the size that viewport claims.
1573
1728
  *
1574
1729
  * @param width - The viewport width in CSS pixels.
@@ -1587,12 +1742,17 @@ function pixels(element, property) {
1587
1742
  * therefore unscaled and lifted to the window's own origin for the shot. The `iframe[data-vitest]`
1588
1743
  * selector and the `--tester-transform`, `--tester-margin-left`, `--viewport-width`, and
1589
1744
  * `--viewport-height` custom properties are the runner's, so a Vitest release that renames any of
1590
- * them reddens the size check below rather than writing a wrong frame.
1745
+ * them reddens the size check that follows rather than writing a wrong frame.
1591
1746
  *
1592
1747
  * Hand the pane straight back with {@link releasePane}. A tester pinned at a viewport taller than
1593
1748
  * the window puts its lower half beyond what a pointer can reach, so an ordinary press then fails
1594
1749
  * as a control outside the viewport, in a test that took no picture at all.
1595
1750
  *
1751
+ * This is a capture's staging alone. A suite that resizes the tester for a journey — a breakpoint
1752
+ * to drive, a variant to act at — calls `page.viewport` from `vitest/browser` and leaves the tester
1753
+ * there. Staging and releasing as a pair resizes and then undoes the resize, so the journey step
1754
+ * after it runs at the size the file started at.
1755
+ *
1596
1756
  * The rule is declared rather than written inline, because the runner writes its own scale onto the
1597
1757
  * pane as inline custom properties and rewrites them whenever the tester resizes. A declared rule
1598
1758
  * marked important outranks an inline value and survives every rewrite. It finds the pane by the
@@ -1602,12 +1762,18 @@ function pixels(element, property) {
1602
1762
  * The wait is two frames rather than a delay: the first carries the resize into layout and the
1603
1763
  * second is the paint a screenshot reads.
1604
1764
  *
1765
+ * The viewport the tester had before this staging is written onto that rule element as the
1766
+ * {@link CAPTURE_PANE} value, in `<width>x<height>` form, and {@link releasePane} hands it back.
1767
+ * Staging an already-staged pane leaves that value alone, so a capture that stages a second time to
1768
+ * cover a taller document still releases to the viewport the tester started with.
1769
+ *
1605
1770
  * @example
1606
1771
  * ```ts
1607
1772
  * await stagePane(390, 844)
1608
1773
  * ```
1609
1774
  */
1610
1775
  async function stagePane(width, height) {
1776
+ const viewport = `${String(window.innerWidth)}x${String(window.innerHeight)}`;
1611
1777
  await page.viewport(width, height);
1612
1778
  const frame = window.frameElement;
1613
1779
  const pane = frame?.parentElement;
@@ -1616,7 +1782,7 @@ async function stagePane(width, height) {
1616
1782
  pane.setAttribute(CAPTURE_PANE, "");
1617
1783
  if (owner.querySelector(`style[data-capture-pane]`) === null) {
1618
1784
  const rule = owner.createElement("style");
1619
- rule.setAttribute(CAPTURE_PANE, "");
1785
+ rule.setAttribute(CAPTURE_PANE, viewport);
1620
1786
  rule.textContent = [
1621
1787
  `[${CAPTURE_PANE}],:has(>iframe[data-vitest])`,
1622
1788
  "{--tester-transform:none !important;--tester-margin-left:0px !important}",
@@ -1633,33 +1799,50 @@ async function stagePane(width, height) {
1633
1799
  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`);
1634
1800
  }
1635
1801
  /**
1636
- * Hands the tester pane back to the runner's own layout.
1802
+ * Hands the tester pane back to the runner's own layout, at the viewport it had before staging.
1637
1803
  *
1638
1804
  * @remarks
1639
1805
  * A staged pane is the runner's fitting scale suppressed, so a pane left staged outlives the capture
1640
1806
  * that needed it and every later act in the file happens on a surface the runner is no longer
1641
1807
  * fitting to its window. What that costs is not a wrong picture: it is a control whose page
1642
1808
  * coordinates fall outside the pane, which the runner's own layout then intercepts, so an ordinary
1643
- * press fails with the voice of a control that is covered. Calling this on an unstaged pane does
1809
+ * press fails with the voice of a control that is covered.
1810
+ *
1811
+ * The viewport goes back too, because a capture resizes the tester and the size it chose belongs to
1812
+ * the frame rather than to the file: a test that runs after one and reads a breakpoint would
1813
+ * otherwise read the last capture's variant. The size comes off the {@link CAPTURE_PANE} value
1814
+ * {@link stagePane} wrote onto the rule element, which is the reading taken before the first
1815
+ * staging. Calling this on an unstaged pane finds no such value, so it changes nothing and resizes
1644
1816
  * nothing.
1645
1817
  *
1818
+ * That hand-back is what makes {@link stagePane} and this pair a capture's staging rather than a
1819
+ * resize: the pair puts the tester back where it found it, so a suite that used it to reach a
1820
+ * breakpoint runs its next step at the old size. Call `page.viewport` from `vitest/browser` for a
1821
+ * journey's own size, and leave this pair to the capture.
1822
+ *
1646
1823
  * @example
1647
1824
  * ```ts
1648
- * releasePane()
1825
+ * await releasePane()
1649
1826
  * ```
1650
1827
  */
1651
- function releasePane() {
1828
+ async function releasePane() {
1652
1829
  const pane = window.frameElement?.parentElement;
1830
+ const rule = pane?.ownerDocument.querySelector(`style[${CAPTURE_PANE}]`);
1831
+ const viewport = rule?.getAttribute("data-capture-pane")?.split("x") ?? [];
1653
1832
  pane?.removeAttribute(CAPTURE_PANE);
1654
- pane?.ownerDocument.querySelector(`style[${CAPTURE_PANE}]`)?.remove();
1833
+ rule?.remove();
1834
+ const width = Number(viewport[0]);
1835
+ const height = Number(viewport[1]);
1836
+ if (Number.isFinite(width) && Number.isFinite(height)) await page.viewport(width, height);
1655
1837
  }
1656
1838
  /**
1657
1839
  * Shoots one frame at one viewport size and proves the file on disk holds this run's bytes.
1658
1840
  *
1659
1841
  * @param options - The path to write, the viewport to shoot at, and the element to shoot.
1660
1842
  * @returns The absolute path of the written frame, after it has been read back and matched.
1661
- * @throws Thrown when the pane cannot be staged, when the provider wrote the frame somewhere else,
1662
- * and when the bytes on disk are not the ones this shot produced.
1843
+ * @throws Thrown when the pane cannot be staged, when the document's height never settles under
1844
+ * {@link CAPTURE_STAGINGS} restagings, when the provider wrote the frame somewhere else, and when
1845
+ * the bytes on disk are not the ones this shot produced.
1663
1846
  *
1664
1847
  * @remarks
1665
1848
  * The path a screenshot call returns is the path it meant to write, so it is not evidence a file
@@ -1669,8 +1852,41 @@ function releasePane() {
1669
1852
  * path, so the two are compared by the segments that survive resolving `.` and `..` lexically — the
1670
1853
  * refusal is what a provider resolving that path against a different base would trip.
1671
1854
  *
1855
+ * The frame covers the whole document at `options.width`, whatever `options.height` is. The
1856
+ * provider shoots the tester's body in the top-level page's own coordinates, so a document taller
1857
+ * than the pane is painted for the pane's height and the rows below it are the runner's page rather
1858
+ * than the document — a frame that reads as the surface down to the fold and as bare canvas after
1859
+ * it. The document is therefore laid out at the declared viewport first and, where it is taller
1860
+ * than that, the pane is staged again at the height the document needs, for the shot alone.
1861
+ *
1862
+ * That height is {@link measureContent}, never less than `options.height`, because the declared
1863
+ * viewport is the smallest frame a variant asks for. The reading is the content's own edge rather
1864
+ * than the body's box: the box is the larger of the content and the pane, so it stretches with
1865
+ * every pane staged over it and a capture that staged too tall a pane could not read its way back
1866
+ * down. Rounding up is what covers a body ending part way through a row, which the box does and an
1867
+ * integer scroll height does not.
1868
+ *
1869
+ * The edge is read again after every staging, because a rule bound to the viewport height — a `vh`
1870
+ * length, a fixed footer, a full-height panel — lays the document out taller against the taller
1871
+ * pane, so a surface built out of those photographs as its scrolled-open self rather than as one
1872
+ * screen, and the reading taken before that staging is stale by exactly what the reflow added.
1873
+ * Restaging at the edge alone converges on such a document without arriving: each staging closes
1874
+ * the same fraction of what is left, so a rule keeping half the pane reads 1322, 1561, 1681, and
1875
+ * 1741 against a fixed point of 1800. Each staging therefore carries the growth the one before it
1876
+ * produced — the pane is the edge plus that growth — which lands on the fixed point rather than
1877
+ * creeping up to it. The first staging carries no growth, because nothing has grown yet, so a
1878
+ * document of fixed content is staged at its own edge and shot there rather than at a pane the
1879
+ * overshoot stretched.
1880
+ *
1881
+ * The re-reading stops when the pane and the edge agree, which is the pane the shot is taken at. A
1882
+ * rule that adds height with every pane never reaches that point, so the re-reading is bounded by
1883
+ * {@link CAPTURE_STAGINGS} and the shot is refused with
1884
+ * `Capture frame at <path> never settled after <n> restagings: <h> over a <h> pane` rather than
1885
+ * written at a height that is already wrong.
1886
+ *
1672
1887
  * Omit `options.element` to shoot the whole page. The pane is staged for the frame and released
1673
- * before this returns, on the failing path as well as the passing one.
1888
+ * before this returns, on the failing path as well as the passing one, which hands the tester back
1889
+ * the viewport it had before the first staging.
1674
1890
  *
1675
1891
  * @example
1676
1892
  * ```ts
@@ -1680,6 +1896,17 @@ function releasePane() {
1680
1896
  async function captureFrame(options) {
1681
1897
  try {
1682
1898
  await stagePane(options.width, options.height);
1899
+ let pane = options.height;
1900
+ let covered = Math.max(measureContent(), options.height);
1901
+ let growth = 0;
1902
+ for (let staging = 0; pane !== covered; staging += 1) {
1903
+ if (staging === 4) throw new Error(`Capture frame at ${options.path} never settled after ${String(4)} restagings: ${String(covered)} over a ${String(pane)} pane`);
1904
+ pane = covered + growth;
1905
+ await stagePane(options.width, pane);
1906
+ const reading = Math.max(measureContent(), options.height);
1907
+ growth = Math.max(0, reading - covered);
1908
+ covered = reading;
1909
+ }
1683
1910
  const shot = options.element === void 0 ? await page.screenshot({
1684
1911
  path: options.path,
1685
1912
  base64: true
@@ -1698,10 +1925,57 @@ async function captureFrame(options) {
1698
1925
  if (await commands.readFile(shot.path, "base64") !== shot.base64) throw new Error(`Capture frame at ${options.path} is not the one this run shot`);
1699
1926
  return shot.path;
1700
1927
  } finally {
1701
- releasePane();
1928
+ await releasePane();
1702
1929
  }
1703
1930
  }
1704
1931
  /**
1932
+ * Reads one written frame back and reports its size and the color its bottom row paints.
1933
+ *
1934
+ * @param path - The frame's absolute path, as `captureFrame` returns it.
1935
+ * @returns The frame's size in device pixels and its floor.
1936
+ * @throws Thrown when the runner cannot read the path, when the bytes there are not an image this
1937
+ * browser decodes, and when the browser hands out no 2D canvas to measure them on.
1938
+ *
1939
+ * @remarks
1940
+ * The reading comes off the written file rather than off the document that produced it, which is
1941
+ * what makes it evidence about a capture: the browser's own image decoding and an
1942
+ * `OffscreenCanvas` answer for the pixels a viewer would see, so a frame that ends on the runner's
1943
+ * canvas reports that canvas whatever the document's style resolves to. Pass the path the provider
1944
+ * resolved and `captureFrame` returned; the runner's `readFile` command resolves a relative path
1945
+ * against its own root rather than against the calling test file, so a relative path names a file
1946
+ * somewhere else.
1947
+ *
1948
+ * @example
1949
+ * ```ts
1950
+ * const reading = await readFrame(written)
1951
+ * ```
1952
+ */
1953
+ async function readFrame(path) {
1954
+ const encoded = await commands.readFile(path, "base64").catch((cause) => {
1955
+ throw new Error(`Capture frame at ${path} could not be read`, { cause });
1956
+ });
1957
+ const image = new Image();
1958
+ image.src = `data:image/png;base64,${encoded}`;
1959
+ await image.decode().catch((cause) => {
1960
+ throw new Error(`Capture frame at ${path} is not an image this browser decodes`, { cause });
1961
+ });
1962
+ const context = new OffscreenCanvas(image.width, image.height).getContext("2d");
1963
+ if (context === null) throw new Error(`Capture frame at ${path} cannot be measured without a 2D canvas`);
1964
+ context.drawImage(image, 0, 0);
1965
+ const row = context.getImageData(0, image.height - 1, image.width, 1).data;
1966
+ const red = row[0];
1967
+ const green = row[1];
1968
+ const blue = row[2];
1969
+ const alpha = row[3];
1970
+ let single = red !== void 0 && green !== void 0 && blue !== void 0;
1971
+ for (let pixel = 4; single && pixel < row.length; pixel += 4) single = row[pixel] === red && row[pixel + 1] === green && row[pixel + 2] === blue && row[pixel + 3] === alpha;
1972
+ return {
1973
+ width: image.width,
1974
+ height: image.height,
1975
+ floor: single ? `rgb(${String(red)}, ${String(green)}, ${String(blue)})` : void 0
1976
+ };
1977
+ }
1978
+ /**
1705
1979
  * Expands a capture registry across every variant into the filenames a complete portfolio holds.
1706
1980
  *
1707
1981
  * @param states - The registered state names.
@@ -1832,7 +2106,7 @@ function createPortfolio(options) {
1832
2106
  return {
1833
2107
  variant: options.variant,
1834
2108
  files,
1835
- get states() {
2109
+ get placements() {
1836
2110
  return [...placed];
1837
2111
  },
1838
2112
  get paths() {
@@ -1968,6 +2242,6 @@ function createJournal() {
1968
2242
  };
1969
2243
  }
1970
2244
  //#endregion
1971
- export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, build, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, colorEqual, commitInput, contrast, createChannel, createDragEvent, createJournal, createPointerEvent, createPortfolio, describeFocus, describeTree, expandCaptures, extractOrphans, fillAccessible, findKeyframes, findRule, isOutsideViewport, isReachable, isRendered, measureContrast, measureLuminance, mount, parseColor, pixels, pressKeys, readBackdrop, readCascade, readFocus, readLayers, readName, readPage, readPerception, readRing, readRole, readRows, readRules, readStates, readText, readValue, releasePane, removeDatabase, render, resolveAccessible, resolveRendered, rgba, rootToken, stagePane, style, token, traverseAccessible, typeAccessible, typeInput, waitForFrame };
2245
+ export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CAPTURE_STAGINGS, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, build, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, commitInput, computeNamePattern, createChannel, createDragEvent, createJournal, createPointerEvent, createPortfolio, describeFocus, describeTree, expandCaptures, extractOrphans, extractStyles, fillAccessible, findKeyframes, findRule, isOutsideViewport, isReachable, isRendered, matchesColor, measureContent, measureContrast, measureLuminance, mount, parseCSSColor, parseColor, readBackdrop, readCascade, readClasses, readContrast, readFocus, readFrame, readLayers, readName, readPage, readPerception, readPixels, readRing, readRole, readRootToken, readRows, readRules, readStates, readStyle, readText, readToken, readValue, releasePane, removeDatabase, render, resolveAccessible, resolveRendered, stagePane, traverseAccessible, typeAccessible, typeInput, waitForFrame };
1972
2246
 
1973
2247
  //# sourceMappingURL=index.js.map