@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.
- package/README.md +58 -39
- package/dist/src/browser/index.d.ts +516 -268
- package/dist/src/browser/index.js +363 -89
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +191 -23
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +272 -23
- package/dist/src/core/index.d.ts +272 -23
- package/dist/src/core/index.js +185 -24
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +69 -50
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +74 -36
- package/dist/src/server/index.d.ts +74 -36
- package/dist/src/server/index.js +68 -52
- package/dist/src/server/index.js.map +1 -1
- package/package.json +14 -11
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { commands, page, userEvent } from "vitest/browser";
|
|
2
2
|
//#region src/browser/constants.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
50
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
210
|
+
* Determines whether a person can click one element where it sits.
|
|
192
211
|
*
|
|
193
212
|
* @param element - The element to judge.
|
|
194
|
-
* @returns
|
|
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;
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
1084
|
+
* missing token asserts on {@link readToken} rather than on this.
|
|
1028
1085
|
*
|
|
1029
1086
|
* @example
|
|
1030
1087
|
* ```ts
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1088
|
+
* parseCSSColor('rebeccapurple') // [102, 51, 153, 1]
|
|
1089
|
+
* parseCSSColor('not-a-color') // undefined
|
|
1033
1090
|
* ```
|
|
1034
1091
|
*/
|
|
1035
|
-
function
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1067
|
-
*
|
|
1123
|
+
* matchesColor('rebeccapurple', 'rgb(102, 51, 153)') // true
|
|
1124
|
+
* matchesColor('red', [0, 0, 255, 1]) // false
|
|
1068
1125
|
* ```
|
|
1069
1126
|
*/
|
|
1070
|
-
function
|
|
1071
|
-
const left = typeof first === "string" ?
|
|
1072
|
-
const right = typeof second === "string" ?
|
|
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
|
|
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
|
-
*
|
|
1239
|
-
*
|
|
1295
|
+
* readContrast(requireValue(container.firstElementChild)) // 21
|
|
1296
|
+
* readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
|
|
1240
1297
|
* ```
|
|
1241
1298
|
*/
|
|
1242
|
-
function
|
|
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
|
-
* `
|
|
1262
|
-
* that is not matching `:focus-visible` when the call is made
|
|
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
|
|
1387
|
-
* another. Assert on this where the subject is the stylesheet, and on `
|
|
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
|
-
*
|
|
1608
|
+
* readStyle(button, 'padding-left')
|
|
1492
1609
|
* ```
|
|
1493
1610
|
*/
|
|
1494
|
-
function
|
|
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
|
|
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
|
-
*
|
|
1518
|
-
*
|
|
1634
|
+
* readToken(panel, 'surface') // '#ffffff'
|
|
1635
|
+
* readToken(panel, '--surface') // '#ffffff'
|
|
1519
1636
|
* ```
|
|
1520
1637
|
*/
|
|
1521
|
-
function
|
|
1522
|
-
return
|
|
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
|
|
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
|
-
*
|
|
1655
|
+
* readRootToken('surface')
|
|
1539
1656
|
* ```
|
|
1540
1657
|
*/
|
|
1541
|
-
function
|
|
1542
|
-
return
|
|
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
|
|
1676
|
+
* distinction matters, read the text with {@link readStyle} instead.
|
|
1560
1677
|
*
|
|
1561
1678
|
* @example
|
|
1562
1679
|
* ```ts
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1680
|
+
* readPixels(button, 'padding-left') // 12
|
|
1681
|
+
* readPixels(button, 'width') // 0 when the width resolves to `auto`
|
|
1565
1682
|
* ```
|
|
1566
1683
|
*/
|
|
1567
|
-
function
|
|
1568
|
-
const measured = Number.parseFloat(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
1662
|
-
*
|
|
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
|
|
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,
|
|
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
|