@orkestrel/test 0.0.16 → 0.0.17
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 +7 -5
- package/dist/src/browser/index.d.ts +618 -8
- package/dist/src/browser/index.js +625 -1
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +99 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +580 -456
- package/dist/src/core/index.d.ts +580 -456
- package/dist/src/core/index.js +98 -17
- package/dist/src/core/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isError, isString } from "@orkestrel/contract";
|
|
2
|
+
import { STATECHART_ATTRIBUTES, STATECHART_STATUSES, buildRefusal, checkBounds, executeScenario, requireValue, waitForAbort, waitForCondition, waitForDelay } from "../core/index.js";
|
|
1
3
|
import { commands, page, userEvent } from "vitest/browser";
|
|
2
4
|
//#region src/browser/constants.ts
|
|
3
5
|
/**
|
|
@@ -224,6 +226,13 @@ function isOutsideViewport(rectangle) {
|
|
|
224
226
|
* refuses it. Nothing here asks about the viewport: `resolveAccessible` scrolls a wholly
|
|
225
227
|
* off-viewport target into view and measures that separately with {@link isOutsideViewport}.
|
|
226
228
|
*
|
|
229
|
+
* Inside a shadow tree it answers for the element's own facts, in an open root and a closed one
|
|
230
|
+
* alike: the box, the focus order, `:disabled`, and `aria-disabled` are all the element's. The
|
|
231
|
+
* `[inert]` ancestor is the one read that stops at the boundary, because `closest` never leaves the
|
|
232
|
+
* element's own tree, so a host marked `[inert]` is invisible here. What the flat tree decides still
|
|
233
|
+
* reaches the subject — a host the document does not lay out takes the element off the page and this
|
|
234
|
+
* refuses it. Ask the host separately where an ancestor attribute is the subject.
|
|
235
|
+
*
|
|
227
236
|
* @example
|
|
228
237
|
* ```ts
|
|
229
238
|
* isReachable(requireValue(container.querySelector('button')))
|
|
@@ -256,6 +265,13 @@ function isReachable(element) {
|
|
|
256
265
|
* for the box tree, and `visibility` inherits, so between them an ancestor cannot hide a control
|
|
257
266
|
* from a reader and leave it standing in a description.
|
|
258
267
|
*
|
|
268
|
+
* Inside a shadow tree it answers for the element's own facts, in an open root and a closed one
|
|
269
|
+
* alike. The `aria-hidden` ancestor is the one read that stops at the boundary, because `closest`
|
|
270
|
+
* never leaves the element's own tree, so a host marked `aria-hidden="true"` is invisible here and
|
|
271
|
+
* this reports `true` for a subject a reader is never told about. `checkVisibility` and the computed
|
|
272
|
+
* `visibility` read the flat tree, so a host the document does not lay out still takes the element
|
|
273
|
+
* off the page. Ask the host separately where an ancestor attribute is the subject.
|
|
274
|
+
*
|
|
259
275
|
* @example
|
|
260
276
|
* ```ts
|
|
261
277
|
* isRendered(requireValue(container.querySelector('[aria-hidden="true"] button'))) // false
|
|
@@ -531,6 +547,35 @@ async function fillAccessible(name, text) {
|
|
|
531
547
|
await userEvent.fill(resolveRendered(name), text);
|
|
532
548
|
}
|
|
533
549
|
/**
|
|
550
|
+
* Sends a key sequence to whatever holds focus, and refuses to send it to nothing.
|
|
551
|
+
*
|
|
552
|
+
* @param keys - The sequence in the provider's own key syntax, such as `{Enter}` or `{Escape}`.
|
|
553
|
+
* @returns A promise resolving after every keystroke completes.
|
|
554
|
+
* @throws When the document body holds focus, or nothing does.
|
|
555
|
+
*
|
|
556
|
+
* @remarks
|
|
557
|
+
* The refusal is the whole of what this adds over `userEvent.keyboard`. A key sent while focus sits
|
|
558
|
+
* on the body reaches no control, and every assertion after it reads the surface the key never
|
|
559
|
+
* touched — which is the false green a guarded keyboard step exists to catch. Bring focus about
|
|
560
|
+
* first through {@link traverseAccessible}, {@link clickAccessible}, or {@link typeAccessible}, and
|
|
561
|
+
* send the sequence here.
|
|
562
|
+
*
|
|
563
|
+
* Escaping is the caller's, because the sequence is the subject: `{` opens a key name and `[` opens
|
|
564
|
+
* a code name. Reach for {@link typeAccessible} where the text is the subject and the syntax is in
|
|
565
|
+
* the way.
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* ```ts
|
|
569
|
+
* await traverseAccessible('Evaluate')
|
|
570
|
+
* await pressKeys('{Enter}')
|
|
571
|
+
* ```
|
|
572
|
+
*/
|
|
573
|
+
async function pressKeys(keys) {
|
|
574
|
+
const focused = document.activeElement;
|
|
575
|
+
if (focused === null || focused === document.body) throw new Error(`Key sequence "${keys}" was sent with nothing focused`);
|
|
576
|
+
await userEvent.keyboard(keys);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
534
579
|
* Reaches a named control only through natural forward Tab traversal from the current focus.
|
|
535
580
|
*
|
|
536
581
|
* @param name - The target's exact accessible name.
|
|
@@ -669,6 +714,14 @@ function readValue(role, name) {
|
|
|
669
714
|
if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLTextAreaElement) && !(control instanceof HTMLSelectElement)) throw new Error(`Interactive target "${name}" does not carry a value`);
|
|
670
715
|
return control.value;
|
|
671
716
|
}
|
|
717
|
+
function readRefusal(first, second) {
|
|
718
|
+
try {
|
|
719
|
+
resolveRendered(first, second);
|
|
720
|
+
} catch (thrown) {
|
|
721
|
+
if (isError(thrown)) return thrown.message;
|
|
722
|
+
throw thrown;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
672
725
|
/**
|
|
673
726
|
* Reads one element's rendered text the way a name computation reads it.
|
|
674
727
|
*
|
|
@@ -920,6 +973,119 @@ function describeFocus(element) {
|
|
|
920
973
|
function waitForFrame() {
|
|
921
974
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
922
975
|
}
|
|
976
|
+
async function waitForState(first, second, third, fourth) {
|
|
977
|
+
const keyed = isString(third);
|
|
978
|
+
const role = keyed ? first : void 0;
|
|
979
|
+
const name = keyed ? second : first;
|
|
980
|
+
const state = keyed ? third : second;
|
|
981
|
+
const options = keyed ? fourth : third;
|
|
982
|
+
const absent = options?.absent ?? false;
|
|
983
|
+
const description = `"${name}" to ${absent ? "stop announcing" : "announce"} "${state}"`;
|
|
984
|
+
let observed = [];
|
|
985
|
+
let readings = 0;
|
|
986
|
+
let refused;
|
|
987
|
+
try {
|
|
988
|
+
await waitForCondition(description, () => {
|
|
989
|
+
readings += 1;
|
|
990
|
+
try {
|
|
991
|
+
observed = readStates(role === void 0 ? resolveRendered(name) : resolveRendered(role, name));
|
|
992
|
+
} catch (thrown) {
|
|
993
|
+
refused = { thrown };
|
|
994
|
+
throw thrown;
|
|
995
|
+
}
|
|
996
|
+
return observed.includes(state) !== absent;
|
|
997
|
+
}, options);
|
|
998
|
+
} catch (cause) {
|
|
999
|
+
if (refused !== void 0 && cause === refused.thrown) throw cause;
|
|
1000
|
+
if (cause === options?.signal?.reason) throw cause;
|
|
1001
|
+
if (readings === 0) throw cause;
|
|
1002
|
+
if (!isError(cause)) throw cause;
|
|
1003
|
+
throw new Error(`${cause.message} (last states: ${JSON.stringify(observed)})`, { cause });
|
|
1004
|
+
}
|
|
1005
|
+
return observed;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Waits until every finite animation on one element and its subtree has stopped moving.
|
|
1009
|
+
*
|
|
1010
|
+
* @param element - The element whose own animations and descendants' animations to wait on.
|
|
1011
|
+
* @param options - The time bounds and the abort signal.
|
|
1012
|
+
* @returns A promise resolving once no finite animation is still running.
|
|
1013
|
+
* @throws An `Error` when the element is not in a document, when a bound is invalid, or when an
|
|
1014
|
+
* animation is still running at the budget; or the abort reason.
|
|
1015
|
+
*
|
|
1016
|
+
* @remarks
|
|
1017
|
+
* A reading taken while paint is moving reports an interpolated frame — a `background-color` at a
|
|
1018
|
+
* fraction of its alpha, a `color` part way between two values — that no state of the interface
|
|
1019
|
+
* ever paints. This waits for the paint a person sees, whichever state it settles in.
|
|
1020
|
+
*
|
|
1021
|
+
* It parks on each animation's own `finished` promise rather than re-reading on a timer, and reads
|
|
1022
|
+
* the list again after each completion or cancellation, so an animation a finishing one starts is
|
|
1023
|
+
* waited on too.
|
|
1024
|
+
*
|
|
1025
|
+
* Some animations are left out, and each exclusion is a decision rather than an oversight. An animation
|
|
1026
|
+
* whose effect declares infinite iterations never finishes, so a spinner that runs forever is a
|
|
1027
|
+
* finding about the reading rather than a wait to lengthen. A finished animation filling its target
|
|
1028
|
+
* stays in the list a browser reports and is already at rest. A paused animation is at rest too,
|
|
1029
|
+
* and nothing here resumes it.
|
|
1030
|
+
*
|
|
1031
|
+
* The bounds are the wait family's, validated the same way. Default budget: `1000` milliseconds.
|
|
1032
|
+
* The interval is validated for consistency with the family and is not used, because this parks on
|
|
1033
|
+
* the animations. A detached element is refused rather than reported settled, because an element in
|
|
1034
|
+
* no document runs no animation and would answer `true` to every wait.
|
|
1035
|
+
*
|
|
1036
|
+
* @example
|
|
1037
|
+
* ```ts
|
|
1038
|
+
* await clickAccessible('Dark')
|
|
1039
|
+
* await waitForAnimations(document.body)
|
|
1040
|
+
* ```
|
|
1041
|
+
*/
|
|
1042
|
+
async function waitForAnimations(element, options) {
|
|
1043
|
+
const budget = options?.budget ?? 1e3;
|
|
1044
|
+
const interval = options?.interval ?? 10;
|
|
1045
|
+
checkBounds("Animation", budget, interval);
|
|
1046
|
+
if (!element.isConnected) throw new Error("Animation subject is not connected");
|
|
1047
|
+
const signal = options?.signal;
|
|
1048
|
+
const label = readName(element);
|
|
1049
|
+
const subject = `${readRole(element) ?? element.localName}${label.length > 0 ? ` "${label}"` : ""}`;
|
|
1050
|
+
const start = performance.now();
|
|
1051
|
+
let expired = false;
|
|
1052
|
+
let aborted;
|
|
1053
|
+
let timer;
|
|
1054
|
+
const expiry = new Promise((resolve) => {
|
|
1055
|
+
timer = setTimeout(() => {
|
|
1056
|
+
expired = true;
|
|
1057
|
+
resolve();
|
|
1058
|
+
}, budget);
|
|
1059
|
+
});
|
|
1060
|
+
try {
|
|
1061
|
+
while (true) {
|
|
1062
|
+
signal?.throwIfAborted();
|
|
1063
|
+
const running = element.getAnimations({ subtree: true }).filter((animation) => {
|
|
1064
|
+
const iterations = animation.effect?.getTiming().iterations ?? 1;
|
|
1065
|
+
return animation.playState === "running" && Number.isFinite(iterations);
|
|
1066
|
+
});
|
|
1067
|
+
if (running.length === 0) return;
|
|
1068
|
+
const elapsed = performance.now() - start;
|
|
1069
|
+
if (expired || elapsed >= budget) {
|
|
1070
|
+
const names = running.map((animation) => {
|
|
1071
|
+
if (animation instanceof CSSAnimation) return animation.animationName;
|
|
1072
|
+
if (animation instanceof CSSTransition) return animation.transitionProperty;
|
|
1073
|
+
return animation.id;
|
|
1074
|
+
});
|
|
1075
|
+
throw new Error(`Animation "${subject}" did not settle within ${budget}ms (waited ${elapsed}ms): ${names.join(", ")}`);
|
|
1076
|
+
}
|
|
1077
|
+
const pending = running.map((animation) => animation.finished.catch(() => void 0));
|
|
1078
|
+
pending.push(expiry);
|
|
1079
|
+
if (signal !== void 0) {
|
|
1080
|
+
aborted ??= waitForAbort(signal);
|
|
1081
|
+
pending.push(aborted);
|
|
1082
|
+
}
|
|
1083
|
+
await Promise.race(pending);
|
|
1084
|
+
}
|
|
1085
|
+
} finally {
|
|
1086
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
923
1089
|
/**
|
|
924
1090
|
* Builds one unmounted element of a known tag, wearing the classes, text, and attributes asked for.
|
|
925
1091
|
*
|
|
@@ -1484,6 +1650,42 @@ function readClasses(root) {
|
|
|
1484
1650
|
return authored;
|
|
1485
1651
|
}
|
|
1486
1652
|
/**
|
|
1653
|
+
* Takes the authored-class census of one subtree against the cascade this document loaded.
|
|
1654
|
+
*
|
|
1655
|
+
* @param root - The subtree to walk. A detached element and a `DocumentFragment` both work.
|
|
1656
|
+
* @returns The population walked, every class token the markup carries, and every one of them no
|
|
1657
|
+
* loaded stylesheet declares; both lists sorted.
|
|
1658
|
+
* @throws An `Error` when the walk reads no element at all.
|
|
1659
|
+
*
|
|
1660
|
+
* @remarks
|
|
1661
|
+
* This is {@link readClasses} differenced against {@link readCascade}, with the population reported
|
|
1662
|
+
* beside the difference. The population is what makes the reading falsifiable: an empty walk
|
|
1663
|
+
* reports no undeclared token, and so does a subtree whose every class the cascade declares, so a
|
|
1664
|
+
* check reading `undeclared` alone passes for a census that read nothing. The empty walk is refused
|
|
1665
|
+
* outright for the same reason.
|
|
1666
|
+
*
|
|
1667
|
+
* The root counts when it is an `Element`, so a `DocumentFragment` contributes its descendants
|
|
1668
|
+
* alone. Both lists are sorted rather than left in sighting order, because a census is compared
|
|
1669
|
+
* against a previous one or against an expected list, and document order is not a fact about the
|
|
1670
|
+
* classes.
|
|
1671
|
+
*
|
|
1672
|
+
* @example
|
|
1673
|
+
* ```ts
|
|
1674
|
+
* readCensus(container).undeclared // ['lead'] — no loaded stylesheet declares it
|
|
1675
|
+
* ```
|
|
1676
|
+
*/
|
|
1677
|
+
function readCensus(root) {
|
|
1678
|
+
const elements = (root instanceof Element ? 1 : 0) + root.querySelectorAll("*").length;
|
|
1679
|
+
if (elements === 0) throw new Error("Class census walked no element");
|
|
1680
|
+
const declared = readCascade();
|
|
1681
|
+
const tokens = [...readClasses(root)].sort();
|
|
1682
|
+
return Object.freeze({
|
|
1683
|
+
elements,
|
|
1684
|
+
tokens: Object.freeze(tokens),
|
|
1685
|
+
undeclared: Object.freeze(tokens.filter((token) => !declared.has(token)))
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1487
1689
|
* Collects every rule the stylesheets loaded into this document hold, nested grouping rules
|
|
1488
1690
|
* included.
|
|
1489
1691
|
*
|
|
@@ -2063,6 +2265,196 @@ function expandCaptures(states, variants) {
|
|
|
2063
2265
|
for (const state of states) for (const variant of variants) files.push(`${state}--${variant.name}.png`);
|
|
2064
2266
|
return files;
|
|
2065
2267
|
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Builds the refusal a host withholding a storage operation raises.
|
|
2270
|
+
*
|
|
2271
|
+
* @param operation - The withheld operation, named as the `Storage` interface names it.
|
|
2272
|
+
* @param key - The storage key the operation addressed. Omit it for an operation that takes none.
|
|
2273
|
+
* @returns The refusal, unthrown.
|
|
2274
|
+
*
|
|
2275
|
+
* @remarks
|
|
2276
|
+
* A browser with site data blocked, a sandboxed frame, and a hardened privacy mode all raise a
|
|
2277
|
+
* `DOMException` named `SecurityError` from the storage object rather than answering, so this is
|
|
2278
|
+
* the voice rather than a message of this package's. {@link createStorage} raises it from every
|
|
2279
|
+
* operation the permission withholds; it is exported because a fixture implementing `Storage` some
|
|
2280
|
+
* other way needs the same voice rather than a second spelling of it.
|
|
2281
|
+
*
|
|
2282
|
+
* @example
|
|
2283
|
+
* ```ts
|
|
2284
|
+
* buildDenial('getItem', 'theme').message // 'Access is denied for getItem "theme"'
|
|
2285
|
+
* buildDenial('length').name // 'SecurityError'
|
|
2286
|
+
* ```
|
|
2287
|
+
*/
|
|
2288
|
+
function buildDenial(operation, key) {
|
|
2289
|
+
return new DOMException(`Access is denied for ${operation}${key === void 0 ? "" : ` "${key}"`}`, "SecurityError");
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Builds a detached translucent stack whose composited and flat contrast readings straddle one bar.
|
|
2293
|
+
*
|
|
2294
|
+
* @param bar - The contrast ratio `refused` and `accepted` must sit on opposite sides of.
|
|
2295
|
+
* @returns The opaque root carrying the tint, and the refused and accepted foregrounds under it.
|
|
2296
|
+
* @throws An `Error` when no grey foreground puts the two readings on opposite sides of the bar.
|
|
2297
|
+
*
|
|
2298
|
+
* @remarks
|
|
2299
|
+
* A contrast instrument that never composites still clears every fixture painting its own opaque
|
|
2300
|
+
* background, so this is the control that makes {@link readContrast}'s ancestor walk and alpha
|
|
2301
|
+
* blend the thing under test. The stack is an opaque floor, a translucent tint over it, and two
|
|
2302
|
+
* grey foregrounds inside the tint. `refused` reads under the bar composited and at or over it
|
|
2303
|
+
* flat, and `accepted` reads the other way about, so no single non-compositing reading satisfies
|
|
2304
|
+
* both.
|
|
2305
|
+
*
|
|
2306
|
+
* The greys are searched rather than written down, so the control follows the bar it was asked for.
|
|
2307
|
+
* A bar at or under `1` is refused because every contrast ratio reaches `1`, and a bar above what
|
|
2308
|
+
* the tinted surface can reach is refused because no foreground clears it — each refusal names the
|
|
2309
|
+
* bar rather than returning a stack that proves nothing.
|
|
2310
|
+
*
|
|
2311
|
+
* The nodes are detached, so nothing is mounted for you. Append `root` to the surface you are
|
|
2312
|
+
* reading, take both readings, and remove it: a computed color needs the document, and a fixture
|
|
2313
|
+
* left behind is the next test's resolver ambiguity.
|
|
2314
|
+
*
|
|
2315
|
+
* @example
|
|
2316
|
+
* ```ts
|
|
2317
|
+
* const control = buildContrast(4.5)
|
|
2318
|
+
* mount(control.root)
|
|
2319
|
+
* readContrast(control.refused) < 4.5 // true
|
|
2320
|
+
* readContrast(control.accepted) >= 4.5 // true
|
|
2321
|
+
* control.root.remove()
|
|
2322
|
+
* ```
|
|
2323
|
+
*/
|
|
2324
|
+
function buildContrast(bar) {
|
|
2325
|
+
const tint = [
|
|
2326
|
+
0,
|
|
2327
|
+
0,
|
|
2328
|
+
0,
|
|
2329
|
+
.06
|
|
2330
|
+
];
|
|
2331
|
+
const backdrop = blendColor(tint, CANVAS_COLOR);
|
|
2332
|
+
const flattened = [
|
|
2333
|
+
tint[0],
|
|
2334
|
+
tint[1],
|
|
2335
|
+
tint[2],
|
|
2336
|
+
1
|
|
2337
|
+
];
|
|
2338
|
+
let refusedChannel;
|
|
2339
|
+
let acceptedChannel;
|
|
2340
|
+
for (let channel = 0; channel <= 255; channel += 1) {
|
|
2341
|
+
const front = [
|
|
2342
|
+
channel,
|
|
2343
|
+
channel,
|
|
2344
|
+
channel,
|
|
2345
|
+
1
|
|
2346
|
+
];
|
|
2347
|
+
const composited = measureContrast(front, backdrop);
|
|
2348
|
+
const flat = measureContrast(front, flattened);
|
|
2349
|
+
if (refusedChannel === void 0 && composited < bar && flat >= bar) refusedChannel = channel;
|
|
2350
|
+
if (acceptedChannel === void 0 && composited >= bar && flat < bar) acceptedChannel = channel;
|
|
2351
|
+
}
|
|
2352
|
+
if (refusedChannel === void 0 || acceptedChannel === void 0) throw new Error(`Contrast control cannot straddle the bar ${bar}`);
|
|
2353
|
+
const root = build("div", { attributes: { style: `background-color: rgb(${CANVAS_COLOR[0]}, ${CANVAS_COLOR[1]}, ${CANVAS_COLOR[2]})` } });
|
|
2354
|
+
const tinted = build("div", { attributes: { style: `background-color: rgba(${tint[0]}, ${tint[1]}, ${tint[2]}, ${tint[3]})` } });
|
|
2355
|
+
const refused = build("p", {
|
|
2356
|
+
text: "Composited contrast control",
|
|
2357
|
+
attributes: { style: `color: rgb(${refusedChannel}, ${refusedChannel}, ${refusedChannel})` }
|
|
2358
|
+
});
|
|
2359
|
+
const accepted = build("p", {
|
|
2360
|
+
text: "Composited contrast survivor",
|
|
2361
|
+
attributes: { style: `color: rgb(${acceptedChannel}, ${acceptedChannel}, ${acceptedChannel})` }
|
|
2362
|
+
});
|
|
2363
|
+
tinted.append(refused, accepted);
|
|
2364
|
+
root.append(tinted);
|
|
2365
|
+
return Object.freeze({
|
|
2366
|
+
root,
|
|
2367
|
+
refused,
|
|
2368
|
+
accepted
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Builds detached markup carrying one style escape of each kind, plus the sheet a project allows.
|
|
2373
|
+
*
|
|
2374
|
+
* @param permitted - The `id` the caller's reading exempts, placed on the third element.
|
|
2375
|
+
* @returns The detached root, the inline escape, the embedded escape, and the exempt sheet.
|
|
2376
|
+
*
|
|
2377
|
+
* @remarks
|
|
2378
|
+
* {@link extractStyles} has two branches — an inline `style` attribute and a `<style>` element —
|
|
2379
|
+
* and a reading fed only the first never exercises the second. The exempt sheet is the other half
|
|
2380
|
+
* of the control: a project that allows one standalone stylesheet writes an exemption for its id,
|
|
2381
|
+
* and a reading that passes by refusing every `<style>` element clears the two escapes and fails
|
|
2382
|
+
* that exemption.
|
|
2383
|
+
*
|
|
2384
|
+
* The declaration `inline`, `embedded`, and `permitted` carry is this package's, so assert on which
|
|
2385
|
+
* elements are reported rather than on what they declare.
|
|
2386
|
+
*
|
|
2387
|
+
* The nodes are detached and stay that way: `extractStyles` takes any `ParentNode`, so the reading
|
|
2388
|
+
* runs without mounting, and an embedded sheet that reached the document would join the cascade
|
|
2389
|
+
* every other reading measures against.
|
|
2390
|
+
*
|
|
2391
|
+
* @example
|
|
2392
|
+
* ```ts
|
|
2393
|
+
* const control = buildEscapes('project-stylesheet')
|
|
2394
|
+
* extractStyles(control.root).length // 3 — the inline escape, the embedded sheet, and the exempt one
|
|
2395
|
+
* ```
|
|
2396
|
+
*/
|
|
2397
|
+
function buildEscapes(permitted) {
|
|
2398
|
+
const declaration = "color: rgb(1, 2, 3)";
|
|
2399
|
+
const root = build("div");
|
|
2400
|
+
const inline = build("p", {
|
|
2401
|
+
text: "Inline escape control",
|
|
2402
|
+
attributes: { style: declaration }
|
|
2403
|
+
});
|
|
2404
|
+
const embedded = build("style");
|
|
2405
|
+
embedded.textContent = `.escape-embedded { ${declaration} }`;
|
|
2406
|
+
const exempt = build("style", { attributes: { id: permitted } });
|
|
2407
|
+
exempt.textContent = `#escape-permitted { ${declaration} }`;
|
|
2408
|
+
root.append(inline, embedded, exempt);
|
|
2409
|
+
return Object.freeze({
|
|
2410
|
+
root,
|
|
2411
|
+
inline,
|
|
2412
|
+
embedded,
|
|
2413
|
+
permitted: exempt
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Builds detached markup carrying one undeclared class token on HTML and another on SVG.
|
|
2418
|
+
*
|
|
2419
|
+
* @returns The detached root, the token the HTML element carries, and the one the SVG carries.
|
|
2420
|
+
*
|
|
2421
|
+
* @remarks
|
|
2422
|
+
* The SVG element is the trap a census has to survive: `className` on an SVG element is an
|
|
2423
|
+
* `SVGAnimatedString` rather than a string, so a reader splitting that value finds nothing and
|
|
2424
|
+
* reports one undeclared token where two are carried. {@link readCensus} reads every element
|
|
2425
|
+
* through `classList`, and this is the control that proves it.
|
|
2426
|
+
*
|
|
2427
|
+
* The two tokens are returned rather than written into a caller's expectation, so a cascade that
|
|
2428
|
+
* later declares one of these names moves the fixture and the assertion together. Each token also
|
|
2429
|
+
* carries a suffix drawn per call from `crypto.getRandomValues`, so a consumer cascade cannot
|
|
2430
|
+
* declare either of them in advance and two controls in one document never share a token.
|
|
2431
|
+
* `getRandomValues` rather than `randomUUID`, because that one answers outside a secure context
|
|
2432
|
+
* too, and a browser project served from a remote host is not one.
|
|
2433
|
+
*
|
|
2434
|
+
* @example
|
|
2435
|
+
* ```ts
|
|
2436
|
+
* const control = buildCensus()
|
|
2437
|
+
* readCensus(control.root).undeclared // [control.mark, control.token], sorted
|
|
2438
|
+
* ```
|
|
2439
|
+
*/
|
|
2440
|
+
function buildCensus() {
|
|
2441
|
+
const suffix = crypto.getRandomValues(/* @__PURE__ */ new Uint32Array(1)).join("");
|
|
2442
|
+
const token = `census-authored-token-${suffix}`;
|
|
2443
|
+
const mark = `census-authored-mark-${suffix}`;
|
|
2444
|
+
const root = build("div");
|
|
2445
|
+
root.append(build("p", {
|
|
2446
|
+
classes: token,
|
|
2447
|
+
text: "Authored class control"
|
|
2448
|
+
}));
|
|
2449
|
+
const glyph = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
2450
|
+
glyph.setAttribute("class", mark);
|
|
2451
|
+
root.append(glyph);
|
|
2452
|
+
return Object.freeze({
|
|
2453
|
+
root,
|
|
2454
|
+
token,
|
|
2455
|
+
mark
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2066
2458
|
//#endregion
|
|
2067
2459
|
//#region src/browser/factories.ts
|
|
2068
2460
|
/**
|
|
@@ -2306,7 +2698,239 @@ function createJournal() {
|
|
|
2306
2698
|
}
|
|
2307
2699
|
};
|
|
2308
2700
|
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Creates an inert `Storage` a host can withhold, grant, and run out of room in.
|
|
2703
|
+
*
|
|
2704
|
+
* @param options - The seed, the read and write permissions, and the quota.
|
|
2705
|
+
* @returns A store carrying the Web Storage surface plus the grant.
|
|
2706
|
+
* @throws An `Error` when `quota` is not a non-negative safe integer. A quota above
|
|
2707
|
+
* `Number.MAX_SAFE_INTEGER` carries the same sentence, because a counter that cannot decrement past
|
|
2708
|
+
* that point bounds nothing.
|
|
2709
|
+
*
|
|
2710
|
+
* @remarks
|
|
2711
|
+
* Conditions a real origin produces are unreachable from a test otherwise: a browser with
|
|
2712
|
+
* site data blocked refuses every operation the permission withholds, an origin with no room left
|
|
2713
|
+
* refuses `setItem`, and a person allowing site data grants what was withheld. This makes each of
|
|
2714
|
+
* them reachable against a real `Storage` surface rather than a shaped object.
|
|
2715
|
+
*
|
|
2716
|
+
* The store answers through its methods and intercepts no named-property access, so drive a
|
|
2717
|
+
* consumer under test through `getItem` and `setItem`. `Storage` declares an index signature, so
|
|
2718
|
+
* `store.theme` typechecks and reads `undefined` while `getItem('theme')` answers, and a property
|
|
2719
|
+
* write lands on the object rather than in the store, consuming no quota and meeting no refusal.
|
|
2720
|
+
*
|
|
2721
|
+
* It is backed by a map of its own and patches nothing: `localStorage` and `sessionStorage` are
|
|
2722
|
+
* untouched, no `storage` event is dispatched, and the store is reached only by the code the test
|
|
2723
|
+
* hands it to. Reach for {@link clearStorage} where the real browser surfaces are the subject.
|
|
2724
|
+
*
|
|
2725
|
+
* `length`, `key`, and `getItem` are reads; `clear`, `removeItem`, and `setItem` are writes. A
|
|
2726
|
+
* withheld operation raises {@link buildDenial}'s `SecurityError`, and `permit` lifts both
|
|
2727
|
+
* permissions at once, the way a person allowing site data lifts them. Reads answer from what the
|
|
2728
|
+
* store actually accepted, so a journey reads what the application kept while the host was refusing.
|
|
2729
|
+
*
|
|
2730
|
+
* `quota` counts accepted `setItem` calls rather than bytes, because the number of writes is what a
|
|
2731
|
+
* journey scripts and a byte budget is the browser's own arithmetic. `removeItem` consumes none of
|
|
2732
|
+
* it, and `permit` replenishes none of it: room and permission are different refusals, and a test
|
|
2733
|
+
* that granted the permission still meets the full origin.
|
|
2734
|
+
*
|
|
2735
|
+
* @example
|
|
2736
|
+
* ```ts
|
|
2737
|
+
* const storage = createStorage({ values: { theme: 'dark' }, writes: false })
|
|
2738
|
+
* storage.getItem('theme') // 'dark'
|
|
2739
|
+
* storage.permit()
|
|
2740
|
+
* storage.setItem('theme', 'light')
|
|
2741
|
+
* ```
|
|
2742
|
+
*/
|
|
2743
|
+
function createStorage(options) {
|
|
2744
|
+
const quota = options?.quota;
|
|
2745
|
+
if (quota !== void 0 && (!Number.isSafeInteger(quota) || quota < 0)) throw new Error("Storage quota must be a non-negative integer");
|
|
2746
|
+
const values = new Map(Object.entries(options?.values ?? {}));
|
|
2747
|
+
let reads = options?.reads ?? true;
|
|
2748
|
+
let writes = options?.writes ?? true;
|
|
2749
|
+
let room = quota;
|
|
2750
|
+
return {
|
|
2751
|
+
get length() {
|
|
2752
|
+
if (!reads) throw buildDenial("length");
|
|
2753
|
+
return values.size;
|
|
2754
|
+
},
|
|
2755
|
+
permit() {
|
|
2756
|
+
reads = true;
|
|
2757
|
+
writes = true;
|
|
2758
|
+
},
|
|
2759
|
+
clear() {
|
|
2760
|
+
if (!writes) throw buildDenial("clear");
|
|
2761
|
+
values.clear();
|
|
2762
|
+
},
|
|
2763
|
+
getItem(key) {
|
|
2764
|
+
if (!reads) throw buildDenial("getItem", key);
|
|
2765
|
+
return values.get(key) ?? null;
|
|
2766
|
+
},
|
|
2767
|
+
key(index) {
|
|
2768
|
+
if (!reads) throw buildDenial("key");
|
|
2769
|
+
return [...values.keys()][index] ?? null;
|
|
2770
|
+
},
|
|
2771
|
+
removeItem(key) {
|
|
2772
|
+
if (!writes) throw buildDenial("removeItem", key);
|
|
2773
|
+
values.delete(key);
|
|
2774
|
+
},
|
|
2775
|
+
setItem(key, value) {
|
|
2776
|
+
if (!writes) throw buildDenial("setItem", key);
|
|
2777
|
+
if (room !== void 0) {
|
|
2778
|
+
if (room === 0) throw new DOMException(`No room is left for ${key}`, "QuotaExceededError");
|
|
2779
|
+
room -= 1;
|
|
2780
|
+
}
|
|
2781
|
+
values.set(key, value);
|
|
2782
|
+
}
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Creates a mounted statechart harness that renders one transition table and drives it row by row.
|
|
2787
|
+
*
|
|
2788
|
+
* @typeParam TState - The states the entity moves between.
|
|
2789
|
+
* @typeParam TEvent - The events the entity accepts.
|
|
2790
|
+
* @typeParam TContext - The fixture each row drives.
|
|
2791
|
+
* @param options - The table, the fixture builder, the state reader, and the delay between rows.
|
|
2792
|
+
* @returns The mounted harness, standing idle with its tally at zero.
|
|
2793
|
+
* @throws An `Error` reading `Statechart harness mounted no transition` for an empty table, before
|
|
2794
|
+
* anything reaches the document.
|
|
2795
|
+
*
|
|
2796
|
+
* @remarks
|
|
2797
|
+
* A page cannot import this package, because the browser entry imports `vitest/browser` at module
|
|
2798
|
+
* scope. So the harness is test-side: the suite mounts it, a gate outside the page polls the
|
|
2799
|
+
* markup it renders, and `STATECHART_ATTRIBUTES` is the whole contract between the two. Nothing
|
|
2800
|
+
* here spells a `data-statechart-*` string of its own, and neither does a gate. That gate has no
|
|
2801
|
+
* rejection channel, so every exit writes a terminal status: a run that completes writes `passed` or
|
|
2802
|
+
* `failed`, and a run that a `state` reader or a non-`Error` phase throw ends writes `failed` and
|
|
2803
|
+
* then rejects with that value by identity, without counting the row as failed.
|
|
2804
|
+
*
|
|
2805
|
+
* The markup is framework-free. The root carries `status` and the tally; a `role="status"`
|
|
2806
|
+
* announcer narrates each step in a sentence; one element carries `state` and renders what the
|
|
2807
|
+
* entity's own reader reports; and an ordered list carries one row per scenario, each labelled with
|
|
2808
|
+
* its `from`, its `event`, and its `to` and marked with the transition's name. The `state` element
|
|
2809
|
+
* mounts empty and takes its attribute from the first row that produces a context, because a state
|
|
2810
|
+
* is read from an entity and no entity exists until a row builds one.
|
|
2811
|
+
*
|
|
2812
|
+
* Construction writes `pending`, mounts the root, renders every row, then writes the row count and
|
|
2813
|
+
* `idle` — so a gate that reads `pending` has found a harness whose rows never mounted, and the
|
|
2814
|
+
* order is observable from outside through the mutations the document records.
|
|
2815
|
+
*
|
|
2816
|
+
* `execute` clears every rendered result and the rendered state, writes `running`, and drives each
|
|
2817
|
+
* row in order through {@link executeScenario} against a context of that row's own. It continues
|
|
2818
|
+
* past a failing row, so one run reports on the whole table rather than stopping at the first
|
|
2819
|
+
* finding, and a builder that throws counts as its row failing under {@link buildRefusal}'s
|
|
2820
|
+
* sentence, which is the one `executeScenarios` raises. What decides whether a row's phases run is
|
|
2821
|
+
* whether its builder returned, not what it returned, so a table whose context is `undefined` drives
|
|
2822
|
+
* every phase. A second `execute` runs the same table from a fresh tally and a cleared state.
|
|
2823
|
+
*
|
|
2824
|
+
* Every reading comes off the markup, so the object and the page cannot disagree, and `failures` is
|
|
2825
|
+
* the `scenario` name of each row whose rendered `result` reads `failed` rather than a second list
|
|
2826
|
+
* beside them.
|
|
2827
|
+
*
|
|
2828
|
+
* @example
|
|
2829
|
+
* ```ts
|
|
2830
|
+
* const harness = createHarness({ scenarios: SCENARIOS, build: buildDisclosure, state: readState })
|
|
2831
|
+
* await harness.execute()
|
|
2832
|
+
* harness.status // 'passed'
|
|
2833
|
+
* harness.destroy()
|
|
2834
|
+
* ```
|
|
2835
|
+
*/
|
|
2836
|
+
function createHarness(options) {
|
|
2837
|
+
const scenarios = options.scenarios;
|
|
2838
|
+
if (scenarios.length === 0) throw new Error("Statechart harness mounted no transition");
|
|
2839
|
+
const root = mount(build("div", { attributes: { [STATECHART_ATTRIBUTES.status]: "pending" } }));
|
|
2840
|
+
const announcer = build("p", { attributes: { role: "status" } });
|
|
2841
|
+
const state = build("p");
|
|
2842
|
+
const rows = build("ol");
|
|
2843
|
+
root.append(announcer, state, rows);
|
|
2844
|
+
const table = scenarios.map((scenario) => ({
|
|
2845
|
+
scenario,
|
|
2846
|
+
element: build("li", {
|
|
2847
|
+
text: `${scenario.transition.name}: ${scenario.transition.from} on ${scenario.transition.event} becomes ${scenario.transition.to}`,
|
|
2848
|
+
attributes: { [STATECHART_ATTRIBUTES.scenario]: scenario.transition.name }
|
|
2849
|
+
})
|
|
2850
|
+
}));
|
|
2851
|
+
for (const row of table) rows.append(row.element);
|
|
2852
|
+
root.setAttribute(STATECHART_ATTRIBUTES.total, String(table.length));
|
|
2853
|
+
root.setAttribute(STATECHART_ATTRIBUTES.passed, "0");
|
|
2854
|
+
root.setAttribute(STATECHART_ATTRIBUTES.failed, "0");
|
|
2855
|
+
root.setAttribute(STATECHART_ATTRIBUTES.status, "idle");
|
|
2856
|
+
announcer.textContent = `Statechart harness is idle, 0 passed and 0 failed of ${table.length}.`;
|
|
2857
|
+
return {
|
|
2858
|
+
root,
|
|
2859
|
+
get status() {
|
|
2860
|
+
const written = root.getAttribute(STATECHART_ATTRIBUTES.status);
|
|
2861
|
+
return requireValue(STATECHART_STATUSES.find((member) => member === written), "Statechart harness carries no status");
|
|
2862
|
+
},
|
|
2863
|
+
get total() {
|
|
2864
|
+
return Number(root.getAttribute(STATECHART_ATTRIBUTES.total));
|
|
2865
|
+
},
|
|
2866
|
+
get passed() {
|
|
2867
|
+
return Number(root.getAttribute(STATECHART_ATTRIBUTES.passed));
|
|
2868
|
+
},
|
|
2869
|
+
get failed() {
|
|
2870
|
+
return Number(root.getAttribute(STATECHART_ATTRIBUTES.failed));
|
|
2871
|
+
},
|
|
2872
|
+
get failures() {
|
|
2873
|
+
const names = [];
|
|
2874
|
+
for (const row of table) {
|
|
2875
|
+
if (row.element.getAttribute(STATECHART_ATTRIBUTES.result) !== "failed") continue;
|
|
2876
|
+
const name = row.element.getAttribute(STATECHART_ATTRIBUTES.scenario);
|
|
2877
|
+
if (name !== null) names.push(name);
|
|
2878
|
+
}
|
|
2879
|
+
return names;
|
|
2880
|
+
},
|
|
2881
|
+
async execute() {
|
|
2882
|
+
for (const row of table) row.element.removeAttribute(STATECHART_ATTRIBUTES.result);
|
|
2883
|
+
state.removeAttribute(STATECHART_ATTRIBUTES.state);
|
|
2884
|
+
state.textContent = "";
|
|
2885
|
+
let passed = 0;
|
|
2886
|
+
let failed = 0;
|
|
2887
|
+
root.setAttribute(STATECHART_ATTRIBUTES.passed, "0");
|
|
2888
|
+
root.setAttribute(STATECHART_ATTRIBUTES.failed, "0");
|
|
2889
|
+
root.setAttribute(STATECHART_ATTRIBUTES.status, "running");
|
|
2890
|
+
announcer.textContent = `Statechart harness is running, 0 passed and 0 failed of ${table.length}.`;
|
|
2891
|
+
try {
|
|
2892
|
+
for (const [index, row] of table.entries()) {
|
|
2893
|
+
let built;
|
|
2894
|
+
let refusal;
|
|
2895
|
+
try {
|
|
2896
|
+
built = { context: await options.build(row.scenario) };
|
|
2897
|
+
} catch (cause) {
|
|
2898
|
+
refusal = buildRefusal(row.scenario.transition.name, cause).message;
|
|
2899
|
+
}
|
|
2900
|
+
if (built !== void 0) {
|
|
2901
|
+
try {
|
|
2902
|
+
await executeScenario(row.scenario, built.context);
|
|
2903
|
+
} catch (cause) {
|
|
2904
|
+
if (!isError(cause)) throw cause;
|
|
2905
|
+
refusal = cause.message;
|
|
2906
|
+
}
|
|
2907
|
+
const current = options.state(built.context);
|
|
2908
|
+
state.setAttribute(STATECHART_ATTRIBUTES.state, current);
|
|
2909
|
+
state.textContent = current;
|
|
2910
|
+
}
|
|
2911
|
+
if (refusal === void 0) passed += 1;
|
|
2912
|
+
else failed += 1;
|
|
2913
|
+
row.element.setAttribute(STATECHART_ATTRIBUTES.result, refusal === void 0 ? "passed" : "failed");
|
|
2914
|
+
root.setAttribute(STATECHART_ATTRIBUTES.passed, String(passed));
|
|
2915
|
+
root.setAttribute(STATECHART_ATTRIBUTES.failed, String(failed));
|
|
2916
|
+
announcer.textContent = refusal ?? `${row.scenario.transition.name} passed.`;
|
|
2917
|
+
if (options.pause !== void 0 && index < table.length - 1) await waitForDelay(options.pause);
|
|
2918
|
+
}
|
|
2919
|
+
} catch (cause) {
|
|
2920
|
+
root.setAttribute(STATECHART_ATTRIBUTES.status, "failed");
|
|
2921
|
+
announcer.textContent = `Statechart harness failed, ${passed} passed and ${failed} failed of ${table.length}.`;
|
|
2922
|
+
throw cause;
|
|
2923
|
+
}
|
|
2924
|
+
const outcome = failed === 0 ? "passed" : "failed";
|
|
2925
|
+
root.setAttribute(STATECHART_ATTRIBUTES.status, outcome);
|
|
2926
|
+
announcer.textContent = `Statechart harness ${outcome}, ${passed} passed and ${failed} failed of ${table.length}.`;
|
|
2927
|
+
},
|
|
2928
|
+
destroy() {
|
|
2929
|
+
root.remove();
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2309
2933
|
//#endregion
|
|
2310
|
-
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, readHit, 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 };
|
|
2934
|
+
export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CAPTURE_STAGINGS, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, build, buildCensus, buildContrast, buildDenial, buildEscapes, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, commitInput, computeNamePattern, createChannel, createDragEvent, createHarness, createJournal, createPointerEvent, createPortfolio, createStorage, describeFocus, describeTree, expandCaptures, extractOrphans, extractStyles, fillAccessible, findKeyframes, findRule, isOutsideViewport, isReachable, isRendered, matchesColor, measureContent, measureContrast, measureLuminance, mount, parseCSSColor, parseColor, pressKeys, readBackdrop, readCascade, readCensus, readClasses, readContrast, readFocus, readFrame, readHit, readLayers, readName, readPage, readPerception, readPixels, readRefusal, readRing, readRole, readRootToken, readRows, readRules, readStates, readStyle, readText, readToken, readValue, releasePane, removeDatabase, render, resolveAccessible, resolveRendered, stagePane, traverseAccessible, typeAccessible, typeInput, waitForAnimations, waitForFrame, waitForState };
|
|
2311
2935
|
|
|
2312
2936
|
//# sourceMappingURL=index.js.map
|