@orkestrel/test 0.0.15 → 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.
@@ -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
@@ -269,6 +285,67 @@ function isRendered(element) {
269
285
  return getComputedStyle(element).visibility !== "hidden";
270
286
  }
271
287
  /**
288
+ * Reads the topmost element at one element's bounding-box centre.
289
+ *
290
+ * @param element - The element whose bounding-box centre is the point to read.
291
+ * @returns The element the owner document's hit test names at that point, or `undefined` where the
292
+ * point lies outside that viewport or reaches nothing.
293
+ *
294
+ * @remarks
295
+ * This reads one point and nothing else: the centre of the element's own bounding box, hit-tested
296
+ * against `element.ownerDocument`. That point is where a thumb aimed at the middle of what it sees
297
+ * lands, and the arrangements that take it away are the ones {@link isReachable} cannot see: a
298
+ * sticky masthead covering a control that was scrolled to, and a wrapped inline target, whose
299
+ * per-line rectangles leave a gap the single bounding box spans and whose centre falls in that gap
300
+ * on the ancestor. `isReachable` reads `checkVisibility`, geometry, and the focus order, and each
301
+ * arrangement passes all three while a click at that point misses.
302
+ *
303
+ * It does not predict where the installed driver clicks. `playwright-core@1.63.0` clips each
304
+ * content quad to the viewport, drops every quad left without area, and takes the midpoint of the
305
+ * first quad that survives — `_clickablePoint` at `playwright-core/lib/coreBundle.js:20084`,
306
+ * reached from the locator `click` the Vitest provider delegates to. For the wrapped target the
307
+ * first surviving quad is the first line box, so the driver aims inside the link while this centre
308
+ * sits in the gap. Read a result as the answer for the point this names, and for no other.
309
+ *
310
+ * It returns the node rather than a verdict, because the node is the diagnosis: a caller narrows
311
+ * the result, rules on `link.contains(hit)`, and names what came back — the list item rather than
312
+ * the link, the masthead rather than the control.
313
+ *
314
+ * Pass {@link isRendered} and {@link isReachable} before reading, because neither answer means
315
+ * anything on an element that failed them. An element the document does not render measures a zero
316
+ * rectangle at the origin, and a zero-area element measures a point on its own edge; each is
317
+ * hit-tested like any other point and names whatever paints there — the surrounding container for
318
+ * a control clipped inside one, and the document body for a rectangle collapsed at the origin.
319
+ * `contains` is then false, and a caller that skipped the gates reports a cover that is not there.
320
+ *
321
+ * A returned node carries silences of its own. A cover painted with `pointer-events: none` is
322
+ * absent from the hit test, so the reading names the element underneath it and the caller reads
323
+ * reachable for a cover a person can see. An element inside a shadow tree retargets, in an open
324
+ * root and a closed one alike: the document-level hit test names the host, the inner element does
325
+ * not contain the host, and the caller reads the element's own host as a cover. Ask
326
+ * `element.getRootNode()` for its own `elementFromPoint` where the subject sits in a shadow tree.
327
+ *
328
+ * `undefined` carries one silence: a centre outside the viewport reads the same as a centre that
329
+ * reaches nothing. {@link isOutsideViewport} does not separate them, because it asks whether the
330
+ * whole rectangle misses the viewport while this asks where one point lands — a rectangle at
331
+ * `left: -80` with `width: 100` has its right edge at 20, so that predicate reports false while the
332
+ * centre at -30 reads `undefined` here. The two also measure different windows: the predicate reads
333
+ * the global `window`, and this reads `element.ownerDocument`. Compare the centre against that
334
+ * document's own viewport where the distinction is the subject.
335
+ *
336
+ * @example
337
+ * ```ts
338
+ * const link = requireValue(container.querySelector('a'))
339
+ * const hit = readHit(link)
340
+ * // False for a wrapped link whose centre sits between its line boxes.
341
+ * hit !== undefined && link.contains(hit)
342
+ * ```
343
+ */
344
+ function readHit(element) {
345
+ const rectangle = element.getBoundingClientRect();
346
+ return element.ownerDocument.elementFromPoint(rectangle.left + rectangle.width / 2, rectangle.top + rectangle.height / 2) ?? void 0;
347
+ }
348
+ /**
272
349
  * Computes the pattern that matches one accessible name a decorative glyph may sit beside.
273
350
  *
274
351
  * @param name - The exact accessible name a person reads, whitespace runs collapsed on the way in.
@@ -470,6 +547,35 @@ async function fillAccessible(name, text) {
470
547
  await userEvent.fill(resolveRendered(name), text);
471
548
  }
472
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
+ /**
473
579
  * Reaches a named control only through natural forward Tab traversal from the current focus.
474
580
  *
475
581
  * @param name - The target's exact accessible name.
@@ -608,6 +714,14 @@ function readValue(role, name) {
608
714
  if (!(control instanceof HTMLInputElement) && !(control instanceof HTMLTextAreaElement) && !(control instanceof HTMLSelectElement)) throw new Error(`Interactive target "${name}" does not carry a value`);
609
715
  return control.value;
610
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
+ }
611
725
  /**
612
726
  * Reads one element's rendered text the way a name computation reads it.
613
727
  *
@@ -859,6 +973,119 @@ function describeFocus(element) {
859
973
  function waitForFrame() {
860
974
  return new Promise((resolve) => requestAnimationFrame(() => resolve()));
861
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
+ }
862
1089
  /**
863
1090
  * Builds one unmounted element of a known tag, wearing the classes, text, and attributes asked for.
864
1091
  *
@@ -1423,6 +1650,42 @@ function readClasses(root) {
1423
1650
  return authored;
1424
1651
  }
1425
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
+ /**
1426
1689
  * Collects every rule the stylesheets loaded into this document hold, nested grouping rules
1427
1690
  * included.
1428
1691
  *
@@ -2002,6 +2265,196 @@ function expandCaptures(states, variants) {
2002
2265
  for (const state of states) for (const variant of variants) files.push(`${state}--${variant.name}.png`);
2003
2266
  return files;
2004
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
+ }
2005
2458
  //#endregion
2006
2459
  //#region src/browser/factories.ts
2007
2460
  /**
@@ -2245,7 +2698,239 @@ function createJournal() {
2245
2698
  }
2246
2699
  };
2247
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
+ }
2248
2933
  //#endregion
2249
- export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CAPTURE_STAGINGS, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, build, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, commitInput, computeNamePattern, createChannel, createDragEvent, createJournal, createPointerEvent, createPortfolio, describeFocus, describeTree, expandCaptures, extractOrphans, extractStyles, fillAccessible, findKeyframes, findRule, isOutsideViewport, isReachable, isRendered, matchesColor, measureContent, measureContrast, measureLuminance, mount, parseCSSColor, parseColor, readBackdrop, readCascade, readClasses, readContrast, readFocus, readFrame, readLayers, readName, readPage, readPerception, readPixels, readRing, readRole, readRootToken, readRows, readRules, readStates, readStyle, readText, readToken, readValue, releasePane, removeDatabase, render, resolveAccessible, resolveRendered, stagePane, traverseAccessible, typeAccessible, typeInput, waitForFrame };
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 };
2250
2935
 
2251
2936
  //# sourceMappingURL=index.js.map