@zerolith/test 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +9 -3
- package/dist/index.d.ts +28 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +72 -23
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +93 -27
package/README.md
CHANGED
|
@@ -19,14 +19,20 @@ it("renders and hydrates", async () =>
|
|
|
19
19
|
});
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
- `mount` renders a view into a container attached to the document and remembers it; `cleanup` disposes everything mounted, in reverse order.
|
|
22
|
+
- `mount` renders a view into a container attached to the document and remembers it; given a function, as in `mount(() => Dashboard())`, it calls it inside an owner of its own, so a component that registers cleanups with `onDispose` mounts as it is. `cleanup` disposes everything mounted, in reverse order.
|
|
23
23
|
- `settle` waits for the promises a test started, such as resources and lazy routes; effects themselves run synchronously.
|
|
24
24
|
- `hydrationMismatches` renders a view with the server renderer, hydrates the same view over that markup, and returns every disagreement, so a component that cannot hydrate fails its test.
|
|
25
25
|
- `accessibilityIssues` finds the defects a test can catch without a browser engine's accessibility tree: images without alternative text, controls without a label, buttons and links without a name, frames without a title.
|
|
26
26
|
|
|
27
|
-
The guide is
|
|
27
|
+
Under Vitest, which reads the components itself, add the plugin from `@zerolith/vite` as `zerolith({ hot: false })` so a test reads JSX and stylesheets imported with `?scoped` as the app does; a project from `zerolith new` has it in `vitest.config.ts`. The guide is on the [docs site](https://zerolith.dev/guide/testing/).
|
|
28
28
|
|
|
29
|
-
Status:
|
|
29
|
+
Status: alpha. Install with `npm install --save-dev @zerolith/test@alpha`; the API may change before 1.0.
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
- The [testing guide](https://zerolith.dev/guide/testing/) explains it with examples that run as tests.
|
|
34
|
+
- The [reference](https://zerolith.dev/reference/zerolith-test/) lists every export, generated from the source.
|
|
35
|
+
- New to zerolith? The [tutorial](https://zerolith.dev/learn/) builds an app step by step, and the [playground](https://zerolith.dev/playground/) runs code in the browser.
|
|
30
36
|
|
|
31
37
|
## License
|
|
32
38
|
|
package/dist/index.d.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* a view's server markup against hydration, and find the accessibility
|
|
5
5
|
* defects a test can catch.
|
|
6
6
|
*/
|
|
7
|
-
import { type HydrationMismatch, type Root } from "zerolith";
|
|
7
|
+
import { type HydrationMismatch, type Renderable, type Root } from "zerolith";
|
|
8
8
|
/** What `mount` returns. */
|
|
9
9
|
export interface Mounted {
|
|
10
10
|
/** The element the view was rendered into. */
|
|
11
11
|
readonly container: HTMLElement;
|
|
12
|
+
/** The root `render` returned for the view; `unmount` disposes it. */
|
|
12
13
|
readonly root: Root;
|
|
13
14
|
/** Disposes the view and removes the container; `cleanup` does the same for every mount. */
|
|
14
15
|
unmount(): void;
|
|
@@ -17,30 +18,45 @@ export interface Mounted {
|
|
|
17
18
|
export interface MountOptions {
|
|
18
19
|
/** Renders into this element instead of a new `div`. */
|
|
19
20
|
container?: HTMLElement;
|
|
20
|
-
/** Appends a new container to `document.body`. Defaults to true; layout and focus need it. */
|
|
21
|
+
/** Appends a new container to `document.body`. Defaults to true; layout and focus need it. Ignored with `container`. */
|
|
21
22
|
attach?: boolean;
|
|
22
23
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Renders a view into a container and remembers it for `cleanup`. Given a
|
|
26
|
+
* function that returns the view, as `mount(() => Page())`, it hands the
|
|
27
|
+
* function to `render`, which calls it inside the root's owner, so a
|
|
28
|
+
* component that registers cleanups with `onDispose` mounts as it is, and
|
|
29
|
+
* its cleanups run when the view is unmounted. A component that takes
|
|
30
|
+
* props, passed uncalled, fails with `mount_component_uncalled`, as
|
|
31
|
+
* `render` does. A container `mount` created is removed even when
|
|
32
|
+
* rendering throws.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mount(view: Renderable | (() => Renderable), options?: MountOptions): Mounted;
|
|
35
|
+
/** Unmounts everything `mount` created, newest first, each even when another's cleanup throws, and then throws the first error. Register it as the runner's after-each hook. */
|
|
26
36
|
export declare function cleanup(): void;
|
|
27
37
|
/**
|
|
28
|
-
* Waits
|
|
29
|
-
*
|
|
30
|
-
* synchronously when a signal changes, so they need no
|
|
38
|
+
* Waits `rounds` turns of the timer queue, two by default: enough for
|
|
39
|
+
* promise chains, a resource whose loader resolves at once, and zero-delay
|
|
40
|
+
* timers. Effects run synchronously when a signal changes, so they need no
|
|
41
|
+
* settling. For a module import or a request, wait on its result with the
|
|
42
|
+
* runner's `waitFor` instead.
|
|
31
43
|
*/
|
|
32
44
|
export declare function settle(rounds?: number): Promise<void>;
|
|
33
45
|
/**
|
|
34
|
-
* Renders `view
|
|
35
|
-
*
|
|
36
|
-
*
|
|
46
|
+
* Renders `view` with the server renderer, hydrates it over that markup,
|
|
47
|
+
* and returns every disagreement. Each pass calls `view` inside its own
|
|
48
|
+
* owner, so the effects and `onDispose` cleanups a component registers end
|
|
49
|
+
* before the helper returns. An empty array means the component hydrates
|
|
50
|
+
* cleanly; the container is discarded afterwards.
|
|
37
51
|
*/
|
|
38
|
-
export declare function hydrationMismatches(view: () =>
|
|
52
|
+
export declare function hydrationMismatches(view: () => Renderable): HydrationMismatch[];
|
|
39
53
|
/** The accessibility checks `accessibilityIssues` runs. */
|
|
40
54
|
export type AccessibilityCode = "image_without_alt" | "control_without_label" | "button_without_name" | "link_without_name" | "frame_without_title";
|
|
41
55
|
/** One defect found by `accessibilityIssues`. */
|
|
42
56
|
export interface AccessibilityIssue {
|
|
57
|
+
/** Which check the element failed, such as `image_without_alt`. */
|
|
43
58
|
readonly code: AccessibilityCode;
|
|
59
|
+
/** The element that failed the check, found inside the container passed to `accessibilityIssues`. */
|
|
44
60
|
readonly element: Element;
|
|
45
61
|
}
|
|
46
62
|
/**
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,KAAK,iBAAiB,EAAE,KAAK,IAAI,EAAmB,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,KAAK,iBAAiB,EAAE,KAAK,UAAU,EAAE,KAAK,IAAI,EAAmB,MAAM,UAAU,CAAC;AAG/F,4BAA4B;AAC5B,MAAM,WAAW,OAAO;IAEpB,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC;IAChC,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,4FAA4F;IAC5F,OAAO,IAAI,IAAI,CAAC;CACnB;AAED,2BAA2B;AAC3B,MAAM,WAAW,YAAY;IAEzB,wDAAwD;IACxD,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,wHAAwH;IACxH,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB;AAID;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,UAAU,GAAG,CAAC,MAAM,UAAU,CAAC,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CA6ChG;AAED,gLAAgL;AAChL,wBAAgB,OAAO,IAAI,IAAI,CAkB9B;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,MAAM,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,UAAU,GAAG,iBAAiB,EAAE,CAQ/E;AAED,2DAA2D;AAC3D,MAAM,MAAM,iBAAiB,GAAG,mBAAmB,GAAG,uBAAuB,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,qBAAqB,CAAC;AAEpJ,iDAAiD;AACjD,MAAM,WAAW,kBAAkB;IAE/B,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,qGAAqG;IACrG,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC7B;AA8CD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU,GAAG,kBAAkB,EAAE,CAqC/E"}
|
package/dist/index.js
CHANGED
|
@@ -7,13 +7,31 @@
|
|
|
7
7
|
import { hydrate, render } from "zerolith";
|
|
8
8
|
import { renderToString } from "zerolith/ssr";
|
|
9
9
|
const mounted = [];
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Renders a view into a container and remembers it for `cleanup`. Given a
|
|
12
|
+
* function that returns the view, as `mount(() => Page())`, it hands the
|
|
13
|
+
* function to `render`, which calls it inside the root's owner, so a
|
|
14
|
+
* component that registers cleanups with `onDispose` mounts as it is, and
|
|
15
|
+
* its cleanups run when the view is unmounted. A component that takes
|
|
16
|
+
* props, passed uncalled, fails with `mount_component_uncalled`, as
|
|
17
|
+
* `render` does. A container `mount` created is removed even when
|
|
18
|
+
* rendering throws.
|
|
19
|
+
*/
|
|
11
20
|
export function mount(view, options = {}) {
|
|
12
21
|
const container = options.container ?? document.createElement("div");
|
|
13
22
|
if (options.container === undefined && options.attach !== false) {
|
|
14
23
|
document.body.append(container);
|
|
15
24
|
}
|
|
16
|
-
|
|
25
|
+
let root;
|
|
26
|
+
try {
|
|
27
|
+
root = render(view, container);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (options.container === undefined) {
|
|
31
|
+
container.remove();
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
17
35
|
const entry = {
|
|
18
36
|
container,
|
|
19
37
|
root,
|
|
@@ -22,25 +40,40 @@ export function mount(view, options = {}) {
|
|
|
22
40
|
if (index !== -1) {
|
|
23
41
|
mounted.splice(index, 1);
|
|
24
42
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
try {
|
|
44
|
+
root.dispose();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (options.container === undefined) {
|
|
48
|
+
container.remove();
|
|
49
|
+
}
|
|
28
50
|
}
|
|
29
51
|
}
|
|
30
52
|
};
|
|
31
53
|
mounted.push(entry);
|
|
32
54
|
return entry;
|
|
33
55
|
}
|
|
34
|
-
/** Unmounts everything `mount` created, newest first. Register it as the runner's after-each hook. */
|
|
56
|
+
/** Unmounts everything `mount` created, newest first, each even when another's cleanup throws, and then throws the first error. Register it as the runner's after-each hook. */
|
|
35
57
|
export function cleanup() {
|
|
58
|
+
const errors = [];
|
|
36
59
|
for (const entry of mounted.toReversed()) {
|
|
37
|
-
|
|
60
|
+
try {
|
|
61
|
+
entry.unmount();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
errors.push(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (errors.length > 0) {
|
|
68
|
+
throw errors[0];
|
|
38
69
|
}
|
|
39
70
|
}
|
|
40
71
|
/**
|
|
41
|
-
* Waits
|
|
42
|
-
*
|
|
43
|
-
* synchronously when a signal changes, so they need no
|
|
72
|
+
* Waits `rounds` turns of the timer queue, two by default: enough for
|
|
73
|
+
* promise chains, a resource whose loader resolves at once, and zero-delay
|
|
74
|
+
* timers. Effects run synchronously when a signal changes, so they need no
|
|
75
|
+
* settling. For a module import or a request, wait on its result with the
|
|
76
|
+
* runner's `waitFor` instead.
|
|
44
77
|
*/
|
|
45
78
|
export function settle(rounds = 2) {
|
|
46
79
|
let chain = Promise.resolve();
|
|
@@ -50,34 +83,50 @@ export function settle(rounds = 2) {
|
|
|
50
83
|
return chain;
|
|
51
84
|
}
|
|
52
85
|
/**
|
|
53
|
-
* Renders `view
|
|
54
|
-
*
|
|
55
|
-
*
|
|
86
|
+
* Renders `view` with the server renderer, hydrates it over that markup,
|
|
87
|
+
* and returns every disagreement. Each pass calls `view` inside its own
|
|
88
|
+
* owner, so the effects and `onDispose` cleanups a component registers end
|
|
89
|
+
* before the helper returns. An empty array means the component hydrates
|
|
90
|
+
* cleanly; the container is discarded afterwards.
|
|
56
91
|
*/
|
|
57
92
|
export function hydrationMismatches(view) {
|
|
58
93
|
const container = document.createElement("div");
|
|
59
|
-
container.innerHTML = renderToString(view
|
|
60
|
-
const result = hydrate(view
|
|
94
|
+
container.innerHTML = renderToString(view);
|
|
95
|
+
const result = hydrate(view, container);
|
|
61
96
|
const mismatches = [...result.mismatches];
|
|
62
97
|
result.dispose();
|
|
63
98
|
return mismatches;
|
|
64
99
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
/** True when an element has an accessible name from an attribute: `aria-label`, `aria-labelledby`, or `title`. */
|
|
101
|
+
function namedByAttribute(element) {
|
|
102
|
+
return (element.getAttribute("aria-label") ?? "").trim() !== "" || element.hasAttribute("aria-labelledby") || (element.getAttribute("title") ?? "").trim() !== "";
|
|
103
|
+
}
|
|
104
|
+
/** The text an element shows to assistive technology: its text content, leaving out every `aria-hidden="true"` subtree. */
|
|
105
|
+
function exposedText(element) {
|
|
106
|
+
let text = "";
|
|
107
|
+
for (const node of element.childNodes) {
|
|
108
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
109
|
+
text += node.textContent ?? "";
|
|
110
|
+
}
|
|
111
|
+
else if (node instanceof Element && node.getAttribute("aria-hidden") !== "true") {
|
|
112
|
+
text += exposedText(node);
|
|
113
|
+
}
|
|
69
114
|
}
|
|
70
|
-
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
function hasName(element) {
|
|
118
|
+
if (namedByAttribute(element) || exposedText(element).trim() !== "") {
|
|
71
119
|
return true;
|
|
72
120
|
}
|
|
73
|
-
return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "");
|
|
121
|
+
return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "" && image.closest("[aria-hidden=\"true\"]") === null);
|
|
74
122
|
}
|
|
75
123
|
function isLabeled(control) {
|
|
76
124
|
if (hasName(control) || control.closest("label") !== null) {
|
|
77
125
|
return true;
|
|
78
126
|
}
|
|
79
127
|
const id = control.getAttribute("id");
|
|
80
|
-
|
|
128
|
+
const tree = control.getRootNode();
|
|
129
|
+
return id !== null && id !== "" && [...tree.querySelectorAll("label[for]")].some((label) => label.htmlFor === id);
|
|
81
130
|
}
|
|
82
131
|
/**
|
|
83
132
|
* Finds the defects that need no accessibility tree to detect, the ones the
|
|
@@ -107,7 +156,7 @@ export function accessibilityIssues(container) {
|
|
|
107
156
|
}
|
|
108
157
|
}
|
|
109
158
|
for (const frame of container.querySelectorAll("iframe")) {
|
|
110
|
-
if ((frame
|
|
159
|
+
if (!namedByAttribute(frame)) {
|
|
111
160
|
issues.push({ code: "frame_without_title", element: frame });
|
|
112
161
|
}
|
|
113
162
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerolith/test",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.10",
|
|
4
4
|
"description": "Test helpers for zerolith: mount views with automatic cleanup, settle async work, check a view's server markup against hydration, and find the accessibility defects a test can catch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "molexxxx",
|
|
7
|
-
"homepage": "https://
|
|
7
|
+
"homepage": "https://zerolith.dev/",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/molexxxx/zerolith.git",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"build": "tsc -p tsconfig.build.json"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"zerolith": "^0.1.0-alpha.
|
|
44
|
+
"zerolith": "^0.1.0-alpha.10"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"zerolith": "^0.1.0-alpha.
|
|
47
|
+
"zerolith": "^0.1.0-alpha.10"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* a view's server markup against hydration, and find the accessibility
|
|
5
5
|
* defects a test can catch.
|
|
6
6
|
*/
|
|
7
|
-
import { type HydrationMismatch, type Root, hydrate, render } from "zerolith";
|
|
7
|
+
import { type HydrationMismatch, type Renderable, type Root, hydrate, render } from "zerolith";
|
|
8
8
|
import { renderToString } from "zerolith/ssr";
|
|
9
9
|
|
|
10
10
|
/** What `mount` returns. */
|
|
@@ -12,6 +12,7 @@ export interface Mounted
|
|
|
12
12
|
{
|
|
13
13
|
/** The element the view was rendered into. */
|
|
14
14
|
readonly container: HTMLElement;
|
|
15
|
+
/** The root `render` returned for the view; `unmount` disposes it. */
|
|
15
16
|
readonly root: Root;
|
|
16
17
|
/** Disposes the view and removes the container; `cleanup` does the same for every mount. */
|
|
17
18
|
unmount(): void;
|
|
@@ -22,21 +23,42 @@ export interface MountOptions
|
|
|
22
23
|
{
|
|
23
24
|
/** Renders into this element instead of a new `div`. */
|
|
24
25
|
container?: HTMLElement;
|
|
25
|
-
/** Appends a new container to `document.body`. Defaults to true; layout and focus need it. */
|
|
26
|
+
/** Appends a new container to `document.body`. Defaults to true; layout and focus need it. Ignored with `container`. */
|
|
26
27
|
attach?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const mounted: Mounted[] = [];
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Renders a view into a container and remembers it for `cleanup`. Given a
|
|
34
|
+
* function that returns the view, as `mount(() => Page())`, it hands the
|
|
35
|
+
* function to `render`, which calls it inside the root's owner, so a
|
|
36
|
+
* component that registers cleanups with `onDispose` mounts as it is, and
|
|
37
|
+
* its cleanups run when the view is unmounted. A component that takes
|
|
38
|
+
* props, passed uncalled, fails with `mount_component_uncalled`, as
|
|
39
|
+
* `render` does. A container `mount` created is removed even when
|
|
40
|
+
* rendering throws.
|
|
41
|
+
*/
|
|
42
|
+
export function mount(view: Renderable | (() => Renderable), options: MountOptions = {}): Mounted
|
|
33
43
|
{
|
|
34
44
|
const container = options.container ?? document.createElement("div");
|
|
35
45
|
if (options.container === undefined && options.attach !== false)
|
|
36
46
|
{
|
|
37
47
|
document.body.append(container);
|
|
38
48
|
}
|
|
39
|
-
|
|
49
|
+
let root: Root;
|
|
50
|
+
try
|
|
51
|
+
{
|
|
52
|
+
root = render(view, container);
|
|
53
|
+
}
|
|
54
|
+
catch (error)
|
|
55
|
+
{
|
|
56
|
+
if (options.container === undefined)
|
|
57
|
+
{
|
|
58
|
+
container.remove();
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
40
62
|
const entry: Mounted = {
|
|
41
63
|
container,
|
|
42
64
|
root,
|
|
@@ -47,10 +69,16 @@ export function mount(view: unknown, options: MountOptions = {}): Mounted
|
|
|
47
69
|
{
|
|
48
70
|
mounted.splice(index, 1);
|
|
49
71
|
}
|
|
50
|
-
|
|
51
|
-
if (options.container === undefined)
|
|
72
|
+
try
|
|
52
73
|
{
|
|
53
|
-
|
|
74
|
+
root.dispose();
|
|
75
|
+
}
|
|
76
|
+
finally
|
|
77
|
+
{
|
|
78
|
+
if (options.container === undefined)
|
|
79
|
+
{
|
|
80
|
+
container.remove();
|
|
81
|
+
}
|
|
54
82
|
}
|
|
55
83
|
}
|
|
56
84
|
};
|
|
@@ -58,19 +86,33 @@ export function mount(view: unknown, options: MountOptions = {}): Mounted
|
|
|
58
86
|
return entry;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
/** Unmounts everything `mount` created, newest first. Register it as the runner's after-each hook. */
|
|
89
|
+
/** Unmounts everything `mount` created, newest first, each even when another's cleanup throws, and then throws the first error. Register it as the runner's after-each hook. */
|
|
62
90
|
export function cleanup(): void
|
|
63
91
|
{
|
|
92
|
+
const errors: unknown[] = [];
|
|
64
93
|
for (const entry of mounted.toReversed())
|
|
65
94
|
{
|
|
66
|
-
|
|
95
|
+
try
|
|
96
|
+
{
|
|
97
|
+
entry.unmount();
|
|
98
|
+
}
|
|
99
|
+
catch (error)
|
|
100
|
+
{
|
|
101
|
+
errors.push(error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (errors.length > 0)
|
|
105
|
+
{
|
|
106
|
+
throw errors[0];
|
|
67
107
|
}
|
|
68
108
|
}
|
|
69
109
|
|
|
70
110
|
/**
|
|
71
|
-
* Waits
|
|
72
|
-
*
|
|
73
|
-
* synchronously when a signal changes, so they need no
|
|
111
|
+
* Waits `rounds` turns of the timer queue, two by default: enough for
|
|
112
|
+
* promise chains, a resource whose loader resolves at once, and zero-delay
|
|
113
|
+
* timers. Effects run synchronously when a signal changes, so they need no
|
|
114
|
+
* settling. For a module import or a request, wait on its result with the
|
|
115
|
+
* runner's `waitFor` instead.
|
|
74
116
|
*/
|
|
75
117
|
export function settle(rounds = 2): Promise<void>
|
|
76
118
|
{
|
|
@@ -83,15 +125,17 @@ export function settle(rounds = 2): Promise<void>
|
|
|
83
125
|
}
|
|
84
126
|
|
|
85
127
|
/**
|
|
86
|
-
* Renders `view
|
|
87
|
-
*
|
|
88
|
-
*
|
|
128
|
+
* Renders `view` with the server renderer, hydrates it over that markup,
|
|
129
|
+
* and returns every disagreement. Each pass calls `view` inside its own
|
|
130
|
+
* owner, so the effects and `onDispose` cleanups a component registers end
|
|
131
|
+
* before the helper returns. An empty array means the component hydrates
|
|
132
|
+
* cleanly; the container is discarded afterwards.
|
|
89
133
|
*/
|
|
90
|
-
export function hydrationMismatches(view: () =>
|
|
134
|
+
export function hydrationMismatches(view: () => Renderable): HydrationMismatch[]
|
|
91
135
|
{
|
|
92
136
|
const container = document.createElement("div");
|
|
93
|
-
container.innerHTML = renderToString(view
|
|
94
|
-
const result = hydrate(view
|
|
137
|
+
container.innerHTML = renderToString(view);
|
|
138
|
+
const result = hydrate(view, container);
|
|
95
139
|
const mismatches = [...result.mismatches];
|
|
96
140
|
result.dispose();
|
|
97
141
|
return mismatches;
|
|
@@ -103,22 +147,43 @@ export type AccessibilityCode = "image_without_alt" | "control_without_label" |
|
|
|
103
147
|
/** One defect found by `accessibilityIssues`. */
|
|
104
148
|
export interface AccessibilityIssue
|
|
105
149
|
{
|
|
150
|
+
/** Which check the element failed, such as `image_without_alt`. */
|
|
106
151
|
readonly code: AccessibilityCode;
|
|
152
|
+
/** The element that failed the check, found inside the container passed to `accessibilityIssues`. */
|
|
107
153
|
readonly element: Element;
|
|
108
154
|
}
|
|
109
155
|
|
|
110
|
-
|
|
156
|
+
/** True when an element has an accessible name from an attribute: `aria-label`, `aria-labelledby`, or `title`. */
|
|
157
|
+
function namedByAttribute(element: Element): boolean
|
|
111
158
|
{
|
|
112
|
-
|
|
113
|
-
|
|
159
|
+
return (element.getAttribute("aria-label") ?? "").trim() !== "" || element.hasAttribute("aria-labelledby") || (element.getAttribute("title") ?? "").trim() !== "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The text an element shows to assistive technology: its text content, leaving out every `aria-hidden="true"` subtree. */
|
|
163
|
+
function exposedText(element: Element): string
|
|
164
|
+
{
|
|
165
|
+
let text = "";
|
|
166
|
+
for (const node of element.childNodes)
|
|
114
167
|
{
|
|
115
|
-
|
|
168
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
169
|
+
{
|
|
170
|
+
text += node.textContent ?? "";
|
|
171
|
+
}
|
|
172
|
+
else if (node instanceof Element && node.getAttribute("aria-hidden") !== "true")
|
|
173
|
+
{
|
|
174
|
+
text += exposedText(node);
|
|
175
|
+
}
|
|
116
176
|
}
|
|
117
|
-
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hasName(element: Element): boolean
|
|
181
|
+
{
|
|
182
|
+
if (namedByAttribute(element) || exposedText(element).trim() !== "")
|
|
118
183
|
{
|
|
119
184
|
return true;
|
|
120
185
|
}
|
|
121
|
-
return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "");
|
|
186
|
+
return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "" && image.closest("[aria-hidden=\"true\"]") === null);
|
|
122
187
|
}
|
|
123
188
|
|
|
124
189
|
function isLabeled(control: Element): boolean
|
|
@@ -128,7 +193,8 @@ function isLabeled(control: Element): boolean
|
|
|
128
193
|
return true;
|
|
129
194
|
}
|
|
130
195
|
const id = control.getAttribute("id");
|
|
131
|
-
|
|
196
|
+
const tree = control.getRootNode() as ParentNode;
|
|
197
|
+
return id !== null && id !== "" && [...tree.querySelectorAll("label[for]")].some((label) => (label as HTMLLabelElement).htmlFor === id);
|
|
132
198
|
}
|
|
133
199
|
|
|
134
200
|
/**
|
|
@@ -168,7 +234,7 @@ export function accessibilityIssues(container: ParentNode): AccessibilityIssue[]
|
|
|
168
234
|
}
|
|
169
235
|
for (const frame of container.querySelectorAll("iframe"))
|
|
170
236
|
{
|
|
171
|
-
if ((frame
|
|
237
|
+
if (!namedByAttribute(frame))
|
|
172
238
|
{
|
|
173
239
|
issues.push({ code: "frame_without_title", element: frame });
|
|
174
240
|
}
|