@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.
- package/README.md +20 -8
- package/dist/src/browser/index.d.ts +483 -237
- package/dist/src/browser/index.js +357 -69
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +189 -21
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +270 -21
- package/dist/src/core/index.d.ts +270 -21
- package/dist/src/core/index.js +183 -22
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +67 -48
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +70 -32
- package/dist/src/server/index.d.ts +70 -32
- package/dist/src/server/index.js +66 -50
- 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
|
|
@@ -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
|
|
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`);
|
|
@@ -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
|
|
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
|
|
1098
|
+
* missing token asserts on {@link readToken} rather than on this.
|
|
1028
1099
|
*
|
|
1029
1100
|
* @example
|
|
1030
1101
|
* ```ts
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1102
|
+
* parseCSSColor('rebeccapurple') // [102, 51, 153, 1]
|
|
1103
|
+
* parseCSSColor('not-a-color') // undefined
|
|
1033
1104
|
* ```
|
|
1034
1105
|
*/
|
|
1035
|
-
function
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1067
|
-
*
|
|
1137
|
+
* matchesColor('rebeccapurple', 'rgb(102, 51, 153)') // true
|
|
1138
|
+
* matchesColor('red', [0, 0, 255, 1]) // false
|
|
1068
1139
|
* ```
|
|
1069
1140
|
*/
|
|
1070
|
-
function
|
|
1071
|
-
const left = typeof first === "string" ?
|
|
1072
|
-
const right = typeof second === "string" ?
|
|
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
|
|
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
|
-
*
|
|
1239
|
-
*
|
|
1309
|
+
* readContrast(requireValue(container.firstElementChild)) // 21
|
|
1310
|
+
* readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
|
|
1240
1311
|
* ```
|
|
1241
1312
|
*/
|
|
1242
|
-
function
|
|
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
|
|
1387
|
-
* another. Assert on this where the subject is the stylesheet, and on `
|
|
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
|
-
*
|
|
1622
|
+
* readStyle(button, 'padding-left')
|
|
1492
1623
|
* ```
|
|
1493
1624
|
*/
|
|
1494
|
-
function
|
|
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
|
|
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
|
-
*
|
|
1518
|
-
*
|
|
1648
|
+
* readToken(panel, 'surface') // '#ffffff'
|
|
1649
|
+
* readToken(panel, '--surface') // '#ffffff'
|
|
1519
1650
|
* ```
|
|
1520
1651
|
*/
|
|
1521
|
-
function
|
|
1522
|
-
return
|
|
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
|
|
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
|
-
*
|
|
1669
|
+
* readRootToken('surface')
|
|
1539
1670
|
* ```
|
|
1540
1671
|
*/
|
|
1541
|
-
function
|
|
1542
|
-
return
|
|
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
|
|
1690
|
+
* distinction matters, read the text with {@link readStyle} instead.
|
|
1560
1691
|
*
|
|
1561
1692
|
* @example
|
|
1562
1693
|
* ```ts
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1694
|
+
* readPixels(button, 'padding-left') // 12
|
|
1695
|
+
* readPixels(button, 'width') // 0 when the width resolves to `auto`
|
|
1565
1696
|
* ```
|
|
1566
1697
|
*/
|
|
1567
|
-
function
|
|
1568
|
-
const measured = Number.parseFloat(
|
|
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.
|
|
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
|
-
|
|
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
|
|
1662
|
-
*
|
|
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
|
|
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,
|
|
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
|