@uniflowed/test 0.0.0-alpha.4 → 0.0.0-alpha.40

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/in-source.js ADDED
@@ -0,0 +1,169 @@
1
+ // @flow
2
+ //
3
+ // In-source tests: `if (import.meta.uf.test) { … }` in the file being tested.
4
+ //
5
+ // A three-line test for a three-line pure function does not want a file of its
6
+ // own, an import of the thing it tests, and a path that has to be kept in step
7
+ // with it. `import.meta.uf.test` is the block that holds it, and it is the
8
+ // same idea as Vitest's `import.meta.vitest` for the same reason.
9
+ //
10
+ // # What a block looks like
11
+ //
12
+ // import { expect, it } from "@uniflowed/test";
13
+ //
14
+ // export function add(a: number, b: number): number {
15
+ // return a + b;
16
+ // }
17
+ //
18
+ // if (import.meta.uf.test) {
19
+ // it("adds", () => {
20
+ // expect(add(1, 2)).toBe(3);
21
+ // });
22
+ // }
23
+ //
24
+ // An ordinary import for the bindings, because that is what makes them typed:
25
+ // Flow has no `typeof import("…")` for a library definition to use, so a
26
+ // marker that carried the API could not be described to the checker. The
27
+ // marker's *value* is the API anyway — `const { it } = import.meta.uf.test` is
28
+ // what somebody arriving from Vitest writes and it works — but it is untyped,
29
+ // and the import is the form uf recommends.
30
+ //
31
+ // The import costs nothing in a build. The branch folds to `if (void 0)` and
32
+ // goes, `@uniflowed/test` declares `sideEffects: false` so the now-unused
33
+ // import goes with it, and the package carries a `browser` field mapping the
34
+ // six `node:` builtins its edges import at `./internal/browser/node.js` — so
35
+ // the bundler does not even print a warning about modules it is in the middle
36
+ // of removing.
37
+ //
38
+ // # What makes the block disappear
39
+ //
40
+ // `uf` compiles the marker away rather than leaving it to be falsy at runtime:
41
+ // `crates/uf_transform`'s printer substitutes `void 0` for
42
+ // `import.meta.uf.test` in every transform except the ones `uf test` asks for,
43
+ // so `uf build` sees `if (void 0)` and the bundler removes the block. There is
44
+ // no `import.meta.uf` at runtime, in any host, which is also why the block
45
+ // cannot throw in a browser that never heard of uf.
46
+ //
47
+ // `tests/library/in-source.test.js` and `tests/library/in-source-subject.js`
48
+ // are the block running. The other half is in `crates/uf_cli/tests/vite.rs`,
49
+ // which builds a fixture whose block holds a string that appears nowhere else
50
+ // and reads the emitted client bundle back looking for it — in the same file
51
+ // the module's other marker has to be present in, because "a string is
52
+ // missing" is only evidence when something establishes the module is not.
53
+ //
54
+ // # Why the marker is a call, and what it is called with
55
+ //
56
+ // Under `uf test` the printer substitutes
57
+ // `globalThis.__ufInSourceTests?.(import.meta.url)`, and this module is what
58
+ // answers it. The argument is the module asking, because a source file with an
59
+ // in-source block is an ordinary module and is imported more than once in a
60
+ // normal run: `uf test` imports `add.js` as the file it is running, and
61
+ // `add.test.js` imports it again for the function. Both evaluations reach the
62
+ // marker. Only the first is the file the run is reporting on, so only the
63
+ // first is given the API — otherwise every in-source case would register twice
64
+ // and the second copy would be filed under whichever test file imported it.
65
+
66
+ import {
67
+ afterAll,
68
+ afterEach,
69
+ beforeAll,
70
+ beforeEach,
71
+ describe,
72
+ it,
73
+ test,
74
+ } from "./internal/registry.js";
75
+ import { fn, spyOn } from "./internal/spy.js";
76
+
77
+ import type { Expect } from "./internal/expect.js";
78
+ import type { Uft } from "./internal/namespace.js";
79
+ import { expect } from "./internal/expect.js";
80
+ import { uft } from "./internal/namespace.js";
81
+
82
+ /**
83
+ * The name `uf` compiles `import.meta.uf.test` into a call on.
84
+ *
85
+ * Exported so the worker and the tests name it once; it is spelled out in `crates/uf_transform/src/print.rs` as well, and
86
+ * `tests/library/in-source.test.js` is what keeps the two spellings equal.
87
+ */
88
+ export const IN_SOURCE_GLOBAL: "__ufInSourceTests" = "__ufInSourceTests";
89
+
90
+ /**
91
+ * What an in-source block is handed.
92
+ *
93
+ * The subset of `@uniflowed/test` a test body needs, and deliberately not all
94
+ * of it: a block that wants module mocking or a snapshot is a block that has
95
+ * outgrown living inside the file it tests, and `import { uft } from
96
+ * "@uniflowed/test"` in a file of its own is the answer to that. `uft` is here
97
+ * anyway because spies and the fake clock are ordinary for a unit test.
98
+ */
99
+ export type InSourceTests = {
100
+ readonly describe: typeof describe,
101
+ readonly it: typeof it,
102
+ readonly test: typeof test,
103
+ readonly expect: Expect,
104
+ readonly beforeAll: typeof beforeAll,
105
+ readonly beforeEach: typeof beforeEach,
106
+ readonly afterAll: typeof afterAll,
107
+ readonly afterEach: typeof afterEach,
108
+ readonly fn: typeof fn,
109
+ readonly spyOn: typeof spyOn,
110
+ readonly uft: Uft,
111
+ };
112
+
113
+ /**
114
+ * The API every in-source block in a run shares.
115
+ *
116
+ * One frozen object rather than one per file: the bindings are the module's
117
+ * own singletons — `it` registers into the one registry the worker resets
118
+ * between files — so a copy per file would only be a copy of the same
119
+ * references.
120
+ */
121
+ const API: InSourceTests = Object.freeze({
122
+ describe,
123
+ it,
124
+ test,
125
+ expect,
126
+ beforeAll,
127
+ beforeEach,
128
+ afterAll,
129
+ afterEach,
130
+ fn,
131
+ spyOn,
132
+ uft,
133
+ });
134
+
135
+ /**
136
+ * The module URL, without whatever a host appended to it.
137
+ *
138
+ * The worker busts its import cache with `?uf-run=<generation>` so a watch
139
+ * rerun sees the edited file, which means the file being run reports an
140
+ * `import.meta.url` that its own path does not match. Comparing the part
141
+ * before the query is comparing the module.
142
+ *
143
+ * A hash is stripped for the same reason and never appears in practice; it
144
+ * costs one `indexOf` to not have to think about it again.
145
+ */
146
+ function moduleUrl(url: string): string {
147
+ const end = url.search(/[?#]/);
148
+ return end === -1 ? url : url.slice(0, end);
149
+ }
150
+
151
+ /**
152
+ * Hand in-source blocks their API for the file `url`, and nothing to any other
153
+ * module.
154
+ *
155
+ * Returns the function that puts the global back as it was, which the worker
156
+ * calls when the file is over. Restoring rather than leaving it set is the
157
+ * same rule the worker applies to environment stubs and module mocks: a file
158
+ * may not change what the next file on this worker sees.
159
+ */
160
+ export function installInSourceTests(url: string): () => void {
161
+ const wanted = moduleUrl(url);
162
+ const globals: { [string]: mixed } = globalThis as $FlowFixMe;
163
+ const previous = globals[IN_SOURCE_GLOBAL];
164
+ globals[IN_SOURCE_GLOBAL] = (asking: string): InSourceTests | void =>
165
+ moduleUrl(asking) === wanted ? API : undefined;
166
+ return () => {
167
+ globals[IN_SOURCE_GLOBAL] = previous;
168
+ };
169
+ }
package/index.js CHANGED
@@ -12,7 +12,18 @@
12
12
  //
13
13
  // The whole surface is importable from here, so a test file has one import.
14
14
 
15
- export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
15
+ export type { Expect, Expectation, Matchers } from "./internal/expect.js";
16
+ export type { InSourceTests } from "./in-source.js";
17
+ export type {
18
+ BenchOptions,
19
+ Body as TestBody,
20
+ Case,
21
+ Modifier,
22
+ Suite,
23
+ TestOptions,
24
+ } from "./internal/registry.js";
25
+ export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
26
+ export type { Uft } from "./internal/namespace.js";
16
27
  export type { Outcome, Result, RunOptions } from "./internal/run.js";
17
28
  export type { Site } from "./internal/frames.js";
18
29
  export type { SpyCall, SpyResult } from "./internal/spy.js";
@@ -23,6 +34,7 @@ export {
23
34
  afterEach,
24
35
  beforeAll,
25
36
  beforeEach,
37
+ bench,
26
38
  describe,
27
39
  it,
28
40
  test,
@@ -0,0 +1,362 @@
1
+ // @flow
2
+ //
3
+ // The accessibility audit behind `expect(container).toHaveNoAxeViolations()`.
4
+ //
5
+ // `@uniflowed/react-testing` renders into a real DOM, which is the expensive
6
+ // half of an accessibility assertion — and nothing was reading it. axe-core is
7
+ // the engine every other testing stack uses for that, it is the engine the
8
+ // browsers' own developer tools use, and reimplementing its four hundred-odd
9
+ // checks would be a small lookalike of exactly the kind uf's guide says not to
10
+ // ship. So uf runs axe-core rather than competing with it. See
11
+ // ubugeeei-prod/uf#511.
12
+ //
13
+ // # Why the import is dynamic, and why the specifier is a constant
14
+ //
15
+ // Dynamic, because `@uniflowed/test` is imported by every test file in every
16
+ // project and most of them will never audit anything: a static import would
17
+ // put a megabyte of rule definitions into the start-up of every worker to
18
+ // serve the files that ask for it. It is declared as an *optional* peer
19
+ // dependency for the same reason — a project that never writes the matcher
20
+ // should not be made to install the engine — and a project that writes it
21
+ // without installing it is told so in one sentence rather than by
22
+ // `ERR_MODULE_NOT_FOUND` from inside a matcher.
23
+ //
24
+ // A constant specifier, because the alternative is a module path read from
25
+ // configuration and handed to `import()`. `docs/security.md` is about exactly
26
+ // that shape: `uf.config.js` is a file a cloned repository brings with it, and
27
+ // "which module does the test runner load" is not a question it should get to
28
+ // answer. What a project *may* configure is which rules run, which is data.
29
+ //
30
+ // # What is configured once rather than per test
31
+ //
32
+ // The rule set. A project that has decided `color-contrast` cannot be judged
33
+ // by a DOM with no layout has decided it for the whole suite, and repeating
34
+ // that decision in every assertion is how two tests come to disagree about
35
+ // what "accessible" means. `uf.config.js`'s `accessibility.axe` block is the
36
+ // answer, delivered to workers in the environment (`UF_AXE`) the same way
37
+ // `UF_UPDATE_SNAPSHOTS` delivers the other run-wide decision. A call may still
38
+ // pass overrides, and they are merged over the project's — narrowing one
39
+ // assertion is legitimate, and it is visible on the line that does it.
40
+
41
+ /** How serious a violation is, weakest first. */
42
+ export type AxeImpact = "minor" | "moderate" | "serious" | "critical";
43
+
44
+ /** What a test or a project may say about a run. */
45
+ export type AxeOptions = {
46
+ /** Run only rules carrying one of these axe tags; all rules when empty. */
47
+ readonly tags?: $ReadOnlyArray<string>,
48
+ /** Rule ids to turn off, by id. */
49
+ readonly disabledRules?: $ReadOnlyArray<string>,
50
+ /** Weakest impact that counts as a failure. */
51
+ readonly minImpact?: AxeImpact,
52
+ };
53
+
54
+ /** One thing axe found, reduced to what a failure message needs. */
55
+ export type AxeViolation = {
56
+ readonly id: string,
57
+ readonly impact: AxeImpact | null,
58
+ readonly help: string,
59
+ readonly helpUrl: string,
60
+ readonly nodes: $ReadOnlyArray<string>,
61
+ };
62
+
63
+ /** Impacts in order, so a floor can be compared rather than matched. */
64
+ const IMPACTS: $ReadOnlyArray<AxeImpact> = ["minor", "moderate", "serious", "critical"];
65
+
66
+ /** Most violations one message names; the rest are counted. */
67
+ const MAX_VIOLATIONS_SHOWN = 10;
68
+
69
+ /** Most offending elements one violation names. */
70
+ const MAX_NODES_SHOWN = 3;
71
+
72
+ /** Longest excerpt of one element's markup a message quotes. */
73
+ const MAX_NODE_CHARS = 120;
74
+
75
+ /**
76
+ * The engine, loaded at most once per process.
77
+ *
78
+ * A promise rather than a module, so two assertions in flight at the same time
79
+ * share one load rather than racing to start two.
80
+ */
81
+ let engine: Promise<$FlowFixMe> | null = null;
82
+
83
+ /**
84
+ * axe-core, or a refusal that says what to install.
85
+ *
86
+ * The `$FlowFixMe` is the module's own shape: axe-core ships TypeScript
87
+ * declarations and no Flow ones, so what comes back is untyped whatever this
88
+ * file writes. It is confined to this one binding — everything the rest of the
89
+ * module reads out of a result goes through the readers below, which narrow
90
+ * rather than assert.
91
+ */
92
+ function axeEngine(): Promise<$FlowFixMe> {
93
+ if (engine == null) {
94
+ engine = import("axe-core").then(
95
+ (module) => {
96
+ const loaded: $FlowFixMe = (module as $FlowFixMe).default ?? module;
97
+ if (loaded == null || typeof loaded.run !== "function") {
98
+ throw new Error("`axe-core` is installed but has no `run`, so uf cannot audit with it");
99
+ }
100
+ return loaded;
101
+ },
102
+ () => {
103
+ throw new Error(
104
+ "`toHaveNoAxeViolations` needs axe-core, which this project does not have. " +
105
+ "Add it — `uf add -D axe-core` — and the matcher, and `uf dev`'s audit, " +
106
+ "start working; uf does not install it for you because a project that " +
107
+ "never audits should not carry the engine.",
108
+ );
109
+ },
110
+ );
111
+ }
112
+ return engine;
113
+ }
114
+
115
+ /** A string field, or `""`. */
116
+ function stringAt(value: mixed, key: string): string {
117
+ if (value == null || typeof value !== "object") return "";
118
+ const found = value[key];
119
+ return typeof found === "string" ? found : "";
120
+ }
121
+
122
+ /** An array field, or an empty one. */
123
+ function arrayAt(value: mixed, key: string): $ReadOnlyArray<mixed> {
124
+ if (value == null || typeof value !== "object") return [];
125
+ const found = value[key];
126
+ return Array.isArray(found) ? found : [];
127
+ }
128
+
129
+ /** The impact named at `key`, when it is one of the four. */
130
+ function impactAt(value: mixed, key: string): AxeImpact | null {
131
+ const impact = stringAt(value, key);
132
+ return IMPACTS.find((known) => known === impact) ?? null;
133
+ }
134
+
135
+ /** The impact axe gave a violation, when it gave one it knows. */
136
+ function impactOf(value: mixed): AxeImpact | null {
137
+ return impactAt(value, "impact");
138
+ }
139
+
140
+ /**
141
+ * The project's settings, read once from the environment.
142
+ *
143
+ * Absent, unreadable or the wrong shape all mean the same thing: no project
144
+ * settings, run every rule. A malformed value is not worth failing a suite
145
+ * over — the setting is a narrowing, and the widest answer is the safe one.
146
+ */
147
+ function projectOptions(): AxeOptions {
148
+ const raw =
149
+ typeof process === "undefined" ? undefined : ((process.env as $FlowFixMe)?.UF_AXE ?? undefined);
150
+ if (typeof raw !== "string" || raw === "") return {};
151
+ let parsed: mixed;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ } catch {
155
+ return {};
156
+ }
157
+ if (parsed == null || typeof parsed !== "object") return {};
158
+ const tags = arrayAt(parsed, "tags").filter((tag) => typeof tag === "string");
159
+ const disabled = arrayAt(parsed, "disabledRules").filter((rule) => typeof rule === "string");
160
+ const impact = impactAt(parsed, "minImpact") ?? undefined;
161
+ return {
162
+ tags: tags as $FlowFixMe,
163
+ disabledRules: disabled as $FlowFixMe,
164
+ minImpact: impact,
165
+ };
166
+ }
167
+
168
+ /** The project's settings, with a call's own merged over them. */
169
+ function resolveOptions(overrides: AxeOptions | void): AxeOptions {
170
+ const project = projectOptions();
171
+ return {
172
+ tags: overrides?.tags ?? project.tags,
173
+ disabledRules: overrides?.disabledRules ?? project.disabledRules,
174
+ minImpact: overrides?.minImpact ?? project.minImpact,
175
+ };
176
+ }
177
+
178
+ /** Those settings in axe's own vocabulary. */
179
+ function axeRunOptions(options: AxeOptions): { [string]: mixed } {
180
+ const run: { [string]: mixed } = {};
181
+ const tags = options.tags ?? [];
182
+ if (tags.length > 0) run.runOnly = { type: "tag", values: [...tags] };
183
+ const disabled = options.disabledRules ?? [];
184
+ if (disabled.length > 0) {
185
+ const rules: { [string]: mixed } = {};
186
+ for (const rule of disabled) rules[rule] = { enabled: false };
187
+ run.rules = rules;
188
+ }
189
+ return run;
190
+ }
191
+
192
+ /**
193
+ * Whether a violation is at or above the configured floor.
194
+ *
195
+ * A violation axe could not rate is kept: "we do not know how bad this is" is
196
+ * not a reason to drop it, and dropping it is how a floor set to `critical`
197
+ * would quietly hide everything unrated.
198
+ */
199
+ function meetsFloor(violation: AxeViolation, floor: AxeImpact | void): boolean {
200
+ if (floor == null || violation.impact == null) return true;
201
+ return IMPACTS.indexOf(violation.impact) >= IMPACTS.indexOf(floor);
202
+ }
203
+
204
+ /** One element, as short a piece of markup as still identifies it. */
205
+ function excerpt(html: string): string {
206
+ const line = html.split("\n")[0].trim();
207
+ return line.length > MAX_NODE_CHARS ? `${line.slice(0, MAX_NODE_CHARS)}…` : line;
208
+ }
209
+
210
+ /**
211
+ * The audit this process started last, settled or not.
212
+ *
213
+ * axe-core runs one audit at a time per process and refuses a second outright
214
+ * ("Axe is already running") rather than queueing it. One file never starts two
215
+ * on purpose, but a worker serves many files: a case that timed out in the
216
+ * middle of an audit leaves that audit running, and the next file's first audit
217
+ * used to fail on it — reported under a file that had done nothing wrong.
218
+ */
219
+ let running: Promise<mixed> = Promise.resolve();
220
+
221
+ /**
222
+ * Run `audit` once the audit already running has settled, however it settles.
223
+ *
224
+ * Waiting rather than failing is safe because the audit being waited on always
225
+ * ends — axe-core walks a finite tree — and a case that waits too long is still
226
+ * bounded by its own timeout.
227
+ */
228
+ function afterTheAuditRunning<T>(audit: () => Promise<T>): Promise<T> {
229
+ const turn = running.then(audit, audit);
230
+ running = turn.then(
231
+ () => undefined,
232
+ () => undefined,
233
+ );
234
+ return turn;
235
+ }
236
+
237
+ /** How long to wait between looks at an audit this module did not start. */
238
+ const FOREIGN_AUDIT_POLL_MS = 10;
239
+
240
+ /** How long an audit this module did not start may hold axe-core. */
241
+ const FOREIGN_AUDIT_LIMIT_MS = 10000;
242
+
243
+ // The timer and the clock as they were when this module loaded, so a file that
244
+ // fakes timers cannot stall a wait for a real audit to end.
245
+ const pause: typeof setTimeout = setTimeout;
246
+ const now: () => number = Date.now.bind(Date);
247
+
248
+ /**
249
+ * Whether axe-core refused to start because an audit is already running.
250
+ *
251
+ * `running` queues every audit this module starts, so a refusal means an audit
252
+ * started somewhere else. `uf dev`'s overlay runs axe-core itself, and stopping
253
+ * the overlay cannot recall an audit already walking the page: its own test can
254
+ * leave one finishing into the next file in the worker.
255
+ */
256
+ function isAlreadyRunning(error: mixed): boolean {
257
+ return error instanceof Error && error.message.startsWith("Axe is already running");
258
+ }
259
+
260
+ /**
261
+ * Run the audit once axe-core is free of an audit this module did not start.
262
+ *
263
+ * Looks again every `FOREIGN_AUDIT_POLL_MS` until axe-core takes it, and after
264
+ * `FOREIGN_AUDIT_LIMIT_MS` says what held the engine rather than waiting on an
265
+ * audit that is not going to end.
266
+ */
267
+ function runOnceAxeIsFree(
268
+ axe: $FlowFixMe,
269
+ node: mixed,
270
+ options: mixed,
271
+ since: number = now(),
272
+ ): Promise<mixed> {
273
+ return Promise.resolve()
274
+ .then(() => axe.run(node, options))
275
+ .catch((error: mixed) => {
276
+ if (!isAlreadyRunning(error)) {
277
+ throw error;
278
+ }
279
+ if (now() - since >= FOREIGN_AUDIT_LIMIT_MS) {
280
+ throw new Error(
281
+ "axe-core has been running an audit uf did not start for ten seconds, such as " +
282
+ "`uf dev`'s overlay or a direct `axe.run`; this assertion could not start its own.",
283
+ );
284
+ }
285
+ return new Promise((resolve) => {
286
+ pause(resolve, FOREIGN_AUDIT_POLL_MS);
287
+ }).then(() => runOnceAxeIsFree(axe, node, options, since));
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Audit `node` and report what it found, weakest results already dropped.
293
+ *
294
+ * Rejects when axe-core is not installed or the host has no document to audit.
295
+ * Both are "this assertion cannot be made here" rather than "this assertion
296
+ * failed", and both are ordinary errors rather than an [`AssertionError`]: the
297
+ * runner prints an assertion failure as a comparison, and there is nothing
298
+ * here to compare — what a reader needs is the sentence saying what to
299
+ * install.
300
+ */
301
+ export async function auditElement(
302
+ node: mixed,
303
+ overrides?: AxeOptions,
304
+ ): Promise<$ReadOnlyArray<AxeViolation>> {
305
+ if (typeof globalThis.document === "undefined") {
306
+ throw new Error(
307
+ "`toHaveNoAxeViolations` needs a document, and this host has none. " +
308
+ "Render with `@uniflowed/react-testing`, or run the file with `uf test --browser`.",
309
+ );
310
+ }
311
+ const axe = await axeEngine();
312
+ const options = resolveOptions(overrides);
313
+ const results = await afterTheAuditRunning(() =>
314
+ runOnceAxeIsFree(axe, node, axeRunOptions(options)),
315
+ );
316
+ const found: Array<AxeViolation> = [];
317
+ for (const raw of arrayAt(results, "violations")) {
318
+ const violation: AxeViolation = {
319
+ id: stringAt(raw, "id"),
320
+ impact: impactOf(raw),
321
+ help: stringAt(raw, "help"),
322
+ helpUrl: stringAt(raw, "helpUrl"),
323
+ nodes: arrayAt(raw, "nodes").map((one) => excerpt(stringAt(one, "html"))),
324
+ };
325
+ if (meetsFloor(violation, options.minImpact)) found.push(violation);
326
+ }
327
+ return found;
328
+ }
329
+
330
+ /**
331
+ * What a reader is told when the audit found something.
332
+ *
333
+ * The rule id, what it wants, the elements that broke it, and the page Deque
334
+ * publishes about it — which is the part that turns "aria-required-children"
335
+ * into something a person can act on without a search engine.
336
+ *
337
+ * Bounded, because a container rendered from bad markup can violate a hundred
338
+ * rules and a failure that fills a terminal is a failure nobody reads.
339
+ */
340
+ export function describeViolations(violations: $ReadOnlyArray<AxeViolation>): string {
341
+ const lines: Array<string> = [];
342
+ for (const violation of violations.slice(0, MAX_VIOLATIONS_SHOWN)) {
343
+ const impact = violation.impact == null ? "" : ` (${violation.impact})`;
344
+ lines.push(` ${violation.id}${impact} — ${violation.help}`);
345
+ for (const node of violation.nodes.slice(0, MAX_NODES_SHOWN)) {
346
+ lines.push(` ${node}`);
347
+ }
348
+ if (violation.nodes.length > MAX_NODES_SHOWN) {
349
+ lines.push(` …and ${violation.nodes.length - MAX_NODES_SHOWN} more elements`);
350
+ }
351
+ if (violation.helpUrl !== "") lines.push(` ${violation.helpUrl}`);
352
+ }
353
+ if (violations.length > MAX_VIOLATIONS_SHOWN) {
354
+ lines.push(` …and ${violations.length - MAX_VIOLATIONS_SHOWN} more rules`);
355
+ }
356
+ return lines.join("\n");
357
+ }
358
+
359
+ /** The rule ids a set of violations covers, for the `expected`/`received` pair. */
360
+ export function violationIds(violations: $ReadOnlyArray<AxeViolation>): string {
361
+ return violations.map((violation) => violation.id).join(", ");
362
+ }