automatica11y 0.3.2 → 0.4.0
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 +35 -8
- package/package.json +6 -3
- package/skills/automatica11y-runner/SKILL.md +47 -13
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/data/README.md +19 -0
- package/src/data/wcag-2.2.json +7557 -0
- package/src/data/wcag-2.2.source.json +6 -0
- package/src/frameworks/index.js +42 -0
- package/src/frameworks/react.js +77 -0
- package/src/frameworks/vue.js +90 -0
- package/src/frameworks/wc.js +55 -0
- package/src/globals.d.ts +2 -0
- package/src/harness/bundle.js +23 -8
- package/src/harness/generate/dialects.js +64 -0
- package/src/harness/generate/index.js +12 -0
- package/src/harness/generate/jsx-recipes.js +224 -0
- package/src/harness/generate/jsx.js +76 -0
- package/src/harness/generate/kit.js +50 -0
- package/src/harness/generate/marking.js +64 -0
- package/src/harness/generate/probe.js +97 -0
- package/src/harness/generate/shared.js +12 -0
- package/src/harness/generate/wc-recipes.js +132 -0
- package/src/harness/npm-install.js +85 -5
- package/src/harness/settle.js +17 -0
- package/src/harness/storybook.js +1 -0
- package/src/harness/url.js +13 -3
- package/src/plan/classify.js +11 -4
- package/src/plan/mapping.js +11 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +21 -6
- package/src/report/parts.js +60 -8
- package/src/run/audit-npm.js +137 -29
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +24 -4
- package/src/run/summary.js +10 -0
- package/src/schema.js +34 -7
- package/src/tiers/computed/checks.js +197 -0
- package/src/tiers/computed/color.js +48 -0
- package/src/tiers/computed/index.js +25 -0
- package/src/tiers/computed/measure-kit.js +225 -0
- package/src/tiers/conditions/checks.js +283 -0
- package/src/tiers/conditions/index.js +30 -0
- package/src/tiers/conditions/kit.js +133 -0
- package/src/tiers/interactions/archetypes.js +117 -4
- package/src/tiers/interactions/focus-indicator.js +39 -0
- package/src/tiers/interactions/helpers.js +87 -16
- package/src/tiers/interactions/index.js +17 -4
- package/src/tiers/rules/axe.js +4 -3
- package/src/wcag/index.js +83 -0
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
package/src/run/audit-npm.js
CHANGED
|
@@ -2,14 +2,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { bundleEntries } from "../harness/bundle.js";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import
|
|
5
|
+
import { GENERATABLE } from "../harness/generate/index.js";
|
|
6
|
+
import { generateFixture } from "./generate-fixture.js";
|
|
7
|
+
import { settleAnimations } from "../harness/settle.js";
|
|
8
|
+
import { installExtraPackages, installOptionalPeers, installPackage } from "../harness/npm-install.js";
|
|
9
|
+
import { adapterFor, adapterForKind } from "../frameworks/index.js";
|
|
10
|
+
import { subpathProblem } from "../plan/subpath.js";
|
|
8
11
|
import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
|
|
9
12
|
import { serveStatic } from "../harness/static-serve.js";
|
|
10
13
|
import { openPage } from "../harness/url.js";
|
|
11
14
|
import { candidateMapping, findAuthoredFixture } from "../plan/mapping.js";
|
|
12
15
|
import { ARCHETYPES } from "../schema.js";
|
|
16
|
+
import { runComputed } from "../tiers/computed/index.js";
|
|
17
|
+
import { runConditions } from "../tiers/conditions/index.js";
|
|
13
18
|
import { runInteractions } from "../tiers/interactions/index.js";
|
|
14
19
|
import { runRules } from "../tiers/rules/index.js";
|
|
15
20
|
import { failedVsr, runVsr } from "../tiers/vsr.js";
|
|
@@ -23,6 +28,7 @@ const STATES = {
|
|
|
23
28
|
tooltip: ["closed", "open"],
|
|
24
29
|
combobox: ["closed", "open"],
|
|
25
30
|
accordion: ["collapsed", "expanded"],
|
|
31
|
+
"live-region": ["before message", "message shown"],
|
|
26
32
|
};
|
|
27
33
|
|
|
28
34
|
const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
|
|
@@ -40,12 +46,28 @@ function watchErrors(errors) {
|
|
|
40
46
|
};
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Bundle, and if the library needs an optional peer dependency that npm left out, install it and bundle once more.
|
|
51
|
+
* @param {Parameters<typeof bundleEntries>[0]} options
|
|
52
|
+
* @param {string[]} warnings Gets a note when peers were installed.
|
|
53
|
+
*/
|
|
54
|
+
async function bundleWithPeers(options, warnings) {
|
|
55
|
+
try {
|
|
56
|
+
return await bundleEntries(options);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const added = await installOptionalPeers({ dir: options.workDir, unresolved: error.unresolved ?? [] });
|
|
59
|
+
if (added.installed.length === 0) throw error;
|
|
60
|
+
warnings.push(...added.warnings);
|
|
61
|
+
return bundleEntries(options);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
43
65
|
/** Load the whole package in a page to list its exports and the custom elements it defines. */
|
|
44
|
-
async function discover({ browser, workDir, flavor, pkg, buildDir }) {
|
|
45
|
-
const
|
|
66
|
+
async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
|
|
67
|
+
const adapter = adapterFor(flavor);
|
|
46
68
|
const entryFile = join(workDir, "discover.js");
|
|
47
|
-
writeFileSync(entryFile,
|
|
48
|
-
await
|
|
69
|
+
writeFileSync(entryFile, adapter.discoverEntry(pkg));
|
|
70
|
+
await bundleWithPeers({ entries: { discover: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
|
|
49
71
|
const server = await serveStatic(buildDir);
|
|
50
72
|
const errors = [];
|
|
51
73
|
try {
|
|
@@ -57,8 +79,31 @@ async function discover({ browser, workDir, flavor, pkg, buildDir }) {
|
|
|
57
79
|
}
|
|
58
80
|
const exportsList = await opened.page.evaluate(() => /** @type {any} */ (window).__a11yExports);
|
|
59
81
|
const tags = await opened.page.evaluate(() => [.../** @type {any} */ (window).__a11yDefined ?? []]);
|
|
82
|
+
// What each element says about itself, so a fixture can be built around it: observed attributes, class members, slots.
|
|
83
|
+
const facts = await opened.page.evaluate((names) => {
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const name of names.slice(0, 80)) {
|
|
86
|
+
const Element = customElements.get(name);
|
|
87
|
+
if (!Element) continue;
|
|
88
|
+
const members = new Set();
|
|
89
|
+
for (let proto = Element.prototype; proto && proto !== HTMLElement.prototype && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
90
|
+
for (const key of Object.getOwnPropertyNames(proto)) if (!key.startsWith("_") && key !== "constructor") members.add(key);
|
|
91
|
+
}
|
|
92
|
+
let slots = [];
|
|
93
|
+
try {
|
|
94
|
+
const el = document.createElement(name);
|
|
95
|
+
document.body.append(el);
|
|
96
|
+
slots = [...(el.shadowRoot?.querySelectorAll("slot") ?? [])].map((slot) => slot.getAttribute("name") ?? "");
|
|
97
|
+
el.remove();
|
|
98
|
+
} catch {
|
|
99
|
+
// An element that can't be created on its own has no slots to report.
|
|
100
|
+
}
|
|
101
|
+
out[name] = { attributes: [.../** @type {any} */ (Element).observedAttributes ?? []], members: [...members], slots: slots.filter(Boolean) };
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}, tags);
|
|
60
105
|
await opened.close();
|
|
61
|
-
return { exports: exportsList, tags };
|
|
106
|
+
return { exports: exportsList, tags, facts };
|
|
62
107
|
} finally {
|
|
63
108
|
await server.close();
|
|
64
109
|
}
|
|
@@ -79,6 +124,9 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
|
|
|
79
124
|
if (count !== 1) return { gap: `The fixture must mark exactly one data-a11y-trigger. It marked ${num(count)}.` };
|
|
80
125
|
if (errors.length) return { gap: `The fixture logged errors when it mounted: ${errors[0]}` };
|
|
81
126
|
|
|
127
|
+
if (archetype === "live-region" && (await trigger.first().evaluate((el) => el.matches("[data-a11y-root]")))) {
|
|
128
|
+
return { gap: "The fixture marks the same element as the trigger and the message. A live-region fixture needs a control that makes the message appear (data-a11y-trigger) and the message itself (data-a11y-root)." };
|
|
129
|
+
}
|
|
82
130
|
const states = STATES[archetype] ?? ["initial"];
|
|
83
131
|
const configs = [];
|
|
84
132
|
for (const [index, state] of states.entries()) {
|
|
@@ -88,23 +136,25 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
|
|
|
88
136
|
if (archetype === "tooltip") await trigger.first().focus();
|
|
89
137
|
else await trigger.first().click();
|
|
90
138
|
await opened.page.waitForFunction(
|
|
91
|
-
() => {
|
|
139
|
+
(needsText) => {
|
|
92
140
|
const root = document.querySelector("[data-a11y-root]");
|
|
93
141
|
const shown = root && /** @type {HTMLElement} */ (root).getClientRects().length > 0;
|
|
142
|
+
// A live region can be on the page and empty until the trigger fills it, so wait for the message itself.
|
|
143
|
+
if (needsText) return Boolean(shown && ((root.textContent ?? "").trim() || root.getAttribute("aria-label") || root.getAttribute("aria-labelledby")));
|
|
94
144
|
return shown || document.querySelector('[data-a11y-trigger][aria-expanded="true"]') !== null;
|
|
95
145
|
},
|
|
96
|
-
|
|
146
|
+
archetype === "live-region",
|
|
97
147
|
{ timeout: 3000 },
|
|
98
148
|
);
|
|
99
149
|
} catch {
|
|
100
150
|
failure = `The ${state} state never appeared after activating the trigger. A fixture's data-a11y-root has to show up when the ${archetype} opens.`;
|
|
101
151
|
}
|
|
102
152
|
}
|
|
103
|
-
await opened.page
|
|
153
|
+
await settleAnimations(opened.page);
|
|
104
154
|
/** @type {Record<string, any>} */
|
|
105
155
|
const tiers = {};
|
|
106
156
|
for (const tier of plan.options.tiers) {
|
|
107
|
-
if (tier === "interactions") continue;
|
|
157
|
+
if (tier === "interactions" || tier === "computed" || tier === "conditions") continue;
|
|
108
158
|
if (tier === "vsr") tiers.vsr = failure ? { status: "skipped", simulated: true, reason: failure } : await runVsr(opened.page, { scope: "body", state }).catch(failedVsr);
|
|
109
159
|
else if (failure) tiers.rules = { status: "failed", reason: failure, engines: Object.fromEntries(plan.options.engines.map((e) => [e, { status: "failed", reason: failure }])) };
|
|
110
160
|
else {
|
|
@@ -117,6 +167,8 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
|
|
|
117
167
|
const hidden = notTestableEntries(await closedShadowHosts(opened.page));
|
|
118
168
|
// The checks open their own fresh pages, so run them after this page's rules results are in.
|
|
119
169
|
if (plan.options.tiers.includes("interactions")) configs[0].tiers.interactions = await runInteractions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
|
|
170
|
+
if (plan.options.tiers.includes("computed")) configs[0].tiers.computed = await runComputed(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
|
|
171
|
+
if (plan.options.tiers.includes("conditions")) configs[0].tiers.conditions = await runConditions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
|
|
120
172
|
return { configs, hidden };
|
|
121
173
|
} finally {
|
|
122
174
|
await opened.close();
|
|
@@ -143,14 +195,14 @@ async function auditFixture({ browser, url, archetype, plan, toggle }) {
|
|
|
143
195
|
/**
|
|
144
196
|
* Audit an npm package: install it on its own, find what it exports, and audit each archetype that has a fixture.
|
|
145
197
|
* An archetype without a usable fixture is a gap with a reason, never a pass.
|
|
146
|
-
* @returns {Promise<{ result: any, mapping: Record<string, any> | null }>}
|
|
198
|
+
* @returns {Promise<{ result: any, mapping: Record<string, any> | null, files?: Record<string, string> }>}
|
|
147
199
|
*/
|
|
148
200
|
export async function auditNpm({ browser, planTarget, plan, cwd, install = installPackage }) {
|
|
149
201
|
install ??= installPackage;
|
|
150
202
|
const base = { id: planTarget.id, reason: null, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
|
|
151
203
|
const resolved = planTarget.resolved ?? {};
|
|
152
204
|
if (planTarget.kind === "npm-unsupported") {
|
|
153
|
-
return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported.
|
|
205
|
+
return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported. This version covers React, Vue 3, and web components.` }, mapping: null };
|
|
154
206
|
}
|
|
155
207
|
const tmp = mkdtempSync(join(tmpdir(), "automatica11y-npm-"));
|
|
156
208
|
const workDir = join(tmp, "install");
|
|
@@ -159,22 +211,33 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
|
|
|
159
211
|
mkdirSync(fixtureDir, { recursive: true });
|
|
160
212
|
const servers = [];
|
|
161
213
|
try {
|
|
162
|
-
/**
|
|
163
|
-
|
|
214
|
+
/** The framework's id (react, vue, or wc), or "unknown" when the metadata couldn't say. */
|
|
215
|
+
/** @type {any} */
|
|
216
|
+
let flavor = adapterForKind(planTarget.kind)?.id ?? "unknown";
|
|
164
217
|
const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
|
|
165
218
|
const warnings = [...installed.warnings];
|
|
219
|
+
// What fixtures and templates import: the package, or the sub-path of it that was asked for.
|
|
220
|
+
const importSpec = resolved.subpath ? `${resolved.name}/${resolved.subpath}` : resolved.name;
|
|
221
|
+
if (resolved.subpath) {
|
|
222
|
+
const problem = subpathProblem(workDir, resolved.name, resolved.subpath, installed.version ?? resolved.version);
|
|
223
|
+
if (problem) throw new Error(problem);
|
|
224
|
+
}
|
|
166
225
|
|
|
167
|
-
|
|
226
|
+
// Packages the mapping names (a token stylesheet, a theme) go in beside the library, so a fixture can import them.
|
|
227
|
+
const extras = await installExtraPackages({ dir: workDir, specs: Object.values(planTarget.mapping ?? {}).flatMap((entry) => entry.install ?? []) });
|
|
228
|
+
warnings.push(...extras.warnings);
|
|
229
|
+
|
|
230
|
+
const found = await discover({ browser, workDir, flavor: flavor === "unknown" ? "wc" : flavor, pkg: importSpec, buildDir, warnings });
|
|
168
231
|
if (flavor === "unknown") {
|
|
169
232
|
if (found.tags.length > 0) flavor = "wc";
|
|
170
233
|
else if (installed.react && found.exports.some((e) => /^[A-Z]/.test(e.name))) flavor = "react";
|
|
171
234
|
}
|
|
172
235
|
if (flavor === "unknown" || (flavor === "wc" && found.tags.length === 0)) {
|
|
173
|
-
return { result: { ...base, status: "not-applicable", reason: "The package has no rendering surface. It exports no
|
|
236
|
+
return { result: { ...base, status: "not-applicable", reason: "The package has no rendering surface. It exports no components for a supported framework and defines no custom elements.", warnings }, mapping: null };
|
|
174
237
|
}
|
|
175
238
|
|
|
176
239
|
const candidates = candidateMapping({ flavor, exports: found.exports, tags: found.tags });
|
|
177
|
-
const
|
|
240
|
+
const adapter = adapterFor(flavor);
|
|
178
241
|
const wanted = plan.options.archetypes ?? ARCHETYPES;
|
|
179
242
|
/** @type {Record<string, any>} */
|
|
180
243
|
const mapping = {};
|
|
@@ -182,7 +245,20 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
|
|
|
182
245
|
const archetypes = {};
|
|
183
246
|
const gaps = [];
|
|
184
247
|
const hidden = [];
|
|
185
|
-
|
|
248
|
+
/** Files to write beside the report: the fixtures the tool generated, so they can be reviewed and adopted. */
|
|
249
|
+
const files = {};
|
|
250
|
+
/** Where each archetype's fixture came from, for the results. */
|
|
251
|
+
const sources = {};
|
|
252
|
+
/** One server for the probes, started when the first one is needed. */
|
|
253
|
+
let probeServer = null;
|
|
254
|
+
const getServer = async () => {
|
|
255
|
+
if (!probeServer) {
|
|
256
|
+
mkdirSync(buildDir, { recursive: true });
|
|
257
|
+
probeServer = await serveStatic(buildDir);
|
|
258
|
+
servers.push(probeServer);
|
|
259
|
+
}
|
|
260
|
+
return probeServer;
|
|
261
|
+
};
|
|
186
262
|
|
|
187
263
|
// Decide where each archetype's fixture comes from, then bundle each one on its own so one bad fixture can't break the rest.
|
|
188
264
|
const runnable = {};
|
|
@@ -200,29 +276,57 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
|
|
|
200
276
|
entry.status = "needs-fixture";
|
|
201
277
|
entry.reason = `The mapping names ${user.fixture}, but that file doesn't exist.`;
|
|
202
278
|
} else if (entry.status === "template" || (user.export || user.tag) && ["button", "link"].includes(archetype)) {
|
|
203
|
-
const source = flavor === "
|
|
279
|
+
const source = adapter.template(archetype, importSpec, flavor === "wc" ? entry.tag : entry.export);
|
|
204
280
|
if (source) {
|
|
205
281
|
entry.status = "template";
|
|
206
282
|
delete entry.reason;
|
|
207
|
-
fixture = join(fixtureDir, `${archetype}.${
|
|
283
|
+
fixture = join(fixtureDir, `${archetype}.${adapter.extension}`);
|
|
208
284
|
writeFileSync(fixture, source);
|
|
209
285
|
}
|
|
210
286
|
}
|
|
287
|
+
/** @type {any} */
|
|
288
|
+
let source = fixture ? { source: entry.status === "authored" ? "authored" : "template" } : null;
|
|
289
|
+
let attempts = [];
|
|
290
|
+
let generationTried = false;
|
|
291
|
+
// Nothing authored and no template: build candidates from what the package exports, and keep one only if it works.
|
|
292
|
+
if (!fixture && !user.fixture && plan.options.generate !== false && GENERATABLE.has(archetype) && !(entry.status === "no-match" && flavor === "wc")) {
|
|
293
|
+
const generated = await generateFixture({ browser, adapter, archetype, entry, found, explicit: Boolean(user.export), pkg: importSpec, tmp, workDir, buildDir, getServer, bundle: (options) => bundleWithPeers(options, warnings) });
|
|
294
|
+
attempts = generated.attempts;
|
|
295
|
+
generationTried = attempts.length > 0;
|
|
296
|
+
if (generated.ok && generated.winner) {
|
|
297
|
+
const { winner } = generated;
|
|
298
|
+
const relative = `generated/${planTarget.id}/${archetype}.${winner.extension}`;
|
|
299
|
+
files[relative] = winner.source;
|
|
300
|
+
fixture = winner.file;
|
|
301
|
+
entry.status = "generated";
|
|
302
|
+
entry.recipe = winner.recipe;
|
|
303
|
+
entry.summary = winner.summary;
|
|
304
|
+
entry.used = winner.used;
|
|
305
|
+
entry.generatedFile = relative;
|
|
306
|
+
delete entry.reason;
|
|
307
|
+
source = { source: "generated", recipe: winner.recipe, summary: winner.summary, used: winner.used, file: relative, attempts };
|
|
308
|
+
} else if (generated.reason && (generationTried || entry.status !== "no-match")) {
|
|
309
|
+
entry.reason = `${entry.status === "no-match" ? "" : `${entry.reason ?? `The ${archetype} archetype needs a fixture someone writes.`} `}Generating one didn't work. ${generated.reason}`.trim();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
211
312
|
mapping[archetype] = entry;
|
|
212
313
|
if (!fixture) {
|
|
213
|
-
|
|
214
|
-
|
|
314
|
+
// A no-match archetype has no component to write a fixture for, unless a generator looked and said why it couldn't build one.
|
|
315
|
+
const writable = entry.status !== "no-match" || generationTried;
|
|
316
|
+
const reason = entry.reason ?? `The ${archetype} archetype needs a fixture someone writes.`;
|
|
317
|
+
archetypes[archetype] = { status: "gap", reason: writable ? `${reason} Write fixtures/${planTarget.id}/${archetype}.${adapter.extension}.` : reason, configs: [], ...(attempts.length ? { fixture: { source: "none", attempts } } : {}) };
|
|
215
318
|
gaps.push(`archetype:${archetype}`);
|
|
216
319
|
continue;
|
|
217
320
|
}
|
|
218
321
|
const entryFile = join(tmp, "entries", `${archetype}-entry.js`);
|
|
219
322
|
mkdirSync(join(tmp, "entries"), { recursive: true });
|
|
220
|
-
writeFileSync(entryFile,
|
|
323
|
+
writeFileSync(entryFile, adapter.entry(fixture, importSpec));
|
|
221
324
|
try {
|
|
222
|
-
await
|
|
325
|
+
await bundleWithPeers({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
|
|
223
326
|
runnable[archetype] = `/${archetype}.html`;
|
|
327
|
+
sources[archetype] = source;
|
|
224
328
|
} catch (error) {
|
|
225
|
-
archetypes[archetype] = { status: "gap", reason: `The fixture didn't bundle. ${firstLine(error)}`, configs: [] };
|
|
329
|
+
archetypes[archetype] = { status: "gap", reason: `The fixture didn't bundle. ${firstLine(error)}`, configs: [], ...(attempts.length ? { fixture: { source: "none", attempts } } : {}) };
|
|
226
330
|
gaps.push(`archetype:${archetype}`);
|
|
227
331
|
entry.status = "needs-fixture";
|
|
228
332
|
entry.reason = firstLine(error);
|
|
@@ -235,13 +339,14 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
|
|
|
235
339
|
for (const [archetype, path] of Object.entries(runnable)) {
|
|
236
340
|
/** @type {any} */
|
|
237
341
|
const outcome = await auditFixture({ browser, url: `${live.origin}${path}`, archetype, plan, toggle: mapping[archetype].libA11y === true }).catch((error) => ({ gap: firstLine(error) }));
|
|
342
|
+
const fixtureInfo = sources[archetype];
|
|
238
343
|
if (outcome.gap) {
|
|
239
|
-
archetypes[archetype] = { status: "gap", reason: outcome.gap, configs: [] };
|
|
344
|
+
archetypes[archetype] = { status: "gap", reason: outcome.gap, configs: [], ...(fixtureInfo?.attempts?.length ? { fixture: { source: "none", attempts: fixtureInfo.attempts } } : {}) };
|
|
240
345
|
gaps.push(`archetype:${archetype}`);
|
|
241
346
|
mapping[archetype].status = "needs-fixture";
|
|
242
347
|
mapping[archetype].reason = outcome.gap;
|
|
243
348
|
} else {
|
|
244
|
-
archetypes[archetype] = { status: "ran", configs: outcome.configs };
|
|
349
|
+
archetypes[archetype] = { status: "ran", configs: outcome.configs, ...(fixtureInfo ? { fixture: fixtureInfo } : {}) };
|
|
245
350
|
hidden.push(...outcome.hidden.map((h) => `${archetype}: ${h}`));
|
|
246
351
|
}
|
|
247
352
|
}
|
|
@@ -257,11 +362,13 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
|
|
|
257
362
|
archetypes: ordered,
|
|
258
363
|
npm: {
|
|
259
364
|
name: resolved.name,
|
|
365
|
+
subpath: resolved.subpath ?? null,
|
|
260
366
|
version: installed.version ?? resolved.version,
|
|
261
367
|
flavor,
|
|
262
368
|
framework: resolved.framework ?? null,
|
|
263
369
|
react: installed.react,
|
|
264
370
|
reactDom: installed.reactDom,
|
|
371
|
+
vue: installed.vue ?? null,
|
|
265
372
|
tags: flavor === "wc" ? found.tags : [],
|
|
266
373
|
},
|
|
267
374
|
summary: (() => {
|
|
@@ -271,6 +378,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
|
|
|
271
378
|
warnings,
|
|
272
379
|
},
|
|
273
380
|
mapping: Object.fromEntries(Object.entries(mapping).map(([k, v]) => [k, { ...v, fixture: v.fixture ?? null }])),
|
|
381
|
+
files,
|
|
274
382
|
};
|
|
275
383
|
} finally {
|
|
276
384
|
for (const s of servers) await s.close();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { probeFixture } from "../harness/generate/probe.js";
|
|
4
|
+
|
|
5
|
+
const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a fixture for an archetype from what discovery found, and keep it only if it works.
|
|
9
|
+
* Candidates are bundled (all at once, or one by one if that fails), then probed in the browser in order. The first that
|
|
10
|
+
* passes wins. Every attempt is recorded, whether or not one wins, so a report can say what was tried.
|
|
11
|
+
*
|
|
12
|
+
* @param {{
|
|
13
|
+
* browser: import("playwright-core").Browser,
|
|
14
|
+
* adapter: import("../frameworks/index.js").Adapter,
|
|
15
|
+
* archetype: string,
|
|
16
|
+
* entry: { export?: string, tag?: string },
|
|
17
|
+
* found: { exports: any[], tags: string[], facts: Record<string, any> },
|
|
18
|
+
* pkg: string,
|
|
19
|
+
* explicit?: boolean,
|
|
20
|
+
* tmp: string,
|
|
21
|
+
* workDir: string,
|
|
22
|
+
* buildDir: string,
|
|
23
|
+
* getServer: () => Promise<{ origin: string }>,
|
|
24
|
+
* bundle: (options: any) => Promise<unknown>,
|
|
25
|
+
* }} input
|
|
26
|
+
* @returns {Promise<{ ok: boolean, reason: string | null, attempts: Array<{ recipe: string, summary: string, ok: boolean, reason: string | null }>, winner?: { recipe: string, summary: string, used: string[], source: string, file: string, extension: string } }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function generateFixture({ browser, adapter, archetype, entry, found, explicit = false, pkg, tmp, workDir, buildDir, getServer, bundle }) {
|
|
29
|
+
const { candidates, reason } = adapter.generate({ archetype, pkg, entry, exports: found.exports, facts: found.facts, explicit });
|
|
30
|
+
if (candidates.length === 0) return { ok: false, reason, attempts: [] };
|
|
31
|
+
|
|
32
|
+
const extension = adapter.extension;
|
|
33
|
+
mkdirSync(join(tmp, "generated"), { recursive: true });
|
|
34
|
+
mkdirSync(join(tmp, "entries"), { recursive: true });
|
|
35
|
+
const prepared = candidates.map((candidate, index) => {
|
|
36
|
+
const name = `gen-${archetype}-${index}`;
|
|
37
|
+
const file = join(tmp, "generated", `${archetype}-${index}.${extension}`);
|
|
38
|
+
writeFileSync(file, candidate.source);
|
|
39
|
+
const entryFile = join(tmp, "entries", `${name}.js`);
|
|
40
|
+
writeFileSync(entryFile, adapter.entry(file, pkg));
|
|
41
|
+
return { candidate, name, file, entryFile, bundled: null };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// One build for every candidate. If a single bad one breaks it, build them one by one so the rest still get a chance.
|
|
45
|
+
try {
|
|
46
|
+
await bundle({ entries: Object.fromEntries(prepared.map((p) => [p.name, p.entryFile])), outdir: buildDir, workDir, framework: adapter });
|
|
47
|
+
for (const p of prepared) p.bundled = true;
|
|
48
|
+
} catch {
|
|
49
|
+
for (const p of prepared) {
|
|
50
|
+
try {
|
|
51
|
+
await bundle({ entries: { [p.name]: p.entryFile }, outdir: buildDir, workDir, framework: adapter });
|
|
52
|
+
p.bundled = true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
p.bundled = firstLine(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const server = await getServer();
|
|
60
|
+
const attempts = [];
|
|
61
|
+
for (const p of prepared) {
|
|
62
|
+
const base = { recipe: p.candidate.id, summary: p.candidate.summary };
|
|
63
|
+
if (p.bundled !== true) {
|
|
64
|
+
attempts.push({ ...base, ok: false, reason: `it didn't bundle: ${p.bundled}` });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const probe = await probeFixture(browser, `${server.origin}/${p.name}.html`, archetype);
|
|
68
|
+
attempts.push({ ...base, ok: probe.ok, reason: probe.reason });
|
|
69
|
+
if (probe.ok) {
|
|
70
|
+
return { ok: true, reason: null, attempts, winner: { ...base, used: p.candidate.used, source: p.candidate.source, file: p.file, extension } };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { ok: false, reason: `${attempts.length === 1 ? "The one generated fixture didn't work" : `None of the ${attempts.length} generated fixtures worked`}. ${attempts.slice(0, 2).map((a) => `${a.summary}: ${a.reason}`).join("; ")}${attempts.length > 2 ? `; and ${attempts.length - 2} more` : ""}.`, attempts };
|
|
74
|
+
}
|
package/src/run/run-plan.js
CHANGED
|
@@ -6,6 +6,8 @@ import { launchBrowser } from "../harness/browser.js";
|
|
|
6
6
|
import { serveStatic } from "../harness/static-serve.js";
|
|
7
7
|
import { listStories, readIndex, selectStories, storyUrl, waitForStory } from "../harness/storybook.js";
|
|
8
8
|
import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
|
|
9
|
+
import { COMPUTED_NOT_APPLICABLE_FOR_PAGES } from "../tiers/computed/index.js";
|
|
10
|
+
import { CONDITIONS_NOT_APPLICABLE_FOR_STORIES, runConditions } from "../tiers/conditions/index.js";
|
|
9
11
|
import { NOT_APPLICABLE_FOR_PAGES } from "../tiers/interactions/index.js";
|
|
10
12
|
import { failedVsr, runVsr } from "../tiers/vsr.js";
|
|
11
13
|
import { openPage } from "../harness/url.js";
|
|
@@ -16,6 +18,7 @@ import { runRules, selfTest } from "../tiers/rules/index.js";
|
|
|
16
18
|
import { evaluateFailCheck } from "./fail-check.js";
|
|
17
19
|
import { mapPool } from "./pool.js";
|
|
18
20
|
import { auditNpm } from "./audit-npm.js";
|
|
21
|
+
import { adapterFor } from "../frameworks/index.js";
|
|
19
22
|
import { failedTarget, summarize } from "./summary.js";
|
|
20
23
|
|
|
21
24
|
/** How many stories to audit at once. */
|
|
@@ -23,7 +26,7 @@ const STORY_CONCURRENCY = 4;
|
|
|
23
26
|
|
|
24
27
|
const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
|
|
25
28
|
|
|
26
|
-
const NPM_KINDS = new Set(["npm", "npm-react", "npm-wc", "npm-unsupported"]);
|
|
29
|
+
const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported"]);
|
|
27
30
|
const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
|
|
28
31
|
|
|
29
32
|
/** Audit one page and return its target result. */
|
|
@@ -37,6 +40,11 @@ async function auditPage(browser, url, planTarget, plan, extraWarnings) {
|
|
|
37
40
|
tiers.rules = await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level });
|
|
38
41
|
} else if (tier === "interactions") {
|
|
39
42
|
tiers.interactions = NOT_APPLICABLE_FOR_PAGES;
|
|
43
|
+
} else if (tier === "computed") {
|
|
44
|
+
tiers.computed = COMPUTED_NOT_APPLICABLE_FOR_PAGES;
|
|
45
|
+
} else if (tier === "conditions") {
|
|
46
|
+
// Each check opens its own copies of the page, so this runs while the first page is still open.
|
|
47
|
+
tiers.conditions = await runConditions(browser, url, "page");
|
|
40
48
|
} else {
|
|
41
49
|
tiers.vsr = await runVsr(opened.page, { scope: "body" }).catch(failedVsr);
|
|
42
50
|
}
|
|
@@ -71,7 +79,11 @@ async function auditStory(browser, base, story, plan) {
|
|
|
71
79
|
? await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level, scope: "#storybook-root" })
|
|
72
80
|
: tier === "interactions"
|
|
73
81
|
? NOT_APPLICABLE_FOR_PAGES
|
|
74
|
-
:
|
|
82
|
+
: tier === "computed"
|
|
83
|
+
? COMPUTED_NOT_APPLICABLE_FOR_PAGES
|
|
84
|
+
: tier === "conditions"
|
|
85
|
+
? CONDITIONS_NOT_APPLICABLE_FOR_STORIES
|
|
86
|
+
: await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
|
|
75
87
|
}
|
|
76
88
|
const hidden = notTestableEntries(await closedShadowHosts(opened.page));
|
|
77
89
|
return { id: story.id, ok: true, archetype: { status: "ran", configs: [{ libA11y: "n/a", tiers }] }, hidden };
|
|
@@ -217,14 +229,17 @@ export async function runPlan(plan, io) {
|
|
|
217
229
|
}
|
|
218
230
|
const targets = [];
|
|
219
231
|
const mappings = {};
|
|
232
|
+
/** Fixtures the tool generated, by path under the output folder. */
|
|
233
|
+
const generatedFiles = {};
|
|
220
234
|
for (const planTarget of plan.targets) {
|
|
221
|
-
const { result, mapping } = await runTarget(browser, planTarget, plan, io);
|
|
235
|
+
const { result, mapping, files } = await runTarget(browser, planTarget, plan, io);
|
|
222
236
|
targets.push(result);
|
|
237
|
+
if (files) Object.assign(generatedFiles, files);
|
|
223
238
|
if (mapping) {
|
|
224
239
|
mappings[planTarget.id] = mapping;
|
|
225
240
|
planTarget.mapping = mapping;
|
|
226
241
|
}
|
|
227
|
-
if (result.npm) planTarget.kind = result.npm.flavor
|
|
242
|
+
if (result.npm) planTarget.kind = adapterFor(result.npm.flavor).kind;
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
const results = parseResults({
|
|
@@ -240,6 +255,11 @@ export async function runPlan(plan, io) {
|
|
|
240
255
|
mkdirSync(outDir, { recursive: true });
|
|
241
256
|
writeFileSync(resolve(outDir, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
|
|
242
257
|
writeFileSync(resolve(outDir, "report.md"), renderReport({ plan, results }));
|
|
258
|
+
for (const [relative, text] of Object.entries(generatedFiles)) {
|
|
259
|
+
const file = resolve(outDir, relative);
|
|
260
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
261
|
+
writeFileSync(file, text);
|
|
262
|
+
}
|
|
243
263
|
if (Object.keys(mappings).length > 0) {
|
|
244
264
|
// The candidate mapping, in the shape --mapping reads, so it can be edited and passed back in.
|
|
245
265
|
writeFileSync(resolve(outDir, "mapping.json"), `${JSON.stringify(mappings, null, 2)}\n`);
|
package/src/run/summary.js
CHANGED
|
@@ -32,6 +32,16 @@ export function summarize(archetypes, engines, gaps = []) {
|
|
|
32
32
|
summary.interactions = { pass: 0, fail: 0, notApplicable: 0, error: 0 };
|
|
33
33
|
for (const check of checks) summary.interactions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
|
|
34
34
|
}
|
|
35
|
+
const measured = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.computed?.checks ?? []));
|
|
36
|
+
if (measured.length) {
|
|
37
|
+
summary.computed = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
|
|
38
|
+
for (const check of measured) summary.computed[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
|
|
39
|
+
}
|
|
40
|
+
const adapted = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.conditions?.checks ?? []));
|
|
41
|
+
if (adapted.length) {
|
|
42
|
+
summary.conditions = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
|
|
43
|
+
for (const check of adapted) summary.conditions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
|
|
44
|
+
}
|
|
35
45
|
const walks = Object.entries(archetypes).flatMap(([name, a]) => a.configs.map((c) => ({ name, vsr: c.tiers.vsr })).filter((x) => x.vsr?.status === "ran"));
|
|
36
46
|
if (walks.length) {
|
|
37
47
|
summary.vsr = { walks: walks.length, flagged: walks.reduce((n, w) => n + w.vsr.flags.length, 0) };
|
package/src/schema.js
CHANGED
|
@@ -2,20 +2,20 @@ import * as v from "valibot";
|
|
|
2
2
|
|
|
3
3
|
export const WCAG_VERSIONS = ["2.0", "2.1", "2.2"];
|
|
4
4
|
export const LEVELS = ["A", "AA", "AAA"];
|
|
5
|
-
export const TIERS = ["rules", "interactions", "vsr"];
|
|
5
|
+
export const TIERS = ["rules", "interactions", "computed", "conditions", "vsr"];
|
|
6
6
|
export const ENGINES = ["axe", "ibm"];
|
|
7
7
|
export const LIB_A11Y = ["on", "off"];
|
|
8
8
|
export const IMPACTS = ["minor", "moderate", "serious", "critical"];
|
|
9
9
|
export const TOOLKIT_LEVELS = [1, 2, 3];
|
|
10
10
|
export const FAIL_MODES = ["any", "all"];
|
|
11
|
-
export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "chart"];
|
|
12
|
-
export const FLAVORS = ["react", "wc"];
|
|
13
|
-
export const MAPPING_STATUSES = ["template", "authored", "needs-fixture", "no-match"];
|
|
11
|
+
export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "live-region", "chart"];
|
|
12
|
+
export const FLAVORS = ["react", "vue", "wc"];
|
|
13
|
+
export const MAPPING_STATUSES = ["template", "authored", "generated", "needs-fixture", "no-match"];
|
|
14
14
|
|
|
15
15
|
/** What a candidate mapping says about one archetype of one npm target. */
|
|
16
16
|
export const MappingEntrySchema = v.object({
|
|
17
17
|
flavor: v.optional(v.picklist(FLAVORS)),
|
|
18
|
-
/** Export name (React) the fixture or template uses. */
|
|
18
|
+
/** Export name (React or Vue) the fixture or template uses. */
|
|
19
19
|
export: v.optional(v.string()),
|
|
20
20
|
/** Custom element tag (web components) the fixture or template uses. */
|
|
21
21
|
tag: v.optional(v.string()),
|
|
@@ -23,7 +23,14 @@ export const MappingEntrySchema = v.object({
|
|
|
23
23
|
fixture: v.optional(v.nullable(v.string())),
|
|
24
24
|
/** The library ships opt-in accessibility features. The fixture gets `libA11y` (true or false) and `--lib-a11y` runs it both ways. */
|
|
25
25
|
libA11y: v.optional(v.boolean()),
|
|
26
|
+
/** Other packages to install beside the target, such as the token stylesheet or theme the library asks for. A fixture can then import them. */
|
|
27
|
+
install: v.optional(v.array(v.string())),
|
|
26
28
|
status: v.optional(v.picklist(MAPPING_STATUSES)),
|
|
29
|
+
/** For a generated fixture: which recipe worked, what it was, which parts it used, and where its source was written. */
|
|
30
|
+
recipe: v.optional(v.string()),
|
|
31
|
+
summary: v.optional(v.string()),
|
|
32
|
+
used: v.optional(v.array(v.string())),
|
|
33
|
+
generatedFile: v.optional(v.string()),
|
|
27
34
|
candidates: v.optional(v.array(v.string())),
|
|
28
35
|
parts: v.optional(v.array(v.string())),
|
|
29
36
|
reason: v.optional(v.string()),
|
|
@@ -42,7 +49,7 @@ export function parseMappingFile(input) {
|
|
|
42
49
|
return result.output;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
export const TARGET_KINDS = ["npm", "npm-react", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
|
|
52
|
+
export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
|
|
46
53
|
|
|
47
54
|
const nullableString = v.nullable(v.string());
|
|
48
55
|
|
|
@@ -77,6 +84,8 @@ export const PlanSchema = v.object({
|
|
|
77
84
|
archetypes: v.nullable(v.array(v.picklist(ARCHETYPES))),
|
|
78
85
|
mapping: nullableString,
|
|
79
86
|
maxStories: v.pipe(v.number(), v.integer(), v.minValue(1)),
|
|
87
|
+
/** Build fixtures from a package's parts when none is authored. A plan saved before this existed generates. */
|
|
88
|
+
generate: v.optional(v.boolean(), true),
|
|
80
89
|
out: v.string(),
|
|
81
90
|
fail: v.nullable(FailConfigSchema),
|
|
82
91
|
}),
|
|
@@ -136,10 +145,12 @@ const TierResultSchema = v.object({
|
|
|
136
145
|
v.object({
|
|
137
146
|
name: v.string(),
|
|
138
147
|
criteria: v.optional(v.array(v.string())),
|
|
139
|
-
result: v.picklist(["pass", "fail", "not-applicable", "error"]),
|
|
148
|
+
result: v.picklist(["pass", "fail", "undetermined", "not-applicable", "error"]),
|
|
140
149
|
detail: v.string(),
|
|
141
150
|
/** How the focus indicator was detected: computed-style or screenshot. */
|
|
142
151
|
method: v.optional(v.string()),
|
|
152
|
+
/** The numbers behind a computed check, for example contrast ratios by state. */
|
|
153
|
+
measurements: v.optional(v.array(v.record(v.string(), v.unknown()))),
|
|
143
154
|
}),
|
|
144
155
|
),
|
|
145
156
|
),
|
|
@@ -198,6 +209,17 @@ export const TargetResultSchema = v.object({
|
|
|
198
209
|
v.object({
|
|
199
210
|
status: v.picklist(["ran", "gap"]),
|
|
200
211
|
reason: v.optional(v.nullable(v.string())),
|
|
212
|
+
/** Where the fixture came from, and for a generated one what was tried. */
|
|
213
|
+
fixture: v.optional(
|
|
214
|
+
v.object({
|
|
215
|
+
source: v.picklist(["template", "authored", "generated", "none"]),
|
|
216
|
+
recipe: v.optional(v.string()),
|
|
217
|
+
summary: v.optional(v.string()),
|
|
218
|
+
used: v.optional(v.array(v.string())),
|
|
219
|
+
file: v.optional(v.nullable(v.string())),
|
|
220
|
+
attempts: v.optional(v.array(v.object({ recipe: v.string(), summary: v.string(), ok: v.boolean(), reason: v.nullable(v.string()) }))),
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
201
223
|
configs: v.array(v.object({ libA11y: v.picklist(["on", "off", "n/a"]), state: v.optional(v.string()), tiers: v.record(v.string(), TierResultSchema) })),
|
|
202
224
|
}),
|
|
203
225
|
),
|
|
@@ -205,11 +227,14 @@ export const TargetResultSchema = v.object({
|
|
|
205
227
|
npm: v.optional(
|
|
206
228
|
v.object({
|
|
207
229
|
name: v.string(),
|
|
230
|
+
/** The sub-path of the package that was tested, such as `button/v2`, or null for the package itself. */
|
|
231
|
+
subpath: v.optional(nullableString),
|
|
208
232
|
version: nullableString,
|
|
209
233
|
flavor: v.picklist(FLAVORS),
|
|
210
234
|
framework: nullableString,
|
|
211
235
|
react: nullableString,
|
|
212
236
|
reactDom: nullableString,
|
|
237
|
+
vue: v.optional(nullableString),
|
|
213
238
|
tags: v.array(v.string()),
|
|
214
239
|
}),
|
|
215
240
|
),
|
|
@@ -218,6 +243,8 @@ export const TargetResultSchema = v.object({
|
|
|
218
243
|
gaps: v.array(v.string()),
|
|
219
244
|
notTestable: v.array(v.string()),
|
|
220
245
|
interactions: v.optional(v.object({ pass: v.number(), fail: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
246
|
+
computed: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
247
|
+
conditions: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
221
248
|
vsr: v.optional(v.object({ walks: v.number(), flagged: v.number() })),
|
|
222
249
|
}),
|
|
223
250
|
warnings: v.array(v.string()),
|