@orkestrel/test 0.0.11 → 0.0.12

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
@@ -191,9 +210,9 @@ function isOutsideViewport(rectangle) {
191
210
  * Determines whether a person can click one element where it currently 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`);
@@ -456,6 +521,13 @@ async function traverseAccessible(name) {
456
521
  * visually-hidden content.
457
522
  * @throws When the named region is absent, hidden, or ambiguous.
458
523
  *
524
+ * @remarks
525
+ * One pass answers this, because absence and concealment share the refusal. The pass asks the role
526
+ * engine over the elements the accessibility tree presents, so the name matched is the one a screen
527
+ * reader announces and an `aria-hidden` glyph in a heading a region points at contributes nothing
528
+ * to it. A region the tree does not present is refused as not visible, which is what a reader
529
+ * perceiving nothing there means.
530
+ *
459
531
  * @example
460
532
  * ```ts
461
533
  * readPerception('Run')
@@ -473,8 +545,7 @@ function readPerception(name) {
473
545
  "tabpanel"
474
546
  ]) for (const element of page.getByRole(role, {
475
547
  name,
476
- exact: true,
477
- includeHidden: true
548
+ exact: true
478
549
  }).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
479
550
  const visible = matches.filter((element) => {
480
551
  const rectangle = element.getBoundingClientRect();
@@ -834,8 +905,8 @@ function build(tag, options) {
834
905
  * @remarks
835
906
  * What this buys is the composition, not the attachment: the `append` method returns `void`, and
836
907
  * 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.
908
+ * returns its fixture through it, and the {@link parseCSSColor} helper probes through
909
+ * `mount(build('span'))`. A bare `append` call breaks each of those call sites.
839
910
  *
840
911
  * Being connected is what the attachment then buys: `getComputedStyle` resolves against the shipped
841
912
  * cascade, custom properties inherit from `:root`, and the element lays out a real box. A detached
@@ -1024,20 +1095,20 @@ function parseColor(value) {
1024
1095
  * Refusal is the CSSOM's: an expression it will not parse leaves the probe's inline `color` empty
1025
1096
  * and this returns `undefined`. A `var()` naming an undeclared custom property is not refused,
1026
1097
  * 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.
1098
+ * missing token asserts on {@link readToken} rather than on this.
1028
1099
  *
1029
1100
  * @example
1030
1101
  * ```ts
1031
- * rgba('rebeccapurple') // [102, 51, 153, 1]
1032
- * rgba('not-a-color') // undefined
1102
+ * parseCSSColor('rebeccapurple') // [102, 51, 153, 1]
1103
+ * parseCSSColor('not-a-color') // undefined
1033
1104
  * ```
1034
1105
  */
1035
- function rgba(value) {
1106
+ function parseCSSColor(value) {
1036
1107
  const probe = mount(build("span"));
1037
1108
  try {
1038
1109
  probe.style.color = value;
1039
1110
  if (probe.style.color === "") return void 0;
1040
- return parseColor(style(probe, "color"));
1111
+ return parseColor(readStyle(probe, "color"));
1041
1112
  } finally {
1042
1113
  probe.remove();
1043
1114
  }
@@ -1047,11 +1118,11 @@ function rgba(value) {
1047
1118
  *
1048
1119
  * @param first - A CSS color expression or an already-parsed color.
1049
1120
  * @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,
1121
+ * @returns True if every channel and the alpha agree within the tolerance; false otherwise,
1051
1122
  * including when either side names no readable color.
1052
1123
  *
1053
1124
  * @remarks
1054
- * Each string side is resolved through {@link rgba}, so a keyword, a token reference, and the
1125
+ * Each string side is resolved through {@link parseCSSColor}, so a keyword, a token reference, and the
1055
1126
  * `rgb()` the engine computes for either of them compare equal without a test converting anything
1056
1127
  * first. A side that resolves to nothing makes the answer `false` rather than a throw, because this
1057
1128
  * is a predicate.
@@ -1063,13 +1134,13 @@ function rgba(value) {
1063
1134
  *
1064
1135
  * @example
1065
1136
  * ```ts
1066
- * colorEqual('rebeccapurple', 'rgb(102, 51, 153)') // true
1067
- * colorEqual('red', [0, 0, 255, 1]) // false
1137
+ * matchesColor('rebeccapurple', 'rgb(102, 51, 153)') // true
1138
+ * matchesColor('red', [0, 0, 255, 1]) // false
1068
1139
  * ```
1069
1140
  */
1070
- function colorEqual(first, second) {
1071
- const left = typeof first === "string" ? rgba(first) : first;
1072
- const right = typeof second === "string" ? rgba(second) : second;
1141
+ function matchesColor(first, second) {
1142
+ const left = typeof first === "string" ? parseCSSColor(first) : first;
1143
+ const right = typeof second === "string" ? parseCSSColor(second) : second;
1073
1144
  if (left === void 0 || right === void 0) return false;
1074
1145
  const tolerance = .5;
1075
1146
  const [leftRed, leftGreen, leftBlue, leftAlpha] = left;
@@ -1155,7 +1226,7 @@ function measureContrast(front, back) {
1155
1226
  * the walk stops at the first fully opaque layer, because nothing above that layer is visible.
1156
1227
  *
1157
1228
  * 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,
1229
+ * surface exactly when its last layer's alpha is `1`. {@link readContrast} refuses on that reading,
1159
1230
  * which no comparison of composited colors can replace — 64 half-transparent layers composite to
1160
1231
  * the same channels over opposite floors, because the floor's remaining share falls below the last
1161
1232
  * bit a channel carries.
@@ -1235,11 +1306,11 @@ function readBackdrop(element, floor) {
1235
1306
  * @example
1236
1307
  * ```ts
1237
1308
  * 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
1309
+ * readContrast(requireValue(container.firstElementChild)) // 21
1310
+ * readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
1240
1311
  * ```
1241
1312
  */
1242
- function contrast(element, floor) {
1313
+ function readContrast(element, floor) {
1243
1314
  const foreground = parseColor(getComputedStyle(element).color);
1244
1315
  if (foreground === void 0) throw new Error("Computed foreground color is unavailable");
1245
1316
  const layers = readLayers(element);
@@ -1334,6 +1405,34 @@ function readCascade() {
1334
1405
  return known;
1335
1406
  }
1336
1407
  /**
1408
+ * Collects every class token the markup under one root carries.
1409
+ *
1410
+ * @param root - The subtree to sweep. A detached element and a `DocumentFragment` both work.
1411
+ * @returns The class tokens in document order of first sighting; an empty set for markup carrying no
1412
+ * class at all.
1413
+ *
1414
+ * @remarks
1415
+ * The root's own classes count when the root is an `Element`, so a `DocumentFragment` contributes
1416
+ * its descendants alone. Every element is read through `classList`, which is what makes an SVG
1417
+ * element count the same as an HTML one: `className` on an SVG element is an `SVGAnimatedString`
1418
+ * rather than a string, and a reader splitting that value finds nothing.
1419
+ *
1420
+ * This is the authored half of a class conformance check and {@link readCascade} is the defined
1421
+ * half, so the difference between them is the set of classes the markup uses and no loaded
1422
+ * stylesheet declares.
1423
+ *
1424
+ * @example
1425
+ * ```ts
1426
+ * [...readClasses(container)].filter((name) => !readCascade().has(name))
1427
+ * ```
1428
+ */
1429
+ function readClasses(root) {
1430
+ const authored = /* @__PURE__ */ new Set();
1431
+ if (root instanceof Element) for (const name of root.classList) authored.add(name);
1432
+ for (const element of root.querySelectorAll("*")) for (const name of element.classList) authored.add(name);
1433
+ return authored;
1434
+ }
1435
+ /**
1337
1436
  * Collects every rule the stylesheets loaded into this document hold, nested grouping rules
1338
1437
  * included.
1339
1438
  *
@@ -1383,8 +1482,8 @@ function readRules() {
1383
1482
  *
1384
1483
  * @remarks
1385
1484
  * 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
1485
+ * element resolves to: {@link readStyle} reads the winner, and a rule this finds may be overridden by
1486
+ * another. Assert on this where the subject is the stylesheet, and on `readStyle` where the subject is
1388
1487
  * the rendered result.
1389
1488
  *
1390
1489
  * The match is a substring, so `findRule('.card')` finds `.card`, `.card:hover`, and
@@ -1475,6 +1574,38 @@ function extractOrphans(root, child, parent) {
1475
1574
  return [...root.querySelectorAll(`.${child}`)].filter((node) => (node.parentElement?.closest(`.${parent}`) ?? null) === null).map((node) => node.outerHTML);
1476
1575
  }
1477
1576
  /**
1577
+ * Collects the markup of every element carrying a non-empty `style` attribute and of every `<style>`
1578
+ * element, in document order, `root` included in both populations when it is an `Element`.
1579
+ *
1580
+ * @param root - The subtree to sweep. A detached element and a `DocumentFragment` both work.
1581
+ * @returns The `outerHTML` of each such element, in document order; an empty list when the markup
1582
+ * declares no style of its own.
1583
+ *
1584
+ * @remarks
1585
+ * These are the declarations the stylesheet never sees: an inline `style` attribute, wherever it
1586
+ * sits, and a `<style>` element, whatever it holds. Nothing else counts. A class and a `data-*`
1587
+ * attribute name something the cascade resolves, so neither is reported however unusual it looks;
1588
+ * an inline `style` on a `<path>` inside an SVG is reported, because a namespace changes nothing
1589
+ * about what an inline declaration is.
1590
+ *
1591
+ * A `style` attribute holding nothing but whitespace declares nothing, so it is not reported. A
1592
+ * `DocumentFragment` root contributes its descendants alone, because it is not an `Element`; a
1593
+ * `<style>` root and a root carrying an inline attribute are each reported, and an element that is a
1594
+ * `<style>` element and carries an inline attribute too is reported once.
1595
+ *
1596
+ * @example
1597
+ * ```ts
1598
+ * extractStyles(container) // []
1599
+ * ```
1600
+ */
1601
+ function extractStyles(root) {
1602
+ const elements = root instanceof Element ? [root] : [];
1603
+ elements.push(...root.querySelectorAll("*"));
1604
+ const styled = [];
1605
+ for (const element of elements) if ((element.getAttribute("style") ?? "").trim() !== "" || element.localName === "style") styled.push(element.outerHTML);
1606
+ return styled;
1607
+ }
1608
+ /**
1478
1609
  * Reads one resolved CSS property from a real browser element.
1479
1610
  *
1480
1611
  * @param element - The element whose resolved style to inspect.
@@ -1488,10 +1619,10 @@ function extractOrphans(root, child, parent) {
1488
1619
  *
1489
1620
  * @example
1490
1621
  * ```ts
1491
- * style(button, 'padding-left')
1622
+ * readStyle(button, 'padding-left')
1492
1623
  * ```
1493
1624
  */
1494
- function style(element, property) {
1625
+ function readStyle(element, property) {
1495
1626
  return getComputedStyle(element).getPropertyValue(property).trim();
1496
1627
  }
1497
1628
  /**
@@ -1509,17 +1640,17 @@ function style(element, property) {
1509
1640
  * presence.
1510
1641
  *
1511
1642
  * 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
1643
+ * from an unmounted element reads as `''`. Use {@link readRootToken} where the declaration is the
1513
1644
  * document's.
1514
1645
  *
1515
1646
  * @example
1516
1647
  * ```ts
1517
- * token(panel, 'surface') // '#ffffff'
1518
- * token(panel, '--surface') // '#ffffff'
1648
+ * readToken(panel, 'surface') // '#ffffff'
1649
+ * readToken(panel, '--surface') // '#ffffff'
1519
1650
  * ```
1520
1651
  */
1521
- function token(element, name) {
1522
- return style(element, name.startsWith("--") ? name : `--${name}`);
1652
+ function readToken(element, name) {
1653
+ return readStyle(element, name.startsWith("--") ? name : `--${name}`);
1523
1654
  }
1524
1655
  /**
1525
1656
  * Reads one custom property from the document element.
@@ -1528,18 +1659,18 @@ function token(element, name) {
1528
1659
  * @returns The resolved value, trimmed; an empty string when the document declares no such property.
1529
1660
  *
1530
1661
  * @remarks
1531
- * This is {@link token} against `document.documentElement`, which is where a theme declares its
1662
+ * This is {@link readToken} against `document.documentElement`, which is where a theme declares its
1532
1663
  * tokens and where a `[data-theme]` switch retunes them. It exists as its own name because that
1533
1664
  * element is the one a token question is nearly always about, and naming it at every call site
1534
1665
  * buries the question.
1535
1666
  *
1536
1667
  * @example
1537
1668
  * ```ts
1538
- * rootToken('surface')
1669
+ * readRootToken('surface')
1539
1670
  * ```
1540
1671
  */
1541
- function rootToken(name) {
1542
- return token(document.documentElement, name);
1672
+ function readRootToken(name) {
1673
+ return readToken(document.documentElement, name);
1543
1674
  }
1544
1675
  /**
1545
1676
  * Reads one resolved CSS length as a number of pixels.
@@ -1556,19 +1687,57 @@ function rootToken(name) {
1556
1687
  *
1557
1688
  * An unparsable value reads as `0` rather than as absence, because every caller of this is measuring
1558
1689
  * 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.
1690
+ * distinction matters, read the text with {@link readStyle} instead.
1560
1691
  *
1561
1692
  * @example
1562
1693
  * ```ts
1563
- * pixels(button, 'padding-left') // 12
1564
- * pixels(button, 'width') // 0 when the width resolves to `auto`
1694
+ * readPixels(button, 'padding-left') // 12
1695
+ * readPixels(button, 'width') // 0 when the width resolves to `auto`
1565
1696
  * ```
1566
1697
  */
1567
- function pixels(element, property) {
1568
- const measured = Number.parseFloat(style(element, property));
1698
+ function readPixels(element, property) {
1699
+ const measured = Number.parseFloat(readStyle(element, property));
1569
1700
  return Number.isFinite(measured) ? measured : 0;
1570
1701
  }
1571
1702
  /**
1703
+ * Measures the row the document's own content ends on, in document coordinates.
1704
+ *
1705
+ * @returns The content edge, rounded up to a whole row.
1706
+ *
1707
+ * @remarks
1708
+ * The body's box is not the document's height: it is the larger of the content and the pane. A pane
1709
+ * taller than the document stretches it, and `document.body.getBoundingClientRect()`,
1710
+ * `body.scrollHeight`, `body.offsetHeight`, and `documentElement.scrollHeight` all read that pane
1711
+ * back rather than the content under it. So a caller that has staged a pane taller than the
1712
+ * document cannot find its way down again from any of them. This reading can, because it is taken
1713
+ * over the elements inside the body rather than over the box around them: a document of fixed
1714
+ * content answers the same number under a short pane and a tall one, and a document laid out
1715
+ * against the viewport answers what that viewport actually laid out.
1716
+ *
1717
+ * Each element contributes its client rectangle's bottom edge in document coordinates plus its own
1718
+ * bottom margin, which sits outside that rectangle, and the largest contribution wins. Taking the
1719
+ * largest is what handles a collapsed margin without asking whether it collapsed: a child margin
1720
+ * that collapses out through its parent is counted once, at the child, and one the parent's padding
1721
+ * holds in is counted once, at the parent. The body's and the root's own bottom padding and margin
1722
+ * sit under every child rather than beside them, so they are added after the walk.
1723
+ *
1724
+ * The sum is rounded up because a box can end part way through a row and a frame cannot hold part
1725
+ * of one.
1726
+ *
1727
+ * @example
1728
+ * ```ts
1729
+ * const covered = measureContent()
1730
+ * ```
1731
+ */
1732
+ function measureContent() {
1733
+ let edge = 0;
1734
+ for (const element of document.body.querySelectorAll("*")) {
1735
+ const bottom = element.getBoundingClientRect().bottom + window.scrollY + readPixels(element, "margin-bottom");
1736
+ if (bottom > edge) edge = bottom;
1737
+ }
1738
+ return Math.ceil(edge + readPixels(document.body, "padding-bottom") + readPixels(document.body, "margin-bottom") + readPixels(document.documentElement, "padding-bottom") + readPixels(document.documentElement, "margin-bottom"));
1739
+ }
1740
+ /**
1572
1741
  * Sets the tester's viewport and renders the runner's pane at the size that viewport claims.
1573
1742
  *
1574
1743
  * @param width - The viewport width in CSS pixels.
@@ -1593,6 +1762,11 @@ function pixels(element, property) {
1593
1762
  * the window puts its lower half beyond what a pointer can reach, so an ordinary press then fails
1594
1763
  * as a control outside the viewport, in a test that took no picture at all.
1595
1764
  *
1765
+ * This is a capture's staging alone. A suite that resizes the tester for a journey — a breakpoint
1766
+ * to drive, a variant to act at — calls `page.viewport` from `vitest/browser` and leaves the tester
1767
+ * there. Staging and releasing as a pair resizes and then undoes the resize, so the journey step
1768
+ * after it runs at the size the file started at.
1769
+ *
1596
1770
  * The rule is declared rather than written inline, because the runner writes its own scale onto the
1597
1771
  * pane as inline custom properties and rewrites them whenever the tester resizes. A declared rule
1598
1772
  * marked important outranks an inline value and survives every rewrite. It finds the pane by the
@@ -1602,12 +1776,18 @@ function pixels(element, property) {
1602
1776
  * The wait is two frames rather than a delay: the first carries the resize into layout and the
1603
1777
  * second is the paint a screenshot reads.
1604
1778
  *
1779
+ * The viewport the tester had before this staging is written onto that rule element as the
1780
+ * {@link CAPTURE_PANE} value, in `<width>x<height>` form, and {@link releasePane} hands it back.
1781
+ * Staging an already-staged pane leaves that value alone, so a capture that stages a second time to
1782
+ * cover a taller document still releases to the viewport the tester started with.
1783
+ *
1605
1784
  * @example
1606
1785
  * ```ts
1607
1786
  * await stagePane(390, 844)
1608
1787
  * ```
1609
1788
  */
1610
1789
  async function stagePane(width, height) {
1790
+ const viewport = `${String(window.innerWidth)}x${String(window.innerHeight)}`;
1611
1791
  await page.viewport(width, height);
1612
1792
  const frame = window.frameElement;
1613
1793
  const pane = frame?.parentElement;
@@ -1616,7 +1796,7 @@ async function stagePane(width, height) {
1616
1796
  pane.setAttribute(CAPTURE_PANE, "");
1617
1797
  if (owner.querySelector(`style[data-capture-pane]`) === null) {
1618
1798
  const rule = owner.createElement("style");
1619
- rule.setAttribute(CAPTURE_PANE, "");
1799
+ rule.setAttribute(CAPTURE_PANE, viewport);
1620
1800
  rule.textContent = [
1621
1801
  `[${CAPTURE_PANE}],:has(>iframe[data-vitest])`,
1622
1802
  "{--tester-transform:none !important;--tester-margin-left:0px !important}",
@@ -1633,33 +1813,50 @@ async function stagePane(width, height) {
1633
1813
  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
1814
  }
1635
1815
  /**
1636
- * Hands the tester pane back to the runner's own layout.
1816
+ * Hands the tester pane back to the runner's own layout, at the viewport it had before staging.
1637
1817
  *
1638
1818
  * @remarks
1639
1819
  * A staged pane is the runner's fitting scale suppressed, so a pane left staged outlives the capture
1640
1820
  * that needed it and every later act in the file happens on a surface the runner is no longer
1641
1821
  * fitting to its window. What that costs is not a wrong picture: it is a control whose page
1642
1822
  * 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
1823
+ * press fails with the voice of a control that is covered.
1824
+ *
1825
+ * The viewport goes back too, because a capture resizes the tester and the size it chose belongs to
1826
+ * the frame rather than to the file: a test that runs after one and reads a breakpoint would
1827
+ * otherwise read the last capture's variant. The size comes off the {@link CAPTURE_PANE} value
1828
+ * {@link stagePane} wrote onto the rule element, which is the reading taken before the first
1829
+ * staging. Calling this on an unstaged pane finds no such value, so it changes nothing and resizes
1644
1830
  * nothing.
1645
1831
  *
1832
+ * That hand-back is what makes {@link stagePane} and this pair a capture's staging rather than a
1833
+ * resize: the pair puts the tester back where it found it, so a suite that used it to reach a
1834
+ * breakpoint runs its next step at the old size. Call `page.viewport` from `vitest/browser` for a
1835
+ * journey's own size, and leave this pair to the capture.
1836
+ *
1646
1837
  * @example
1647
1838
  * ```ts
1648
- * releasePane()
1839
+ * await releasePane()
1649
1840
  * ```
1650
1841
  */
1651
- function releasePane() {
1842
+ async function releasePane() {
1652
1843
  const pane = window.frameElement?.parentElement;
1844
+ const rule = pane?.ownerDocument.querySelector(`style[${CAPTURE_PANE}]`);
1845
+ const viewport = rule?.getAttribute("data-capture-pane")?.split("x") ?? [];
1653
1846
  pane?.removeAttribute(CAPTURE_PANE);
1654
- pane?.ownerDocument.querySelector(`style[${CAPTURE_PANE}]`)?.remove();
1847
+ rule?.remove();
1848
+ const width = Number(viewport[0]);
1849
+ const height = Number(viewport[1]);
1850
+ if (Number.isFinite(width) && Number.isFinite(height)) await page.viewport(width, height);
1655
1851
  }
1656
1852
  /**
1657
1853
  * Shoots one frame at one viewport size and proves the file on disk holds this run's bytes.
1658
1854
  *
1659
1855
  * @param options - The path to write, the viewport to shoot at, and the element to shoot.
1660
1856
  * @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.
1857
+ * @throws Thrown when the pane cannot be staged, when the document's height never settles under
1858
+ * {@link CAPTURE_STAGINGS} restagings, when the provider wrote the frame somewhere else, and when
1859
+ * the bytes on disk are not the ones this shot produced.
1663
1860
  *
1664
1861
  * @remarks
1665
1862
  * The path a screenshot call returns is the path it meant to write, so it is not evidence a file
@@ -1669,8 +1866,41 @@ function releasePane() {
1669
1866
  * path, so the two are compared by the segments that survive resolving `.` and `..` lexically — the
1670
1867
  * refusal is what a provider resolving that path against a different base would trip.
1671
1868
  *
1869
+ * The frame covers the whole document at `options.width`, whatever `options.height` is. The
1870
+ * provider shoots the tester's body in the top-level page's own coordinates, so a document taller
1871
+ * than the pane is painted for the pane's height and the rows below it are the runner's page rather
1872
+ * than the document — a frame that reads as the surface down to the fold and as bare canvas after
1873
+ * it. The document is therefore laid out at the declared viewport first and, where it is taller
1874
+ * than that, the pane is staged again at the height the document needs, for the shot alone.
1875
+ *
1876
+ * That height is {@link measureContent}, never less than `options.height`, because the declared
1877
+ * viewport is the smallest frame a variant asks for. The reading is the content's own edge rather
1878
+ * than the body's box: the box is the larger of the content and the pane, so it stretches with
1879
+ * every pane staged over it and a capture that staged too tall a pane could not read its way back
1880
+ * down. Rounding up is what covers a body ending part way through a row, which the box does and an
1881
+ * integer scroll height does not.
1882
+ *
1883
+ * The edge is read again after every staging, because a rule bound to the viewport height — a `vh`
1884
+ * length, a fixed footer, a full-height panel — lays the document out taller against the taller
1885
+ * pane, so a surface built out of those photographs as its scrolled-open self rather than as one
1886
+ * screen, and the reading taken before that staging is stale by exactly what the reflow added.
1887
+ * Restaging at the edge alone converges on such a document without arriving: each staging closes
1888
+ * the same fraction of what is left, so a rule keeping half the pane reads 1322, 1561, 1681, and
1889
+ * 1741 against a fixed point of 1800. Each staging therefore carries the growth the one before it
1890
+ * produced — the pane is the edge plus that growth — which lands on the fixed point rather than
1891
+ * creeping up to it. The first staging carries no growth, because nothing has grown yet, so a
1892
+ * document of fixed content is staged at its own edge and shot there rather than at a pane the
1893
+ * overshoot stretched.
1894
+ *
1895
+ * The re-reading stops when the pane and the edge agree, which is the pane the shot is taken at. A
1896
+ * rule that adds height with every pane never reaches that point, so the re-reading is bounded by
1897
+ * {@link CAPTURE_STAGINGS} and the shot is refused with
1898
+ * `Capture frame at <path> never settled after <n> restagings: <h> over a <h> pane` rather than
1899
+ * written at a height that is already wrong.
1900
+ *
1672
1901
  * 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.
1902
+ * before this returns, on the failing path as well as the passing one, which hands the tester back
1903
+ * the viewport it had before the first staging.
1674
1904
  *
1675
1905
  * @example
1676
1906
  * ```ts
@@ -1680,6 +1910,17 @@ function releasePane() {
1680
1910
  async function captureFrame(options) {
1681
1911
  try {
1682
1912
  await stagePane(options.width, options.height);
1913
+ let pane = options.height;
1914
+ let covered = Math.max(measureContent(), options.height);
1915
+ let growth = 0;
1916
+ for (let staging = 0; pane !== covered; staging += 1) {
1917
+ if (staging === 4) throw new Error(`Capture frame at ${options.path} never settled after ${String(4)} restagings: ${String(covered)} over a ${String(pane)} pane`);
1918
+ pane = covered + growth;
1919
+ await stagePane(options.width, pane);
1920
+ const reading = Math.max(measureContent(), options.height);
1921
+ growth = Math.max(0, reading - covered);
1922
+ covered = reading;
1923
+ }
1683
1924
  const shot = options.element === void 0 ? await page.screenshot({
1684
1925
  path: options.path,
1685
1926
  base64: true
@@ -1698,10 +1939,57 @@ async function captureFrame(options) {
1698
1939
  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
1940
  return shot.path;
1700
1941
  } finally {
1701
- releasePane();
1942
+ await releasePane();
1702
1943
  }
1703
1944
  }
1704
1945
  /**
1946
+ * Reads one written frame back and reports its size and the color its bottom row paints.
1947
+ *
1948
+ * @param path - The frame's absolute path, as `captureFrame` returns it.
1949
+ * @returns The frame's size in device pixels and its floor.
1950
+ * @throws Thrown when the runner cannot read the path, when the bytes there are not an image this
1951
+ * browser decodes, and when the browser hands out no 2D canvas to measure them on.
1952
+ *
1953
+ * @remarks
1954
+ * The reading comes off the written file rather than off the document that produced it, which is
1955
+ * what makes it evidence about a capture: the browser's own image decoding and an
1956
+ * `OffscreenCanvas` answer for the pixels a viewer would see, so a frame that ends on the runner's
1957
+ * canvas reports that canvas whatever the document's style resolves to. Pass the path the provider
1958
+ * resolved and `captureFrame` returned; the runner's `readFile` command resolves a relative path
1959
+ * against its own root rather than against the calling test file, so a relative path names a file
1960
+ * somewhere else.
1961
+ *
1962
+ * @example
1963
+ * ```ts
1964
+ * const reading = await readFrame(written)
1965
+ * ```
1966
+ */
1967
+ async function readFrame(path) {
1968
+ const encoded = await commands.readFile(path, "base64").catch((cause) => {
1969
+ throw new Error(`Capture frame at ${path} could not be read`, { cause });
1970
+ });
1971
+ const image = new Image();
1972
+ image.src = `data:image/png;base64,${encoded}`;
1973
+ await image.decode().catch((cause) => {
1974
+ throw new Error(`Capture frame at ${path} is not an image this browser decodes`, { cause });
1975
+ });
1976
+ const context = new OffscreenCanvas(image.width, image.height).getContext("2d");
1977
+ if (context === null) throw new Error(`Capture frame at ${path} cannot be measured without a 2D canvas`);
1978
+ context.drawImage(image, 0, 0);
1979
+ const row = context.getImageData(0, image.height - 1, image.width, 1).data;
1980
+ const red = row[0];
1981
+ const green = row[1];
1982
+ const blue = row[2];
1983
+ const alpha = row[3];
1984
+ let single = red !== void 0 && green !== void 0 && blue !== void 0;
1985
+ 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;
1986
+ return {
1987
+ width: image.width,
1988
+ height: image.height,
1989
+ floor: single ? `rgb(${String(red)}, ${String(green)}, ${String(blue)})` : void 0
1990
+ };
1991
+ }
1992
+ /**
1705
1993
  * Expands a capture registry across every variant into the filenames a complete portfolio holds.
1706
1994
  *
1707
1995
  * @param states - The registered state names.
@@ -1832,7 +2120,7 @@ function createPortfolio(options) {
1832
2120
  return {
1833
2121
  variant: options.variant,
1834
2122
  files,
1835
- get states() {
2123
+ get placements() {
1836
2124
  return [...placed];
1837
2125
  },
1838
2126
  get paths() {
@@ -1968,6 +2256,6 @@ function createJournal() {
1968
2256
  };
1969
2257
  }
1970
2258
  //#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 };
2259
+ 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, pressKeys, 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
2260
 
1973
2261
  //# sourceMappingURL=index.js.map