@wcag-audit/cli 1.0.0-alpha.3
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/LICENSE +25 -0
- package/README.md +110 -0
- package/package.json +72 -0
- package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
- package/src/__tests__/sanity.test.js +7 -0
- package/src/ai-fix-json.js +321 -0
- package/src/audit.js +199 -0
- package/src/cache/route-cache.js +46 -0
- package/src/cache/route-cache.test.js +96 -0
- package/src/checkers/ai-vision.js +102 -0
- package/src/checkers/auth.js +98 -0
- package/src/checkers/axe.js +65 -0
- package/src/checkers/consistency.js +222 -0
- package/src/checkers/forms.js +149 -0
- package/src/checkers/interaction.js +142 -0
- package/src/checkers/keyboard.js +347 -0
- package/src/checkers/media.js +102 -0
- package/src/checkers/motion.js +155 -0
- package/src/checkers/pointer.js +128 -0
- package/src/checkers/screen-reader.js +522 -0
- package/src/checkers/viewport.js +202 -0
- package/src/cli.js +156 -0
- package/src/commands/ci.js +63 -0
- package/src/commands/ci.test.js +55 -0
- package/src/commands/doctor.js +105 -0
- package/src/commands/doctor.test.js +81 -0
- package/src/commands/init.js +126 -0
- package/src/commands/init.test.js +83 -0
- package/src/commands/scan.js +322 -0
- package/src/commands/scan.test.js +139 -0
- package/src/config/global.js +60 -0
- package/src/config/global.test.js +58 -0
- package/src/config/project.js +35 -0
- package/src/config/project.test.js +44 -0
- package/src/devserver/spawn.js +82 -0
- package/src/devserver/spawn.test.js +58 -0
- package/src/discovery/astro.js +86 -0
- package/src/discovery/astro.test.js +76 -0
- package/src/discovery/crawl.js +93 -0
- package/src/discovery/crawl.test.js +93 -0
- package/src/discovery/dynamic-samples.js +44 -0
- package/src/discovery/dynamic-samples.test.js +66 -0
- package/src/discovery/manual.js +38 -0
- package/src/discovery/manual.test.js +52 -0
- package/src/discovery/nextjs.js +136 -0
- package/src/discovery/nextjs.test.js +141 -0
- package/src/discovery/registry.js +80 -0
- package/src/discovery/registry.test.js +33 -0
- package/src/discovery/remix.js +82 -0
- package/src/discovery/remix.test.js +77 -0
- package/src/discovery/sitemap.js +73 -0
- package/src/discovery/sitemap.test.js +69 -0
- package/src/discovery/sveltekit.js +85 -0
- package/src/discovery/sveltekit.test.js +76 -0
- package/src/discovery/vite.js +94 -0
- package/src/discovery/vite.test.js +144 -0
- package/src/license/log-usage.js +23 -0
- package/src/license/log-usage.test.js +45 -0
- package/src/license/validate.js +58 -0
- package/src/license/validate.test.js +58 -0
- package/src/output/agents-md.js +58 -0
- package/src/output/agents-md.test.js +62 -0
- package/src/output/cursor-rules.js +57 -0
- package/src/output/cursor-rules.test.js +62 -0
- package/src/output/markdown.js +119 -0
- package/src/output/markdown.test.js +95 -0
- package/src/report.js +235 -0
- package/src/util/anthropic.js +25 -0
- package/src/util/llm.js +159 -0
- package/src/util/screenshot.js +131 -0
- package/src/wcag-criteria.js +256 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Viewport / CSS-injection checker. Covers:
|
|
2
|
+
//
|
|
3
|
+
// 1.4.4 Resize Text — emulate 200% browser zoom, look for content loss
|
|
4
|
+
// 1.4.10 Reflow — shrink viewport to 320 CSS px, check for h-scroll
|
|
5
|
+
// 1.4.12 Text Spacing — inject WCAG-required spacing CSS, check for clipped text
|
|
6
|
+
//
|
|
7
|
+
// Each test runs in a fresh page context built from the live URL so we
|
|
8
|
+
// don't pollute the main page state for downstream checkers.
|
|
9
|
+
|
|
10
|
+
import { saveScreenshot } from "../util/screenshot.js";
|
|
11
|
+
|
|
12
|
+
export async function runViewport(ctx) {
|
|
13
|
+
const findings = [];
|
|
14
|
+
|
|
15
|
+
// ── 1.4.10 Reflow ────────────────────────────────────────────────────
|
|
16
|
+
{
|
|
17
|
+
const sub = await ctx.context.newPage();
|
|
18
|
+
try {
|
|
19
|
+
await sub.setViewportSize({ width: 320, height: 256 });
|
|
20
|
+
await sub.goto(ctx.url, { waitUntil: "networkidle", timeout: 60_000 });
|
|
21
|
+
await sub.waitForTimeout(500);
|
|
22
|
+
const result = await sub.evaluate(() => {
|
|
23
|
+
return {
|
|
24
|
+
docWidth: document.documentElement.scrollWidth,
|
|
25
|
+
viewportWidth: window.innerWidth,
|
|
26
|
+
horizontalOverflowElems: (() => {
|
|
27
|
+
const out = [];
|
|
28
|
+
document.querySelectorAll("body *").forEach((el) => {
|
|
29
|
+
const r = el.getBoundingClientRect();
|
|
30
|
+
if (r.right > window.innerWidth + 5 && r.width > 50) {
|
|
31
|
+
out.push({
|
|
32
|
+
tag: el.tagName.toLowerCase(),
|
|
33
|
+
width: Math.round(r.width),
|
|
34
|
+
text: (el.innerText || "").trim().slice(0, 60),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return out.slice(0, 15);
|
|
39
|
+
})(),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
if (result.docWidth > result.viewportWidth + 5) {
|
|
43
|
+
const buf = await sub.screenshot({ fullPage: false, type: "png" });
|
|
44
|
+
const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "reflow_320");
|
|
45
|
+
findings.push({
|
|
46
|
+
source: "playwright",
|
|
47
|
+
ruleId: "viewport/reflow",
|
|
48
|
+
criteria: "1.4.10",
|
|
49
|
+
level: "AA",
|
|
50
|
+
impact: "serious",
|
|
51
|
+
description: `Page requires horizontal scrolling at 320 CSS px (document width = ${result.docWidth}px). WCAG 1.4.10 requires reflow without two-dimensional scrolling at this width.`,
|
|
52
|
+
help: "Make layouts responsive. Avoid fixed-pixel widths on containers, tables, and images. Use min-width: 0 on flex children, allow long words to break, and use overflow-wrap: anywhere.",
|
|
53
|
+
helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html",
|
|
54
|
+
selector: result.horizontalOverflowElems.map((e) => e.tag).slice(0, 5).join(", "),
|
|
55
|
+
evidence: JSON.stringify(result.horizontalOverflowElems, null, 2),
|
|
56
|
+
failureSummary: `${result.horizontalOverflowElems.length} elements overflow the 320px viewport`,
|
|
57
|
+
screenshotFile,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
await sub.close().catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 1.4.4 Resize Text (200% zoom) ────────────────────────────────────
|
|
66
|
+
{
|
|
67
|
+
const sub = await ctx.context.newPage();
|
|
68
|
+
try {
|
|
69
|
+
await sub.setViewportSize({ width: 1366, height: 900 });
|
|
70
|
+
await sub.goto(ctx.url, { waitUntil: "networkidle", timeout: 60_000 });
|
|
71
|
+
// Approximate 200% zoom by doubling the document fontSize and using
|
|
72
|
+
// CSS transform on body. Pure zoom would need DevTools Protocol; this
|
|
73
|
+
// catches most layout-loss bugs.
|
|
74
|
+
await sub.addStyleTag({
|
|
75
|
+
content: `html { font-size: 200% !important; } body { zoom: 2; }`,
|
|
76
|
+
});
|
|
77
|
+
await sub.waitForTimeout(400);
|
|
78
|
+
const overflow = await sub.evaluate(() => {
|
|
79
|
+
// Visually-hidden patterns (sr-only, screen-reader-only, etc.) are
|
|
80
|
+
// INTENDED to be 1x1px with clipped content — they're WCAG-approved
|
|
81
|
+
// for skip links and screen-reader-only instructions. Skip them.
|
|
82
|
+
function isVisuallyHidden(el, cs) {
|
|
83
|
+
const r = el.getBoundingClientRect();
|
|
84
|
+
if (r.width <= 2 && r.height <= 2) return true;
|
|
85
|
+
if (cs.clip && cs.clip !== "auto" && cs.clip !== "unset") return true;
|
|
86
|
+
if (cs.clipPath && cs.clipPath !== "none" && cs.clipPath.includes("inset(100%)")) return true;
|
|
87
|
+
const classes = el.className || "";
|
|
88
|
+
if (typeof classes === "string" && /\b(sr-only|visually-hidden|screen-reader|a11y-hidden)\b/.test(classes)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const out = [];
|
|
94
|
+
document.querySelectorAll("body *").forEach((el) => {
|
|
95
|
+
const cs = getComputedStyle(el);
|
|
96
|
+
if (isVisuallyHidden(el, cs)) return;
|
|
97
|
+
if (el.scrollHeight > el.clientHeight + 2 && cs.overflow === "hidden") {
|
|
98
|
+
out.push({
|
|
99
|
+
tag: el.tagName.toLowerCase(),
|
|
100
|
+
text: (el.innerText || "").trim().slice(0, 60),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return out.slice(0, 15);
|
|
105
|
+
});
|
|
106
|
+
if (overflow.length) {
|
|
107
|
+
const buf = await sub.screenshot({ fullPage: false, type: "png" });
|
|
108
|
+
const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "resize_text_200");
|
|
109
|
+
findings.push({
|
|
110
|
+
source: "playwright",
|
|
111
|
+
ruleId: "viewport/resize-text",
|
|
112
|
+
criteria: "1.4.4",
|
|
113
|
+
level: "AA",
|
|
114
|
+
impact: "serious",
|
|
115
|
+
description: `${overflow.length} elements have clipped content when text is doubled in size (overflow:hidden with scroll content).`,
|
|
116
|
+
help: "WCAG 1.4.4 requires that text be resizable up to 200% without loss of content or function. Avoid fixed heights on text containers. Use min-height + padding instead of height.",
|
|
117
|
+
helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html",
|
|
118
|
+
selector: overflow.map((e) => e.tag).slice(0, 5).join(", "),
|
|
119
|
+
evidence: JSON.stringify(overflow.slice(0, 10), null, 2),
|
|
120
|
+
failureSummary: `${overflow.length} clipped containers`,
|
|
121
|
+
screenshotFile,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
await sub.close().catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 1.4.12 Text Spacing ──────────────────────────────────────────────
|
|
130
|
+
{
|
|
131
|
+
const sub = await ctx.context.newPage();
|
|
132
|
+
try {
|
|
133
|
+
await sub.setViewportSize({ width: 1366, height: 900 });
|
|
134
|
+
await sub.goto(ctx.url, { waitUntil: "networkidle", timeout: 60_000 });
|
|
135
|
+
// Inject the WCAG 1.4.12 mandatory spacing values
|
|
136
|
+
await sub.addStyleTag({
|
|
137
|
+
content: `
|
|
138
|
+
* {
|
|
139
|
+
line-height: 1.5 !important;
|
|
140
|
+
letter-spacing: 0.12em !important;
|
|
141
|
+
word-spacing: 0.16em !important;
|
|
142
|
+
}
|
|
143
|
+
p { margin-bottom: 2em !important; }
|
|
144
|
+
`,
|
|
145
|
+
});
|
|
146
|
+
await sub.waitForTimeout(400);
|
|
147
|
+
const clipped = await sub.evaluate(() => {
|
|
148
|
+
// Skip visually-hidden (sr-only) patterns — they're intentionally
|
|
149
|
+
// clipped and WCAG-approved for skip links.
|
|
150
|
+
function isVisuallyHidden(el, cs) {
|
|
151
|
+
const r = el.getBoundingClientRect();
|
|
152
|
+
if (r.width <= 2 && r.height <= 2) return true;
|
|
153
|
+
if (cs.clip && cs.clip !== "auto" && cs.clip !== "unset") return true;
|
|
154
|
+
if (cs.clipPath && cs.clipPath !== "none" && cs.clipPath.includes("inset(100%)")) return true;
|
|
155
|
+
const classes = el.className || "";
|
|
156
|
+
if (typeof classes === "string" && /\b(sr-only|visually-hidden|screen-reader|a11y-hidden)\b/.test(classes)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const out = [];
|
|
162
|
+
document.querySelectorAll("body *").forEach((el) => {
|
|
163
|
+
const cs = getComputedStyle(el);
|
|
164
|
+
if (isVisuallyHidden(el, cs)) return;
|
|
165
|
+
if (
|
|
166
|
+
(cs.overflow === "hidden" || cs.overflowY === "hidden") &&
|
|
167
|
+
el.scrollHeight > el.clientHeight + 4 &&
|
|
168
|
+
(el.innerText || "").trim().length > 10
|
|
169
|
+
) {
|
|
170
|
+
out.push({
|
|
171
|
+
tag: el.tagName.toLowerCase(),
|
|
172
|
+
text: (el.innerText || "").trim().slice(0, 60),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return out.slice(0, 15);
|
|
177
|
+
});
|
|
178
|
+
if (clipped.length) {
|
|
179
|
+
const buf = await sub.screenshot({ fullPage: false, type: "png" });
|
|
180
|
+
const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "text_spacing");
|
|
181
|
+
findings.push({
|
|
182
|
+
source: "playwright",
|
|
183
|
+
ruleId: "viewport/text-spacing",
|
|
184
|
+
criteria: "1.4.12",
|
|
185
|
+
level: "AA",
|
|
186
|
+
impact: "serious",
|
|
187
|
+
description: `${clipped.length} elements clip their content when WCAG-required text-spacing values are applied (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em, paragraph spacing 2x).`,
|
|
188
|
+
help: "Remove fixed heights on text containers and avoid overflow:hidden on text. Allow text containers to grow with content.",
|
|
189
|
+
helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html",
|
|
190
|
+
selector: clipped.map((e) => e.tag).slice(0, 5).join(", "),
|
|
191
|
+
evidence: JSON.stringify(clipped.slice(0, 10), null, 2),
|
|
192
|
+
failureSummary: `${clipped.length} clipped containers under WCAG spacing`,
|
|
193
|
+
screenshotFile,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
await sub.close().catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { findings };
|
|
202
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI entry. Hosts multiple subcommands:
|
|
3
|
+
// wcag-audit init — interactive setup (~/.wcagauditrc)
|
|
4
|
+
// wcag-audit scan — audit current project (uses discovery)
|
|
5
|
+
// wcag-audit audit <url> — legacy single-URL mode (back-compat)
|
|
6
|
+
// wcag-audit config — view current config
|
|
7
|
+
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { runInit } from "./commands/init.js";
|
|
10
|
+
import { runScan } from "./commands/scan.js";
|
|
11
|
+
import { runCi, FAIL_ON_LEVELS } from "./commands/ci.js";
|
|
12
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
13
|
+
import { readGlobalConfig } from "./config/global.js";
|
|
14
|
+
import { runAudit } from "./audit.js";
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name("wcag-audit")
|
|
20
|
+
.description("WCAG 2.1/2.2 auditor for web projects — with AI-ready fixes for Cursor / Claude Code / Windsurf.")
|
|
21
|
+
.version("1.0.0-alpha.3");
|
|
22
|
+
|
|
23
|
+
// ── init ─────────────────────────────────────────────────────────
|
|
24
|
+
program
|
|
25
|
+
.command("init")
|
|
26
|
+
.description("Interactive setup — saves license key + optional AI config to ~/.wcagauditrc")
|
|
27
|
+
.action(async () => {
|
|
28
|
+
const result = await runInit();
|
|
29
|
+
if (!result.ok) process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── scan ─────────────────────────────────────────────────────────
|
|
33
|
+
program
|
|
34
|
+
.command("scan")
|
|
35
|
+
.description("Audit every route in the current project")
|
|
36
|
+
.option("--dry-run", "Preview credit cost only; do not actually scan", false)
|
|
37
|
+
.option("--no-ai", "Skip AI vision review", false)
|
|
38
|
+
.option("--no-cache", "Ignore cached results from previous scans and re-audit every route", false)
|
|
39
|
+
.option("--url <url>", "Scan a deployed site via BFS crawl instead of local project")
|
|
40
|
+
.option("--routes <file>", "Load routes from a plain text file (one per line)")
|
|
41
|
+
.option("--crawl-depth <n>", "Max BFS depth when using --url", "2")
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
const result = await runScan({
|
|
44
|
+
dryRun: !!opts.dryRun,
|
|
45
|
+
noAi: !opts.ai,
|
|
46
|
+
noCache: !opts.cache,
|
|
47
|
+
urlMode: opts.url || null,
|
|
48
|
+
routesFile: opts.routes || null,
|
|
49
|
+
crawlDepth: parseInt(opts.crawlDepth, 10) || 2,
|
|
50
|
+
});
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
console.error(`\n✗ ${result.error}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── config ───────────────────────────────────────────────────────
|
|
58
|
+
program
|
|
59
|
+
.command("config")
|
|
60
|
+
.description("Show current global config (~/.wcagauditrc)")
|
|
61
|
+
.action(async () => {
|
|
62
|
+
const cfg = await readGlobalConfig();
|
|
63
|
+
const redacted = {
|
|
64
|
+
...cfg,
|
|
65
|
+
licenseKey: cfg.licenseKey ? maskKey(cfg.licenseKey) : null,
|
|
66
|
+
ai: {
|
|
67
|
+
...cfg.ai,
|
|
68
|
+
apiKey: cfg.ai?.apiKey ? "***" : null,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
console.log(JSON.stringify(redacted, null, 2));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── ci ───────────────────────────────────────────────────────────
|
|
75
|
+
program
|
|
76
|
+
.command("ci")
|
|
77
|
+
.description("CI-optimized scan. Exit 1 when findings meet the --fail-on threshold.")
|
|
78
|
+
.option(
|
|
79
|
+
`--fail-on <level>`,
|
|
80
|
+
`Fail when an issue of this impact or higher is found. One of: ${FAIL_ON_LEVELS.join(", ")}`,
|
|
81
|
+
"critical",
|
|
82
|
+
)
|
|
83
|
+
.action(async (opts) => {
|
|
84
|
+
const result = await runCi({ failOn: opts.failOn });
|
|
85
|
+
process.exit(result.exitCode || 0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── doctor ───────────────────────────────────────────────────────
|
|
89
|
+
program
|
|
90
|
+
.command("doctor")
|
|
91
|
+
.description("Diagnose common setup issues (license, AI key, framework, dev script).")
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const result = await runDoctor();
|
|
94
|
+
if (!result.ok) process.exit(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── audit (legacy) ───────────────────────────────────────────────
|
|
98
|
+
program
|
|
99
|
+
.command("audit")
|
|
100
|
+
.description("Single-URL audit (legacy). Prefer `scan` for local projects.")
|
|
101
|
+
.argument("<url>", "URL of the page (or starting page) to audit")
|
|
102
|
+
.option("--version-wcag <v>", "WCAG version: 2.1 or 2.2", "2.2")
|
|
103
|
+
.option("--levels <levels>", "Comma list of conformance levels (A,AA,AAA)", "A,AA")
|
|
104
|
+
.option("--out <path>", "Output xlsx path", "./wcag-report.xlsx")
|
|
105
|
+
.option("--screenshots-dir <path>", "Where to save annotated PNGs", "./wcag-screenshots")
|
|
106
|
+
.option("--max-pages <n>", "Max pages to crawl for multi-page consistency checks", "5")
|
|
107
|
+
.option("--auth-storage <path>", "Path to a Playwright storageState.json")
|
|
108
|
+
.option("--ai", "Enable AI review for visual-judgment criteria", false)
|
|
109
|
+
.option("--ai-provider <provider>", "LLM provider: anthropic | openai | google", "anthropic")
|
|
110
|
+
.option("--ai-model <model>", "Model id")
|
|
111
|
+
.option("--ai-key <key>", "API key for the chosen provider")
|
|
112
|
+
.option("--headed", "Run Chromium with a visible window", false)
|
|
113
|
+
.option("--slow-mo <ms>", "Slow each Playwright action by N ms", "0")
|
|
114
|
+
.option("--skip <checkers>", "Comma list of checkers to skip")
|
|
115
|
+
.option("--include <checkers>", "Comma list of opt-in checkers to enable")
|
|
116
|
+
.option("--screen-reader", "Shortcut for --include screen-reader", false)
|
|
117
|
+
.action(async (url, opts) => {
|
|
118
|
+
try {
|
|
119
|
+
await runAudit({
|
|
120
|
+
url,
|
|
121
|
+
wcagVersion: opts.versionWcag,
|
|
122
|
+
levels: opts.levels.split(",").map((s) => s.trim().toUpperCase()),
|
|
123
|
+
outPath: opts.out,
|
|
124
|
+
screenshotsDir: opts.screenshotsDir,
|
|
125
|
+
maxPages: parseInt(opts.maxPages, 10),
|
|
126
|
+
authStorage: opts.authStorage || null,
|
|
127
|
+
aiEnabled: !!opts.ai,
|
|
128
|
+
aiProvider: opts.aiProvider || "anthropic",
|
|
129
|
+
aiModel:
|
|
130
|
+
opts.aiModel ||
|
|
131
|
+
({ anthropic: "claude-sonnet-4-6", openai: "gpt-4.1-mini", google: "gemini-2.5-flash" }[opts.aiProvider || "anthropic"]),
|
|
132
|
+
aiKey:
|
|
133
|
+
opts.aiKey ||
|
|
134
|
+
process.env[{ anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" }[opts.aiProvider || "anthropic"]] ||
|
|
135
|
+
null,
|
|
136
|
+
headed: !!opts.headed,
|
|
137
|
+
slowMo: parseInt(opts.slowMo, 10),
|
|
138
|
+
skip: (opts.skip || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
139
|
+
include: [
|
|
140
|
+
...((opts.include || "").split(",").map((s) => s.trim()).filter(Boolean)),
|
|
141
|
+
...(opts.screenReader ? ["screen-reader"] : []),
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error("\n✗ Audit failed:", err.message);
|
|
146
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function maskKey(key) {
|
|
152
|
+
if (!key || key.length < 12) return "***";
|
|
153
|
+
return key.slice(0, 9) + "…" + key.slice(-4);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { runScan } from "./scan.js";
|
|
2
|
+
|
|
3
|
+
export const FAIL_ON_LEVELS = ["critical", "serious", "moderate", "minor", "none"];
|
|
4
|
+
|
|
5
|
+
// Pure function so it's easy to test in isolation. The runtime runCi
|
|
6
|
+
// below wraps runScan and uses this to decide the exit code.
|
|
7
|
+
export function decideCiResult({ findings, failOn }) {
|
|
8
|
+
if (!FAIL_ON_LEVELS.includes(failOn)) {
|
|
9
|
+
throw new Error(`Invalid failOn value: ${failOn}. Must be one of: ${FAIL_ON_LEVELS.join(", ")}`);
|
|
10
|
+
}
|
|
11
|
+
const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
12
|
+
for (const f of findings) {
|
|
13
|
+
const key = FAIL_ON_LEVELS.includes(f.impact) ? f.impact : "minor";
|
|
14
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
15
|
+
}
|
|
16
|
+
let shouldFail = false;
|
|
17
|
+
if (failOn !== "none") {
|
|
18
|
+
const threshold = FAIL_ON_LEVELS.indexOf(failOn);
|
|
19
|
+
for (let i = 0; i < threshold + 1; i++) {
|
|
20
|
+
if (counts[FAIL_ON_LEVELS[i]] > 0) {
|
|
21
|
+
shouldFail = true;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const total = findings.length;
|
|
27
|
+
const summary = [
|
|
28
|
+
`${total} issues found`,
|
|
29
|
+
`(${counts.critical} critical, ${counts.serious} serious, ${counts.moderate} moderate, ${counts.minor} minor)`,
|
|
30
|
+
].join(" ");
|
|
31
|
+
return { shouldFail, exitCode: shouldFail ? 1 : 0, counts, summary };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Runtime wrapper — CI-flavored runScan: no spinners, structured logs,
|
|
35
|
+
// machine-readable exit code.
|
|
36
|
+
export async function runCi({ cwd = process.cwd(), failOn = "critical", log = console.log } = {}) {
|
|
37
|
+
const result = await runScan({
|
|
38
|
+
cwd,
|
|
39
|
+
dryRun: false,
|
|
40
|
+
noAi: true, // CI runs should be fast + deterministic
|
|
41
|
+
noCache: false,
|
|
42
|
+
log,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
return { ok: false, error: result.error, exitCode: 1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const decision = decideCiResult({
|
|
50
|
+
findings: result.findings || [],
|
|
51
|
+
failOn,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
log("");
|
|
55
|
+
log(`[wcag-audit ci] ${decision.summary}`);
|
|
56
|
+
if (decision.shouldFail) {
|
|
57
|
+
log(`[wcag-audit ci] ❌ Failing build — impact ≥ ${failOn}`);
|
|
58
|
+
} else {
|
|
59
|
+
log(`[wcag-audit ci] ✅ Passing — no impact ≥ ${failOn}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ok: true, exitCode: decision.exitCode, ...decision };
|
|
63
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { decideCiResult, FAIL_ON_LEVELS } from "./ci.js";
|
|
3
|
+
|
|
4
|
+
describe("decideCiResult", () => {
|
|
5
|
+
it("passes when there are zero findings", () => {
|
|
6
|
+
const r = decideCiResult({ findings: [], failOn: "critical" });
|
|
7
|
+
expect(r.shouldFail).toBe(false);
|
|
8
|
+
expect(r.exitCode).toBe(0);
|
|
9
|
+
expect(r.summary).toMatch(/0 issues/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("fails when critical findings exist and threshold is critical", () => {
|
|
13
|
+
const r = decideCiResult({
|
|
14
|
+
findings: [{ impact: "critical" }, { impact: "minor" }],
|
|
15
|
+
failOn: "critical",
|
|
16
|
+
});
|
|
17
|
+
expect(r.shouldFail).toBe(true);
|
|
18
|
+
expect(r.exitCode).toBe(1);
|
|
19
|
+
expect(r.summary).toMatch(/critical/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("fails when serious findings exist and threshold is serious", () => {
|
|
23
|
+
const r = decideCiResult({
|
|
24
|
+
findings: [{ impact: "serious" }],
|
|
25
|
+
failOn: "serious",
|
|
26
|
+
});
|
|
27
|
+
expect(r.shouldFail).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does NOT fail on serious findings when threshold is critical", () => {
|
|
31
|
+
const r = decideCiResult({
|
|
32
|
+
findings: [{ impact: "serious" }, { impact: "minor" }],
|
|
33
|
+
failOn: "critical",
|
|
34
|
+
});
|
|
35
|
+
expect(r.shouldFail).toBe(false);
|
|
36
|
+
expect(r.exitCode).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("never fails when threshold is none", () => {
|
|
40
|
+
const r = decideCiResult({
|
|
41
|
+
findings: [{ impact: "critical" }],
|
|
42
|
+
failOn: "none",
|
|
43
|
+
});
|
|
44
|
+
expect(r.shouldFail).toBe(false);
|
|
45
|
+
expect(r.exitCode).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects unknown failOn value", () => {
|
|
49
|
+
expect(() => decideCiResult({ findings: [], failOn: "bogus" })).toThrow(/failOn/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("exposes the accepted levels for the CLI help text", () => {
|
|
53
|
+
expect(FAIL_ON_LEVELS).toEqual(["critical", "serious", "moderate", "minor", "none"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { readGlobalConfig } from "../config/global.js";
|
|
5
|
+
import { validateLicense } from "../license/validate.js";
|
|
6
|
+
import { detectFramework } from "../discovery/registry.js";
|
|
7
|
+
|
|
8
|
+
const CLI_VERSION = "1.0.0-alpha.3";
|
|
9
|
+
|
|
10
|
+
export async function runDoctor({ cwd = process.cwd(), log = console.log } = {}) {
|
|
11
|
+
const checks = [];
|
|
12
|
+
|
|
13
|
+
// ── License ──────────────────────────────────────────────────────
|
|
14
|
+
const globalCfg = await readGlobalConfig();
|
|
15
|
+
if (!globalCfg.licenseKey) {
|
|
16
|
+
checks.push({
|
|
17
|
+
name: "license",
|
|
18
|
+
status: "fail",
|
|
19
|
+
message: "No license key found in ~/.wcagauditrc. Run `npx wcag-audit init` to set one.",
|
|
20
|
+
});
|
|
21
|
+
} else {
|
|
22
|
+
const license = await validateLicense(globalCfg.licenseKey, {
|
|
23
|
+
machineId: randomUUID(),
|
|
24
|
+
source: "cli",
|
|
25
|
+
version: CLI_VERSION,
|
|
26
|
+
});
|
|
27
|
+
if (!license.valid) {
|
|
28
|
+
checks.push({
|
|
29
|
+
name: "license",
|
|
30
|
+
status: "fail",
|
|
31
|
+
message: `License validation failed: ${license.error}`,
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
const credits = license.creditsRemaining?.total ?? "unlimited";
|
|
35
|
+
checks.push({
|
|
36
|
+
name: "license",
|
|
37
|
+
status: "pass",
|
|
38
|
+
message: `${license.tier} plan, ${credits} credits remaining`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── AI key (optional) ───────────────────────────────────────────
|
|
44
|
+
if (globalCfg.ai?.enabled) {
|
|
45
|
+
if (!globalCfg.ai.apiKey) {
|
|
46
|
+
checks.push({
|
|
47
|
+
name: "ai-key",
|
|
48
|
+
status: "fail",
|
|
49
|
+
message: "AI review is enabled but no API key is configured. Re-run `wcag-audit init`.",
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
checks.push({
|
|
53
|
+
name: "ai-key",
|
|
54
|
+
status: "pass",
|
|
55
|
+
message: `${globalCfg.ai.provider} key present`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Framework ────────────────────────────────────────────────────
|
|
61
|
+
const det = await detectFramework(cwd);
|
|
62
|
+
if (!det) {
|
|
63
|
+
checks.push({
|
|
64
|
+
name: "framework",
|
|
65
|
+
status: "fail",
|
|
66
|
+
message:
|
|
67
|
+
"Could not detect a supported framework. Supported: Next.js, Vite+React Router, SvelteKit, Remix, Astro. Use --url or --routes for deployed sites / custom setups.",
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
checks.push({
|
|
71
|
+
name: "framework",
|
|
72
|
+
status: "pass",
|
|
73
|
+
message: `${det.framework} detected (${det.strategy})`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Dev script ───────────────────────────────────────────────────
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
80
|
+
const hasDev = pkg.scripts && (pkg.scripts.dev || pkg.scripts.start);
|
|
81
|
+
checks.push({
|
|
82
|
+
name: "dev-script",
|
|
83
|
+
status: hasDev ? "pass" : "fail",
|
|
84
|
+
message: hasDev
|
|
85
|
+
? `\`${pkg.scripts.dev ? "npm run dev" : "npm run start"}\` will be used to start the dev server`
|
|
86
|
+
: "No `dev` or `start` script in package.json. Add one or set devServer.command in .wcagauditrc.",
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
// package.json doesn't exist — already failed on framework check
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Print results ────────────────────────────────────────────────
|
|
93
|
+
log("");
|
|
94
|
+
log("wcag-audit doctor");
|
|
95
|
+
log("─────────────────────────────");
|
|
96
|
+
for (const c of checks) {
|
|
97
|
+
const icon = c.status === "pass" ? "✓" : "✗";
|
|
98
|
+
log(`${icon} ${c.name.padEnd(14)} ${c.message}`);
|
|
99
|
+
}
|
|
100
|
+
log("");
|
|
101
|
+
const failing = checks.filter((c) => c.status === "fail");
|
|
102
|
+
const ok = failing.length === 0;
|
|
103
|
+
log(ok ? "All checks passed." : `${failing.length} check(s) failed. Fix these before running scan.`);
|
|
104
|
+
return { ok, checks };
|
|
105
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { runDoctor } from "./doctor.js";
|
|
6
|
+
|
|
7
|
+
let tmpHome;
|
|
8
|
+
let projDir;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tmpHome = await mkdtemp(join(tmpdir(), "wcagdoc-home-"));
|
|
12
|
+
projDir = await mkdtemp(join(tmpdir(), "wcagdoc-proj-"));
|
|
13
|
+
process.env.HOME = tmpHome;
|
|
14
|
+
global.fetch = vi.fn();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(tmpHome, { recursive: true, force: true });
|
|
19
|
+
await rm(projDir, { recursive: true, force: true });
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("runDoctor", () => {
|
|
24
|
+
it("reports missing license when no global config exists", async () => {
|
|
25
|
+
const logs = [];
|
|
26
|
+
const result = await runDoctor({ cwd: projDir, log: (m) => logs.push(m) });
|
|
27
|
+
expect(result.ok).toBe(false);
|
|
28
|
+
expect(result.checks.find((c) => c.name === "license")?.status).toBe("fail");
|
|
29
|
+
const joined = logs.join("\n");
|
|
30
|
+
expect(joined).toMatch(/✗ license/);
|
|
31
|
+
expect(joined).toMatch(/wcag-audit init/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("marks license pass when config + validation succeed", async () => {
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(tmpHome, ".wcagauditrc"),
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
39
|
+
ai: { enabled: false, provider: "anthropic", apiKey: null },
|
|
40
|
+
}),
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
global.fetch.mockResolvedValue({
|
|
44
|
+
ok: true,
|
|
45
|
+
json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
|
|
46
|
+
});
|
|
47
|
+
const result = await runDoctor({ cwd: projDir, log: () => {} });
|
|
48
|
+
expect(result.checks.find((c) => c.name === "license")?.status).toBe("pass");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reports missing framework when cwd is not a Next.js project", async () => {
|
|
52
|
+
await writeFile(
|
|
53
|
+
join(tmpHome, ".wcagauditrc"),
|
|
54
|
+
JSON.stringify({ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC", ai: { enabled: false } }),
|
|
55
|
+
"utf8",
|
|
56
|
+
);
|
|
57
|
+
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ valid: true, tier: "pro" }) });
|
|
58
|
+
const result = await runDoctor({ cwd: projDir, log: () => {} });
|
|
59
|
+
expect(result.checks.find((c) => c.name === "framework")?.status).toBe("fail");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reports framework pass when cwd is a Next.js app", async () => {
|
|
63
|
+
await writeFile(
|
|
64
|
+
join(tmpHome, ".wcagauditrc"),
|
|
65
|
+
JSON.stringify({ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC", ai: { enabled: false } }),
|
|
66
|
+
"utf8",
|
|
67
|
+
);
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(projDir, "package.json"),
|
|
70
|
+
JSON.stringify({ dependencies: { next: "15.0.0" }, scripts: { dev: "next dev" } }),
|
|
71
|
+
"utf8",
|
|
72
|
+
);
|
|
73
|
+
const { mkdir } = await import("fs/promises");
|
|
74
|
+
await mkdir(join(projDir, "app"), { recursive: true });
|
|
75
|
+
await writeFile(join(projDir, "app", "page.tsx"), "// test", "utf8");
|
|
76
|
+
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ valid: true, tier: "pro" }) });
|
|
77
|
+
const result = await runDoctor({ cwd: projDir, log: () => {} });
|
|
78
|
+
expect(result.checks.find((c) => c.name === "framework")?.status).toBe("pass");
|
|
79
|
+
expect(result.checks.find((c) => c.name === "dev-script")?.status).toBe("pass");
|
|
80
|
+
});
|
|
81
|
+
});
|