@orkestrel/test 0.0.4 → 0.0.6
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 +78 -19
- package/dist/src/browser/index.d.ts +478 -0
- package/dist/src/browser/index.js +619 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/core/index.cjs +29 -0
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -0
- package/dist/src/core/index.d.ts +33 -0
- package/dist/src/core/index.js +29 -1
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +31 -0
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +29 -0
- package/dist/src/server/index.d.ts +29 -0
- package/dist/src/server/index.js +31 -1
- package/dist/src/server/index.js.map +1 -1
- package/package.json +21 -8
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { page, userEvent } from "vitest/browser";
|
|
2
|
+
//#region src/browser/constants.ts
|
|
3
|
+
/**
|
|
4
|
+
* The interactive ARIA roles a bare accessible name is searched across.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* A person names a control, not a role, so the one-argument resolver searches every role a control
|
|
8
|
+
* can compute. The two-argument form searches exactly the role it is given, which is how a name
|
|
9
|
+
* shared by a tab and its panel is disambiguated.
|
|
10
|
+
*/
|
|
11
|
+
var ACCESSIBLE_ROLES = Object.freeze([
|
|
12
|
+
"button",
|
|
13
|
+
"checkbox",
|
|
14
|
+
"combobox",
|
|
15
|
+
"link",
|
|
16
|
+
"listbox",
|
|
17
|
+
"menuitem",
|
|
18
|
+
"option",
|
|
19
|
+
"radio",
|
|
20
|
+
"searchbox",
|
|
21
|
+
"slider",
|
|
22
|
+
"spinbutton",
|
|
23
|
+
"switch",
|
|
24
|
+
"tab",
|
|
25
|
+
"tabpanel",
|
|
26
|
+
"textbox",
|
|
27
|
+
"treeitem"
|
|
28
|
+
]);
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/browser/helpers.ts
|
|
31
|
+
/**
|
|
32
|
+
* Determines whether a rectangle lies wholly outside the browser viewport.
|
|
33
|
+
*
|
|
34
|
+
* @param rectangle - The measured client rectangle to inspect.
|
|
35
|
+
* @returns `true` when no part of the rectangle intersects the viewport.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* isOutsideViewport(element.getBoundingClientRect())
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
function isOutsideViewport(rectangle) {
|
|
43
|
+
return rectangle.bottom <= 0 || rectangle.right <= 0 || rectangle.top >= window.innerHeight || rectangle.left >= window.innerWidth;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolves one rendered, focus-reachable interactive element without requiring it to intersect the
|
|
47
|
+
* viewport yet.
|
|
48
|
+
*
|
|
49
|
+
* @param first - The accessible name, or the exact ARIA role when `second` is present.
|
|
50
|
+
* @param second - The accessible name when `first` supplies the role.
|
|
51
|
+
* @returns The one rendered element carrying that name and optional role.
|
|
52
|
+
* @throws When no matching element exists, every match is hidden or unreachable, or several
|
|
53
|
+
* rendered matches make the name ambiguous.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* This is the resolver the acting verbs use, so a click does not fail on a target the act itself
|
|
57
|
+
* scrolls into view. Use {@link resolveAccessible} wherever the target must already be on screen.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* resolveRendered('tab', 'Drafts')
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function resolveRendered(first, second) {
|
|
65
|
+
const name = second ?? first;
|
|
66
|
+
const roles = second === void 0 ? ACCESSIBLE_ROLES : [first];
|
|
67
|
+
const matches = [];
|
|
68
|
+
for (const role of roles) for (const element of page.getByRole(role, {
|
|
69
|
+
name,
|
|
70
|
+
exact: true,
|
|
71
|
+
includeHidden: true
|
|
72
|
+
}).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
|
|
73
|
+
if (matches.length === 0) throw new Error(`No interactive element has the accessible name "${name}"`);
|
|
74
|
+
const reachable = matches.filter((element) => {
|
|
75
|
+
const rectangle = element.getBoundingClientRect();
|
|
76
|
+
return element.isConnected && element.checkVisibility({
|
|
77
|
+
checkOpacity: true,
|
|
78
|
+
checkVisibilityCSS: true
|
|
79
|
+
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
|
|
80
|
+
});
|
|
81
|
+
if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not visible and focus-reachable`);
|
|
82
|
+
if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements`);
|
|
83
|
+
const [target] = reachable;
|
|
84
|
+
if (target === void 0) throw new Error(`Interactive target "${name}" could not be resolved`);
|
|
85
|
+
return target;
|
|
86
|
+
}
|
|
87
|
+
function resolveAccessible(first, second) {
|
|
88
|
+
const target = resolveRendered(first, second);
|
|
89
|
+
let rectangle = target.getBoundingClientRect();
|
|
90
|
+
if (isOutsideViewport(rectangle)) {
|
|
91
|
+
target.scrollIntoView({
|
|
92
|
+
block: "nearest",
|
|
93
|
+
behavior: "instant"
|
|
94
|
+
});
|
|
95
|
+
rectangle = target.getBoundingClientRect();
|
|
96
|
+
}
|
|
97
|
+
if (isOutsideViewport(rectangle)) throw new Error(`Interactive target "${second ?? first}" is unreachable after scrolling`);
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
async function clickAccessible(first, second) {
|
|
101
|
+
const target = resolveRendered(first, second);
|
|
102
|
+
await userEvent.click(target);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Clicks one human-reachable control by role and accessible-name text inside a named region.
|
|
106
|
+
*
|
|
107
|
+
* @param region - The containing region's exact accessible name.
|
|
108
|
+
* @param role - The control's exact ARIA role.
|
|
109
|
+
* @param name - The rendered accessible-name text that identifies the control in that region.
|
|
110
|
+
* @returns A promise resolving after trusted activation completes.
|
|
111
|
+
* @throws When the named control is absent, unreachable, or ambiguous inside the region.
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* Use this form when repeated short verbs such as `Add`, or a line whose status completes its
|
|
115
|
+
* accessible name, need the same region context a person uses to disambiguate them.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* await clickAccessibleWithin('Ledger', 'button', 'Monthly income')
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
async function clickAccessibleWithin(region, role, name) {
|
|
123
|
+
const reachable = page.getByRole("region", {
|
|
124
|
+
name: region,
|
|
125
|
+
exact: true
|
|
126
|
+
}).getByRole(role, {
|
|
127
|
+
name,
|
|
128
|
+
exact: false,
|
|
129
|
+
includeHidden: true
|
|
130
|
+
}).elements().filter((element) => {
|
|
131
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
132
|
+
const rectangle = element.getBoundingClientRect();
|
|
133
|
+
return element.isConnected && element.checkVisibility({
|
|
134
|
+
checkOpacity: true,
|
|
135
|
+
checkVisibilityCSS: true
|
|
136
|
+
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
|
|
137
|
+
});
|
|
138
|
+
if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not reachable inside "${region}"`);
|
|
139
|
+
if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements inside "${region}"`);
|
|
140
|
+
const [target] = reachable;
|
|
141
|
+
if (!(target instanceof HTMLElement)) throw new Error(`Interactive target "${name}" could not be resolved inside "${region}"`);
|
|
142
|
+
await userEvent.click(target);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Opens or closes one native details disclosure by its rendered summary.
|
|
146
|
+
*
|
|
147
|
+
* @param name - The summary text a person reads.
|
|
148
|
+
* @returns A promise resolving after trusted activation completes.
|
|
149
|
+
* @throws When no visible, focus-reachable native summary has that rendered name, or several do.
|
|
150
|
+
*
|
|
151
|
+
* @remarks
|
|
152
|
+
* Chromium exposes `<summary>` as a native disclosure rather than through an ARIA role accepted by
|
|
153
|
+
* `getByRole`, so this resolver names the platform element and its rendered text directly.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* await clickDisclosure('Advanced')
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
async function clickDisclosure(name) {
|
|
161
|
+
const reachable = [...document.querySelectorAll("summary")].filter((element) => element.innerText.replaceAll(/\s+/g, " ").trim() === name).filter((element) => {
|
|
162
|
+
const rectangle = element.getBoundingClientRect();
|
|
163
|
+
return element.isConnected && element.checkVisibility({
|
|
164
|
+
checkOpacity: true,
|
|
165
|
+
checkVisibilityCSS: true
|
|
166
|
+
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && element.closest("[inert]") === null;
|
|
167
|
+
});
|
|
168
|
+
if (reachable.length === 0) throw new Error(`Native disclosure "${name}" is not visible and focus-reachable`);
|
|
169
|
+
if (reachable.length > 1) throw new Error(`Native disclosure "${name}" is ambiguous across ${reachable.length} elements`);
|
|
170
|
+
const [target] = reachable;
|
|
171
|
+
if (target === void 0) throw new Error(`Native disclosure "${name}" could not be resolved`);
|
|
172
|
+
await userEvent.click(target);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Replaces a named field's value through focus, select-all, deletion, and real keystrokes.
|
|
176
|
+
*
|
|
177
|
+
* @param name - The field's exact accessible name.
|
|
178
|
+
* @param text - The text to type.
|
|
179
|
+
* @returns A promise resolving after every keystroke completes.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* await typeAccessible('Runs', '3')
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
async function typeAccessible(name, text) {
|
|
187
|
+
await userEvent.click(resolveRendered(name));
|
|
188
|
+
await userEvent.keyboard("{Control>}a{/Control}{Backspace}");
|
|
189
|
+
if (text === "") return;
|
|
190
|
+
await userEvent.keyboard(text.replaceAll("{", "{{").replaceAll("[", "[["));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Replaces a named field's value in one operation, for text too long to type key by key.
|
|
194
|
+
*
|
|
195
|
+
* @param name - The field's exact accessible name.
|
|
196
|
+
* @param text - The text to place in the field.
|
|
197
|
+
* @returns A promise resolving after the browser commits the value.
|
|
198
|
+
*
|
|
199
|
+
* @remarks
|
|
200
|
+
* The provider drives the real element, so the field publishes the same input event a person's
|
|
201
|
+
* typing publishes. Use {@link typeAccessible} wherever the keystrokes themselves are the subject.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```ts
|
|
205
|
+
* await fillAccessible('Payload', '{"status":"ready"}')
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
async function fillAccessible(name, text) {
|
|
209
|
+
await userEvent.fill(resolveRendered(name), text);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Presses a browser-keyboard sequence using Vitest's installed user-event syntax.
|
|
213
|
+
*
|
|
214
|
+
* @param keys - The keys or key descriptors to press.
|
|
215
|
+
* @returns A promise resolving after the sequence completes.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```ts
|
|
219
|
+
* await pressKeys('{ArrowRight}{Enter}')
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
async function pressKeys(keys) {
|
|
223
|
+
await userEvent.keyboard(keys);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Reaches a named control only through natural forward Tab traversal from the current focus.
|
|
227
|
+
*
|
|
228
|
+
* @param name - The target's exact accessible name.
|
|
229
|
+
* @returns The target after the browser moves focus to it.
|
|
230
|
+
* @throws When one complete traversal cannot reach the target.
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```ts
|
|
234
|
+
* await traverseAccessible('Evaluate')
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
async function traverseAccessible(name) {
|
|
238
|
+
resolveRendered(name);
|
|
239
|
+
const cap = document.querySelectorAll("a[href], button, input, select, textarea, [tabindex]").length * 3 + 10;
|
|
240
|
+
const visited = /* @__PURE__ */ new Set();
|
|
241
|
+
const trail = [];
|
|
242
|
+
for (let attempt = 0; attempt < cap; attempt += 1) {
|
|
243
|
+
await userEvent.tab();
|
|
244
|
+
const focused = document.activeElement;
|
|
245
|
+
if (!(focused instanceof HTMLElement) || focused === document.body) continue;
|
|
246
|
+
let current;
|
|
247
|
+
try {
|
|
248
|
+
current = resolveRendered(name);
|
|
249
|
+
} catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (focused === current) return current;
|
|
253
|
+
if (visited.has(focused)) break;
|
|
254
|
+
visited.add(focused);
|
|
255
|
+
trail.push(`${focused.tagName}:${focused.innerText.slice(0, 20)}`);
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`Interactive target "${name}" is not reachable through forward Tab traversal: ${trail.join(" > ")}`);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Reads the normalized visible text of one named region, dialog, table, tab panel, or alert.
|
|
261
|
+
*
|
|
262
|
+
* @param name - The region's exact accessible name.
|
|
263
|
+
* @returns The text a screen reader can perceive in the visible region, including descendant
|
|
264
|
+
* visually-hidden content.
|
|
265
|
+
* @throws When the named region is absent, hidden, or ambiguous.
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```ts
|
|
269
|
+
* readPerception('Run')
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
function readPerception(name) {
|
|
273
|
+
const matches = [];
|
|
274
|
+
for (const role of [
|
|
275
|
+
"alert",
|
|
276
|
+
"alertdialog",
|
|
277
|
+
"dialog",
|
|
278
|
+
"region",
|
|
279
|
+
"status",
|
|
280
|
+
"table",
|
|
281
|
+
"tabpanel"
|
|
282
|
+
]) for (const element of page.getByRole(role, {
|
|
283
|
+
name,
|
|
284
|
+
exact: true,
|
|
285
|
+
includeHidden: true
|
|
286
|
+
}).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
|
|
287
|
+
const visible = matches.filter((element) => {
|
|
288
|
+
const rectangle = element.getBoundingClientRect();
|
|
289
|
+
return element.isConnected && element.checkVisibility({
|
|
290
|
+
checkOpacity: true,
|
|
291
|
+
checkVisibilityCSS: true
|
|
292
|
+
}) && rectangle.width > 0 && rectangle.height > 0;
|
|
293
|
+
});
|
|
294
|
+
if (visible.length === 0) throw new Error(`Named region "${name}" is not visible`);
|
|
295
|
+
if (visible.length > 1) throw new Error(`Named region "${name}" is ambiguous across ${visible.length} elements`);
|
|
296
|
+
const [region] = visible;
|
|
297
|
+
if (region === void 0) throw new Error(`Named region "${name}" could not be resolved`);
|
|
298
|
+
return region.innerText.replaceAll(/\s+/g, " ").trim();
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Reads the normalized visible text of the whole page.
|
|
302
|
+
*
|
|
303
|
+
* @returns Every rendered word in the document body, its whitespace runs collapsed and trimmed.
|
|
304
|
+
*
|
|
305
|
+
* @remarks
|
|
306
|
+
* This is the reader for a sentence that spans two regions and for a vocabulary sweep over the
|
|
307
|
+
* words an interface uses. Reach for {@link readPerception} wherever one named region is the
|
|
308
|
+
* subject, because that one throws when the region is missing and this one returns whatever is
|
|
309
|
+
* there.
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```ts
|
|
313
|
+
* readPage().includes('No cases yet')
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
function readPage() {
|
|
317
|
+
return document.body.innerText.replaceAll(/\s+/g, " ").trim();
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Reads the rendered text of the element that currently holds focus.
|
|
321
|
+
*
|
|
322
|
+
* @returns The focused HTML element's trimmed rendered text, including an empty string, or
|
|
323
|
+
* `undefined` when focus rests on a non-HTML element. When nothing holds focus, the browser
|
|
324
|
+
* reports the document body as active, so the whole page's rendered text returns.
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* ```ts
|
|
328
|
+
* await traverseAccessible('Evaluate')
|
|
329
|
+
* readFocus() // 'Evaluate'
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
function readFocus() {
|
|
333
|
+
const focused = document.activeElement;
|
|
334
|
+
return focused instanceof HTMLElement ? focused.innerText.trim() : void 0;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Reads the value a resolved control renders.
|
|
338
|
+
*
|
|
339
|
+
* @param role - The control's exact ARIA role.
|
|
340
|
+
* @param name - The control's exact accessible name.
|
|
341
|
+
* @returns The control's current value.
|
|
342
|
+
* @throws When the target does not resolve, or resolves to an element that carries no value.
|
|
343
|
+
*
|
|
344
|
+
* @remarks
|
|
345
|
+
* A control's value is a rendered fact a person can read, not internal state, so it is read from
|
|
346
|
+
* the resolved element rather than from the component that produced it.
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* ```ts
|
|
350
|
+
* readValue('spinbutton', 'Runs') // '3'
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
function readValue(role, name) {
|
|
354
|
+
const control = resolveAccessible(role, name);
|
|
355
|
+
if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLTextAreaElement) && !(control instanceof HTMLSelectElement)) throw new Error(`Interactive target "${name}" does not carry a value`);
|
|
356
|
+
return control.value;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Waits for one animation frame to settle pending browser paint work.
|
|
360
|
+
*
|
|
361
|
+
* @returns A promise resolving after one `requestAnimationFrame`.
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```ts
|
|
365
|
+
* await waitForFrame()
|
|
366
|
+
* ```
|
|
367
|
+
*/
|
|
368
|
+
function waitForFrame() {
|
|
369
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Renders trusted fixture markup into a container attached to the document.
|
|
373
|
+
*
|
|
374
|
+
* @param markup - The fixture markup to render.
|
|
375
|
+
* @returns The attached container.
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```ts
|
|
379
|
+
* const container = render('<button type="button">Save</button>')
|
|
380
|
+
* container.remove()
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
function render(markup) {
|
|
384
|
+
const container = document.createElement("div");
|
|
385
|
+
container.innerHTML = markup;
|
|
386
|
+
document.body.append(container);
|
|
387
|
+
return container;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Measures the WCAG 2.x contrast ratio between an element's computed text and background colors.
|
|
391
|
+
*
|
|
392
|
+
* @param element - The element whose rendered text contrast to measure.
|
|
393
|
+
* @returns The relative-luminance contrast ratio.
|
|
394
|
+
* @throws When the browser does not expose parseable computed colors.
|
|
395
|
+
*
|
|
396
|
+
* @remarks
|
|
397
|
+
* A transparent or translucent background resolves through the element's ancestors: every painted
|
|
398
|
+
* layer from the element up to the first opaque one composites top-over-bottom onto that opaque
|
|
399
|
+
* base, so a 3% surface tint reads as a tint over what shows through it rather than as a
|
|
400
|
+
* full-strength paint. A translucent foreground then resolves against that effective background
|
|
401
|
+
* before luminance is measured.
|
|
402
|
+
*
|
|
403
|
+
* Every element from the target upwards must be reachable, and at least one of them must paint:
|
|
404
|
+
* the measurement throws rather than assuming a white canvas when nothing in the chain declares a
|
|
405
|
+
* background color. The element itself must expose a computed foreground color — a detached
|
|
406
|
+
* element exposes none, and the measurement throws rather than guessing one.
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* ```ts
|
|
410
|
+
* const container = render('<p style="background: #000; color: #fff">Ready</p>')
|
|
411
|
+
* contrast(requireValue(container.firstElementChild)) // 21
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
function contrast(element) {
|
|
415
|
+
const foreground = getComputedStyle(element).color.match(/\d+(?:\.\d+)?/g);
|
|
416
|
+
if (foreground === null || foreground.length < 3) throw new Error("Computed foreground color is unavailable");
|
|
417
|
+
const layers = [];
|
|
418
|
+
let current = element;
|
|
419
|
+
let opaque = false;
|
|
420
|
+
while (current !== null) {
|
|
421
|
+
const channels = getComputedStyle(current).backgroundColor.match(/\d+(?:\.\d+)?/g);
|
|
422
|
+
if (channels !== null && channels.length >= 3) {
|
|
423
|
+
const layerAlpha = channels[3] === void 0 ? 1 : Number(channels[3]);
|
|
424
|
+
if (layerAlpha > 0) layers.push([...channels.slice(0, 3).map(Number), layerAlpha]);
|
|
425
|
+
if (layerAlpha >= 1) {
|
|
426
|
+
opaque = true;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
current = current.parentElement;
|
|
431
|
+
}
|
|
432
|
+
if (layers.length === 0) throw new Error("Computed background color is unavailable");
|
|
433
|
+
const base = layers[layers.length - 1];
|
|
434
|
+
if (base === void 0) throw new Error("Computed background color is unavailable");
|
|
435
|
+
let composed = base.slice(0, 3).map((channel) => channel / 255);
|
|
436
|
+
if (!opaque) composed = [
|
|
437
|
+
1,
|
|
438
|
+
1,
|
|
439
|
+
1
|
|
440
|
+
];
|
|
441
|
+
const start = opaque ? layers.length - 2 : layers.length - 1;
|
|
442
|
+
for (let index = start; index >= 0; index -= 1) {
|
|
443
|
+
const layer = layers[index];
|
|
444
|
+
if (layer === void 0) continue;
|
|
445
|
+
const layerAlpha = layer[3] ?? 1;
|
|
446
|
+
composed = composed.map((channel, position) => {
|
|
447
|
+
return (layer[position] ?? 0) / 255 * layerAlpha + channel * (1 - layerAlpha);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const alpha = foreground[3] === void 0 ? 1 : Number(foreground[3]);
|
|
451
|
+
const backgroundChannels = composed;
|
|
452
|
+
const foregroundLinear = foreground.slice(0, 3).map((channel, index) => {
|
|
453
|
+
const behind = backgroundChannels[index];
|
|
454
|
+
if (behind === void 0) throw new Error("Computed background channel is unavailable");
|
|
455
|
+
return Number(channel) / 255 * alpha + behind * (1 - alpha);
|
|
456
|
+
}).map((channel) => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
|
|
457
|
+
const backgroundLinear = backgroundChannels.map((channel) => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
|
|
458
|
+
const foregroundLuminance = .2126 * (foregroundLinear[0] ?? 0) + .7152 * (foregroundLinear[1] ?? 0) + .0722 * (foregroundLinear[2] ?? 0);
|
|
459
|
+
const backgroundLuminance = .2126 * (backgroundLinear[0] ?? 0) + .7152 * (backgroundLinear[1] ?? 0) + .0722 * (backgroundLinear[2] ?? 0);
|
|
460
|
+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
461
|
+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
462
|
+
return (lighter + .05) / (darker + .05);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Collects every class token the stylesheets loaded into this document actually define.
|
|
466
|
+
*
|
|
467
|
+
* @returns The set of class names reachable in the shipped cascade.
|
|
468
|
+
*
|
|
469
|
+
* @remarks
|
|
470
|
+
* The set is what an authored-class conformance check measures against, so a class no loaded
|
|
471
|
+
* stylesheet defines — an invented utility, a misspelled framework name — is absent from it.
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* readCascade().has('card')
|
|
476
|
+
* ```
|
|
477
|
+
*/
|
|
478
|
+
function readCascade() {
|
|
479
|
+
const known = /* @__PURE__ */ new Set();
|
|
480
|
+
const rules = [];
|
|
481
|
+
for (const sheet of document.styleSheets) rules.push(...sheet.cssRules);
|
|
482
|
+
while (rules.length > 0) {
|
|
483
|
+
const rule = rules.pop();
|
|
484
|
+
if (rule instanceof CSSGroupingRule) rules.push(...rule.cssRules);
|
|
485
|
+
if (!(rule instanceof CSSStyleRule)) continue;
|
|
486
|
+
for (const match of rule.selectorText.matchAll(/\.([a-zA-Z][\w-]*)/g)) known.add(String(match[1]));
|
|
487
|
+
}
|
|
488
|
+
return known;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Reads the normalized visible text of every element a selector matches, in document order.
|
|
492
|
+
*
|
|
493
|
+
* @param root - The subtree to search.
|
|
494
|
+
* @param selector - The CSS selector naming the rows.
|
|
495
|
+
* @returns One line per matched element, its text runs collapsed and single-space joined.
|
|
496
|
+
*
|
|
497
|
+
* @remarks
|
|
498
|
+
* The line is built from the row's text nodes rather than from `textContent`, because adjacent
|
|
499
|
+
* inline elements carry no whitespace between them in compiled template output and would otherwise
|
|
500
|
+
* read as one run-together word.
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* ```ts
|
|
504
|
+
* readRows(container, 'li')
|
|
505
|
+
* ```
|
|
506
|
+
*/
|
|
507
|
+
function readRows(root, selector) {
|
|
508
|
+
const rows = [];
|
|
509
|
+
for (const row of root.querySelectorAll(selector)) {
|
|
510
|
+
const parts = [];
|
|
511
|
+
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
|
|
512
|
+
while (walker.nextNode() !== null) {
|
|
513
|
+
const text = (walker.currentNode.textContent ?? "").replaceAll(/\s+/g, " ").trim();
|
|
514
|
+
if (text !== "") parts.push(text);
|
|
515
|
+
}
|
|
516
|
+
rows.push(parts.join(" "));
|
|
517
|
+
}
|
|
518
|
+
return rows;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Reads one resolved CSS property from a real browser element.
|
|
522
|
+
*
|
|
523
|
+
* @param element - The element whose resolved style to inspect.
|
|
524
|
+
* @param property - The CSS property name.
|
|
525
|
+
* @returns The browser's resolved property value.
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* ```ts
|
|
529
|
+
* style(button, 'padding-left')
|
|
530
|
+
* ```
|
|
531
|
+
*/
|
|
532
|
+
function style(element, property) {
|
|
533
|
+
return getComputedStyle(element).getPropertyValue(property);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Expands a capture registry across every variant into the filenames a complete portfolio holds.
|
|
537
|
+
*
|
|
538
|
+
* @param states - The registered state names.
|
|
539
|
+
* @param variants - The variants the portfolio is rendered in.
|
|
540
|
+
* @returns One `<state>--<variant>.png` name per pair, each state's variants together, in registry
|
|
541
|
+
* order.
|
|
542
|
+
*
|
|
543
|
+
* @remarks
|
|
544
|
+
* The expansion is the portfolio's own definition of complete, so a duplicate in it is a registry
|
|
545
|
+
* defect a proof reads directly rather than a collision discovered on disk.
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* ```ts
|
|
549
|
+
* expandCaptures(['start'], [{ name: 'dark-390', width: 390, height: 844 }])
|
|
550
|
+
* // ['start--dark-390.png']
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
function expandCaptures(states, variants) {
|
|
554
|
+
const files = [];
|
|
555
|
+
for (const state of states) for (const variant of variants) files.push(`${state}--${variant.name}.png`);
|
|
556
|
+
return files;
|
|
557
|
+
}
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/browser/factories.ts
|
|
560
|
+
/**
|
|
561
|
+
* Creates the capture portfolio one run places its screenshots through.
|
|
562
|
+
*
|
|
563
|
+
* @param options - The state registry, the variant matrix, the variant this run renders, the
|
|
564
|
+
* directory it writes into, and whether it writes at all.
|
|
565
|
+
* @returns The portfolio: its registry expansion, what it has placed, and `place`.
|
|
566
|
+
* @throws When no registered variant carries the name `variant` names.
|
|
567
|
+
*
|
|
568
|
+
* @remarks
|
|
569
|
+
* A disabled portfolio is the ordinary run. `place` then resizes nothing, writes nothing, and
|
|
570
|
+
* records nothing, so a journey calls it unconditionally and a suite with the flag unset pays for
|
|
571
|
+
* none of it. The portfolio refuses an unregistered variant at creation. An enabled run refuses an
|
|
572
|
+
* unregistered state name and a second placement of one state.
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* ```ts
|
|
576
|
+
* const portfolio = createPortfolio({
|
|
577
|
+
* states: ['start-empty'],
|
|
578
|
+
* variants: [{ name: 'dark-390', width: 390, height: 844 }],
|
|
579
|
+
* variant: 'dark-390',
|
|
580
|
+
* directory: '../../tmp/capture/states',
|
|
581
|
+
* })
|
|
582
|
+
* await portfolio.place('start-empty')
|
|
583
|
+
* ```
|
|
584
|
+
*/
|
|
585
|
+
function createPortfolio(options) {
|
|
586
|
+
const selected = options.variants.find((candidate) => candidate.name === options.variant);
|
|
587
|
+
if (selected === void 0) throw new Error(`Capture variant "${options.variant}" is not registered`);
|
|
588
|
+
const registry = [...options.states];
|
|
589
|
+
const files = expandCaptures(registry, options.variants);
|
|
590
|
+
const enabled = options.enabled ?? false;
|
|
591
|
+
const placed = [];
|
|
592
|
+
const paths = [];
|
|
593
|
+
return {
|
|
594
|
+
variant: options.variant,
|
|
595
|
+
files,
|
|
596
|
+
get states() {
|
|
597
|
+
return [...placed];
|
|
598
|
+
},
|
|
599
|
+
get paths() {
|
|
600
|
+
return [...paths];
|
|
601
|
+
},
|
|
602
|
+
async place(state) {
|
|
603
|
+
if (!enabled) return void 0;
|
|
604
|
+
if (!registry.includes(state)) throw new Error(`Capture state "${state}" is not registered`);
|
|
605
|
+
if (placed.includes(state)) throw new Error(`Capture state "${state}" is already placed`);
|
|
606
|
+
const file = `${state}--${options.variant}.png`;
|
|
607
|
+
selected.apply?.();
|
|
608
|
+
if (window.innerWidth !== selected.width || window.innerHeight !== selected.height) await page.viewport(selected.width, selected.height);
|
|
609
|
+
const written = await page.screenshot({ path: `${options.directory}/${file}` });
|
|
610
|
+
placed.push(state);
|
|
611
|
+
paths.push(written);
|
|
612
|
+
return written;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
//#endregion
|
|
617
|
+
export { ACCESSIBLE_ROLES, clickAccessible, clickAccessibleWithin, clickDisclosure, contrast, createPortfolio, expandCaptures, fillAccessible, isOutsideViewport, pressKeys, readCascade, readFocus, readPage, readPerception, readRows, readValue, render, resolveAccessible, resolveRendered, style, traverseAccessible, typeAccessible, waitForFrame };
|
|
618
|
+
|
|
619
|
+
//# sourceMappingURL=index.js.map
|