@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.
Files changed (71) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +72 -0
  4. package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
  5. package/src/__tests__/sanity.test.js +7 -0
  6. package/src/ai-fix-json.js +321 -0
  7. package/src/audit.js +199 -0
  8. package/src/cache/route-cache.js +46 -0
  9. package/src/cache/route-cache.test.js +96 -0
  10. package/src/checkers/ai-vision.js +102 -0
  11. package/src/checkers/auth.js +98 -0
  12. package/src/checkers/axe.js +65 -0
  13. package/src/checkers/consistency.js +222 -0
  14. package/src/checkers/forms.js +149 -0
  15. package/src/checkers/interaction.js +142 -0
  16. package/src/checkers/keyboard.js +347 -0
  17. package/src/checkers/media.js +102 -0
  18. package/src/checkers/motion.js +155 -0
  19. package/src/checkers/pointer.js +128 -0
  20. package/src/checkers/screen-reader.js +522 -0
  21. package/src/checkers/viewport.js +202 -0
  22. package/src/cli.js +156 -0
  23. package/src/commands/ci.js +63 -0
  24. package/src/commands/ci.test.js +55 -0
  25. package/src/commands/doctor.js +105 -0
  26. package/src/commands/doctor.test.js +81 -0
  27. package/src/commands/init.js +126 -0
  28. package/src/commands/init.test.js +83 -0
  29. package/src/commands/scan.js +322 -0
  30. package/src/commands/scan.test.js +139 -0
  31. package/src/config/global.js +60 -0
  32. package/src/config/global.test.js +58 -0
  33. package/src/config/project.js +35 -0
  34. package/src/config/project.test.js +44 -0
  35. package/src/devserver/spawn.js +82 -0
  36. package/src/devserver/spawn.test.js +58 -0
  37. package/src/discovery/astro.js +86 -0
  38. package/src/discovery/astro.test.js +76 -0
  39. package/src/discovery/crawl.js +93 -0
  40. package/src/discovery/crawl.test.js +93 -0
  41. package/src/discovery/dynamic-samples.js +44 -0
  42. package/src/discovery/dynamic-samples.test.js +66 -0
  43. package/src/discovery/manual.js +38 -0
  44. package/src/discovery/manual.test.js +52 -0
  45. package/src/discovery/nextjs.js +136 -0
  46. package/src/discovery/nextjs.test.js +141 -0
  47. package/src/discovery/registry.js +80 -0
  48. package/src/discovery/registry.test.js +33 -0
  49. package/src/discovery/remix.js +82 -0
  50. package/src/discovery/remix.test.js +77 -0
  51. package/src/discovery/sitemap.js +73 -0
  52. package/src/discovery/sitemap.test.js +69 -0
  53. package/src/discovery/sveltekit.js +85 -0
  54. package/src/discovery/sveltekit.test.js +76 -0
  55. package/src/discovery/vite.js +94 -0
  56. package/src/discovery/vite.test.js +144 -0
  57. package/src/license/log-usage.js +23 -0
  58. package/src/license/log-usage.test.js +45 -0
  59. package/src/license/validate.js +58 -0
  60. package/src/license/validate.test.js +58 -0
  61. package/src/output/agents-md.js +58 -0
  62. package/src/output/agents-md.test.js +62 -0
  63. package/src/output/cursor-rules.js +57 -0
  64. package/src/output/cursor-rules.test.js +62 -0
  65. package/src/output/markdown.js +119 -0
  66. package/src/output/markdown.test.js +95 -0
  67. package/src/report.js +235 -0
  68. package/src/util/anthropic.js +25 -0
  69. package/src/util/llm.js +159 -0
  70. package/src/util/screenshot.js +131 -0
  71. package/src/wcag-criteria.js +256 -0
@@ -0,0 +1,58 @@
1
+ const LICENSE_KEY_RE = /^WCAG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
2
+
3
+ const DEFAULT_API_URL = "https://api.wcagaudit.io/v1/license/validate";
4
+
5
+ export function isValidLicenseFormat(key) {
6
+ return typeof key === "string" && LICENSE_KEY_RE.test(key);
7
+ }
8
+
9
+ export async function validateLicense(key, { machineId, source = "cli", version = "1.0.0-alpha.1", apiUrl } = {}) {
10
+ const normalized = (key || "").trim().toUpperCase();
11
+
12
+ if (!isValidLicenseFormat(normalized)) {
13
+ return {
14
+ valid: false,
15
+ tier: "free",
16
+ error: "Invalid license key format. Expected WCAG-XXXX-XXXX-XXXX-XXXX.",
17
+ };
18
+ }
19
+
20
+ const url = apiUrl || process.env.WCAG_LICENSE_VALIDATE_URL || DEFAULT_API_URL;
21
+
22
+ try {
23
+ const res = await fetch(url, {
24
+ method: "POST",
25
+ headers: { "content-type": "application/json" },
26
+ body: JSON.stringify({
27
+ key: normalized,
28
+ machineId,
29
+ version,
30
+ source,
31
+ }),
32
+ });
33
+
34
+ const body = await res.json().catch(() => ({}));
35
+
36
+ if (!res.ok) {
37
+ return {
38
+ valid: false,
39
+ tier: body.tier || "free",
40
+ error: body.error || `License server returned ${res.status}`,
41
+ };
42
+ }
43
+
44
+ return {
45
+ valid: !!body.valid,
46
+ tier: body.tier || "free",
47
+ expiresAt: body.expiresAt || null,
48
+ creditsRemaining: body.creditsRemaining || null,
49
+ features: body.features || [],
50
+ };
51
+ } catch (err) {
52
+ return {
53
+ valid: false,
54
+ tier: "free",
55
+ error: `Network error: ${err.message}`,
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { validateLicense } from "./validate.js";
3
+
4
+ describe("validateLicense", () => {
5
+ beforeEach(() => {
6
+ global.fetch = vi.fn();
7
+ });
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("rejects invalid format without hitting the network", async () => {
13
+ const result = await validateLicense("not-a-key", { machineId: "m1" });
14
+ expect(result.valid).toBe(false);
15
+ expect(result.error).toMatch(/format/i);
16
+ expect(global.fetch).not.toHaveBeenCalled();
17
+ });
18
+
19
+ it("returns valid result for accepted license", async () => {
20
+ global.fetch.mockResolvedValueOnce({
21
+ ok: true,
22
+ json: async () => ({
23
+ valid: true,
24
+ tier: "pro",
25
+ expiresAt: null,
26
+ creditsRemaining: { server: 153, total: 153 },
27
+ }),
28
+ });
29
+ const result = await validateLicense("WCAG-TEST-AAAA-BBBB-CCCC", {
30
+ machineId: "m1",
31
+ });
32
+ expect(result.valid).toBe(true);
33
+ expect(result.tier).toBe("pro");
34
+ expect(result.creditsRemaining.total).toBe(153);
35
+ });
36
+
37
+ it("returns invalid result when API rejects the key", async () => {
38
+ global.fetch.mockResolvedValueOnce({
39
+ ok: false,
40
+ status: 403,
41
+ json: async () => ({ valid: false, error: "License revoked" }),
42
+ });
43
+ const result = await validateLicense("WCAG-TEST-AAAA-BBBB-CCCC", {
44
+ machineId: "m1",
45
+ });
46
+ expect(result.valid).toBe(false);
47
+ expect(result.error).toBe("License revoked");
48
+ });
49
+
50
+ it("returns network-error result when fetch throws", async () => {
51
+ global.fetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
52
+ const result = await validateLicense("WCAG-TEST-AAAA-BBBB-CCCC", {
53
+ machineId: "m1",
54
+ });
55
+ expect(result.valid).toBe(false);
56
+ expect(result.error).toMatch(/network|econnrefused/i);
57
+ });
58
+ });
@@ -0,0 +1,58 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ // Markers let us find and rewrite exactly our section without
5
+ // disturbing hand-written content before/after.
6
+ export const WCAG_SECTION_START = "<!-- wcag-audit:start -->";
7
+ export const WCAG_SECTION_END = "<!-- wcag-audit:end -->";
8
+
9
+ function buildSection(body) {
10
+ return [
11
+ WCAG_SECTION_START,
12
+ "## WCAG Accessibility Fixes",
13
+ "",
14
+ "The latest @wcagaudit/cli scan produced the fixes below.",
15
+ "Apply them when editing the affected files.",
16
+ "",
17
+ body.trimEnd(),
18
+ "",
19
+ WCAG_SECTION_END,
20
+ ].join("\n");
21
+ }
22
+
23
+ export async function upsertAgentsMd(projectDir, body) {
24
+ const path = join(projectDir, "AGENTS.md");
25
+ let current = "";
26
+ try {
27
+ current = await readFile(path, "utf8");
28
+ } catch {
29
+ // no existing file — we'll create one
30
+ }
31
+
32
+ const wantsRemoval = body === "";
33
+ const startIdx = current.indexOf(WCAG_SECTION_START);
34
+ const endIdx = current.indexOf(WCAG_SECTION_END);
35
+
36
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
37
+ const before = current.slice(0, startIdx).trimEnd();
38
+ const after = current.slice(endIdx + WCAG_SECTION_END.length).trimStart();
39
+ if (wantsRemoval) {
40
+ const merged = [before, after].filter(Boolean).join("\n\n").trimEnd() + "\n";
41
+ await writeFile(path, merged, "utf8");
42
+ return;
43
+ }
44
+ const merged = [before, buildSection(body), after].filter(Boolean).join("\n\n").trimEnd() + "\n";
45
+ await writeFile(path, merged, "utf8");
46
+ return;
47
+ }
48
+
49
+ if (wantsRemoval) {
50
+ // Nothing to remove — leave the file alone.
51
+ return;
52
+ }
53
+
54
+ const next = current.trim().length
55
+ ? current.trimEnd() + "\n\n" + buildSection(body) + "\n"
56
+ : buildSection(body) + "\n";
57
+ await writeFile(path, next, "utf8");
58
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { upsertAgentsMd, WCAG_SECTION_START, WCAG_SECTION_END } from "./agents-md.js";
6
+
7
+ let dir;
8
+
9
+ beforeEach(async () => {
10
+ dir = await mkdtemp(join(tmpdir(), "agentsmd-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("upsertAgentsMd", () => {
18
+ it("creates AGENTS.md when it does not exist", async () => {
19
+ await upsertAgentsMd(dir, "- Issue A\n- Issue B\n");
20
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
21
+ expect(raw).toMatch(WCAG_SECTION_START);
22
+ expect(raw).toMatch(/Issue A/);
23
+ expect(raw).toMatch(WCAG_SECTION_END);
24
+ });
25
+
26
+ it("appends the WCAG section to an existing AGENTS.md", async () => {
27
+ await writeFile(join(dir, "AGENTS.md"), "# Project\n\nExisting content.\n", "utf8");
28
+ await upsertAgentsMd(dir, "- Issue X\n");
29
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
30
+ expect(raw).toMatch(/Existing content/);
31
+ expect(raw).toMatch(WCAG_SECTION_START);
32
+ expect(raw).toMatch(/Issue X/);
33
+ });
34
+
35
+ it("replaces the WCAG section in-place when it already exists", async () => {
36
+ await writeFile(
37
+ join(dir, "AGENTS.md"),
38
+ `# Project\n\n${WCAG_SECTION_START}\n- Old issue\n${WCAG_SECTION_END}\n\nTrailing content.`,
39
+ "utf8",
40
+ );
41
+ await upsertAgentsMd(dir, "- Fresh issue\n");
42
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
43
+ expect(raw).not.toMatch(/Old issue/);
44
+ expect(raw).toMatch(/Fresh issue/);
45
+ expect(raw).toMatch(/Trailing content/);
46
+ // Single occurrence of the markers
47
+ expect(raw.match(new RegExp(WCAG_SECTION_START, "g"))?.length).toBe(1);
48
+ });
49
+
50
+ it("removes the WCAG section when body is empty string", async () => {
51
+ await writeFile(
52
+ join(dir, "AGENTS.md"),
53
+ `# Project\n\n${WCAG_SECTION_START}\n- Old\n${WCAG_SECTION_END}\n\nDone.\n`,
54
+ "utf8",
55
+ );
56
+ await upsertAgentsMd(dir, "");
57
+ const raw = await readFile(join(dir, "AGENTS.md"), "utf8");
58
+ expect(raw).not.toMatch(WCAG_SECTION_START);
59
+ expect(raw).not.toMatch(WCAG_SECTION_END);
60
+ expect(raw).toMatch(/Done\./);
61
+ });
62
+ });
@@ -0,0 +1,57 @@
1
+ // Renders `.cursor/rules/wcag-fixes.mdc` — Cursor's native rule format.
2
+ // When a user edits any file under the globs, Cursor auto-attaches the
3
+ // rule as context, so the agent "knows" about the WCAG issues in that
4
+ // file without needing a separate prompt.
5
+
6
+ export function renderCursorRules({ findings, projectName }) {
7
+ const frontMatter = [
8
+ "---",
9
+ "description: WCAG accessibility fixes generated by @wcagaudit/cli",
10
+ `globs: ${JSON.stringify(["src/app/**/*.tsx", "src/app/**/*.ts", "src/components/**/*.tsx"])}`,
11
+ "alwaysApply: false",
12
+ "---",
13
+ "",
14
+ ].join("\n");
15
+
16
+ if (!findings || findings.length === 0) {
17
+ return (
18
+ frontMatter +
19
+ `# WCAG Fixes (${projectName})\n\n✅ No outstanding WCAG issues found in the last scan.\n`
20
+ );
21
+ }
22
+
23
+ // Group findings by source file so the rule is contextually useful
24
+ // when the agent is editing one of those files.
25
+ const byFile = new Map();
26
+ for (const f of findings) {
27
+ const key = f.sourceFile || "(unknown)";
28
+ if (!byFile.has(key)) byFile.set(key, []);
29
+ byFile.get(key).push(f);
30
+ }
31
+
32
+ const lines = [
33
+ frontMatter,
34
+ `# WCAG Fixes (${projectName})`,
35
+ "",
36
+ "When editing any file listed below, apply the recommended accessibility fix.",
37
+ "Each item has the WCAG criterion, impact level, and specific remediation.",
38
+ "",
39
+ ];
40
+
41
+ for (const [file, items] of byFile.entries()) {
42
+ lines.push(`## ${file}`);
43
+ lines.push("");
44
+ for (const f of items) {
45
+ const criteriaList = Array.isArray(f.criteria) ? f.criteria : f.criteria ? [f.criteria] : [];
46
+ const crit = criteriaList.join(", ") || "?";
47
+ lines.push(`- **${f.description || f.ruleId}** — ${crit} (${f.level || "?"}, ${f.impact || "minor"})`);
48
+ if (f.help) lines.push(` - Fix: ${f.help}`);
49
+ if (f.evidence) lines.push(` - Evidence: \`${String(f.evidence).replace(/`/g, "'").slice(0, 120)}\``);
50
+ if (f.route) lines.push(` - Observed on route: \`${f.route}\``);
51
+ if (f.helpUrl) lines.push(` - Reference: ${f.helpUrl}`);
52
+ }
53
+ lines.push("");
54
+ }
55
+
56
+ return lines.join("\n");
57
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderCursorRules } from "./cursor-rules.js";
3
+
4
+ describe("renderCursorRules", () => {
5
+ const findings = [
6
+ {
7
+ source: "axe",
8
+ ruleId: "image-alt",
9
+ criteria: ["1.1.1"],
10
+ level: "A",
11
+ impact: "critical",
12
+ description: "Images must have alternate text",
13
+ help: "Add an alt attribute.",
14
+ helpUrl: "https://example/axe/image-alt",
15
+ selector: "img.hero",
16
+ evidence: "<img src='/x.png'>",
17
+ route: "/",
18
+ sourceFile: "src/app/page.tsx",
19
+ },
20
+ {
21
+ source: "playwright",
22
+ ruleId: "kbd/focus-visible",
23
+ criteria: "2.4.7",
24
+ level: "AA",
25
+ impact: "serious",
26
+ description: "No visible focus indicator.",
27
+ help: "Add :focus-visible styles.",
28
+ helpUrl: "https://example/focus-visible",
29
+ selector: "button",
30
+ evidence: "<button/>",
31
+ route: "/about",
32
+ sourceFile: "src/app/about/page.tsx",
33
+ },
34
+ ];
35
+
36
+ it("includes Cursor MDC front-matter with globs", () => {
37
+ const out = renderCursorRules({ findings, projectName: "demo" });
38
+ expect(out).toMatch(/^---\n/);
39
+ expect(out).toMatch(/description: WCAG accessibility fixes/);
40
+ expect(out).toMatch(/globs: \["src\/app\/\*\*\/\*.tsx","src\/app\/\*\*\/\*.ts","src\/components\/\*\*\/\*.tsx"\]/);
41
+ expect(out).toMatch(/alwaysApply: false/);
42
+ });
43
+
44
+ it("returns a minimal no-issues rule when findings is empty", () => {
45
+ const out = renderCursorRules({ findings: [], projectName: "demo" });
46
+ expect(out).toMatch(/^---\n/);
47
+ expect(out).toMatch(/No outstanding WCAG issues found/);
48
+ });
49
+
50
+ it("groups findings by source file so Cursor attaches the rule contextually", () => {
51
+ const out = renderCursorRules({ findings, projectName: "demo" });
52
+ expect(out).toMatch(/src\/app\/page\.tsx/);
53
+ expect(out).toMatch(/src\/app\/about\/page\.tsx/);
54
+ expect(out.indexOf("src/app/page.tsx")).toBeLessThan(out.indexOf("src/app/about/page.tsx"));
55
+ });
56
+
57
+ it("tags each finding with its criterion + impact", () => {
58
+ const out = renderCursorRules({ findings, projectName: "demo" });
59
+ expect(out).toMatch(/1\.1\.1 \(A, critical\)/);
60
+ expect(out).toMatch(/2\.4\.7 \(AA, serious\)/);
61
+ });
62
+ });
@@ -0,0 +1,119 @@
1
+ // Renders findings as an AI-ready markdown document. The format is
2
+ // designed for Cursor / Claude Code / Windsurf — each finding has
3
+ // enough context (route, file, snippet, criterion, fix hint) for an
4
+ // AI agent to open the file and apply the fix directly.
5
+
6
+ const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"];
7
+
8
+ export function renderFindingsMarkdown({
9
+ projectName,
10
+ generator,
11
+ totalPages,
12
+ wcagVersion,
13
+ levels,
14
+ findings,
15
+ }) {
16
+ const header = [
17
+ `# WCAG Audit Findings — ${projectName}`,
18
+ ``,
19
+ `Generated by ${generator}`,
20
+ `Total pages audited: ${totalPages}`,
21
+ `Total issues: ${findings.length}`,
22
+ `Criteria: WCAG ${wcagVersion} Level ${levels.join(" + ")}`,
23
+ ``,
24
+ ].join("\n");
25
+
26
+ if (findings.length === 0) {
27
+ return header + "\n✅ No issues found!\n";
28
+ }
29
+
30
+ const merged = mergeFindings(findings);
31
+
32
+ const groups = new Map(IMPACT_ORDER.map((k) => [k, []]));
33
+ for (const f of merged) {
34
+ const bucket = IMPACT_ORDER.includes(f.impact) ? f.impact : "minor";
35
+ groups.get(bucket).push(f);
36
+ }
37
+
38
+ const sections = [];
39
+ for (const impact of IMPACT_ORDER) {
40
+ const items = groups.get(impact);
41
+ if (!items.length) continue;
42
+ sections.push(
43
+ `## ${capitalize(impact)} Issues (${items.length})`,
44
+ ``,
45
+ ...items.map((f, i) => renderFinding(f, i + 1)),
46
+ );
47
+ }
48
+
49
+ return header + "\n" + sections.join("\n") + "\n";
50
+ }
51
+
52
+ function renderFinding(f, index) {
53
+ // criteria may be a string like "1.1.1" or an array ["1.1.1", "2.4.7"]
54
+ const criteriaList = Array.isArray(f.criteria)
55
+ ? f.criteria
56
+ : f.criteria
57
+ ? [f.criteria]
58
+ : [];
59
+ const criteria = criteriaList.join(", ") || "—";
60
+ const routesLine =
61
+ f.routes.length === 1
62
+ ? `- **Route:** \`${f.routes[0]}\``
63
+ : `- Routes affected: ${f.routes.length}\n${f.routes.map((r) => `- \`${r}\``).join("\n")}`;
64
+
65
+ const fileLine = f.sourceFile
66
+ ? `- **File:** \`${f.sourceFile}\``
67
+ : "- **File:** _approximate — source map not available_";
68
+
69
+ const snippet = f.evidence
70
+ ? "\n**Current code:**\n\n```html\n" + escapeCodeBlock(f.evidence) + "\n```\n"
71
+ : "";
72
+
73
+ const fixHint = f.help
74
+ ? `\n**How to fix:** ${f.help}\n`
75
+ : "";
76
+
77
+ return [
78
+ `### ${index}. ${f.description || f.ruleId}`,
79
+ ``,
80
+ fileLine,
81
+ routesLine,
82
+ `- **Criterion:** ${criteria} (Level ${f.level || "?"})`,
83
+ `- **Rule:** \`${f.ruleId}\``,
84
+ `- **Detected by:** ${f.source}`,
85
+ f.helpUrl ? `- **Reference:** ${f.helpUrl}` : "",
86
+ snippet,
87
+ fixHint,
88
+ "---",
89
+ "",
90
+ ]
91
+ .filter(Boolean)
92
+ .join("\n");
93
+ }
94
+
95
+ function mergeFindings(findings) {
96
+ const byKey = new Map();
97
+ for (const f of findings) {
98
+ const key = `${f.ruleId}::${f.selector || ""}::${f.evidence || ""}`;
99
+ if (!byKey.has(key)) {
100
+ byKey.set(key, { ...f, routes: [], sourceFiles: new Set() });
101
+ }
102
+ const entry = byKey.get(key);
103
+ if (f.route) entry.routes.push(f.route);
104
+ if (f.sourceFile) entry.sourceFiles.add(f.sourceFile);
105
+ }
106
+ for (const entry of byKey.values()) {
107
+ entry.routes = [...new Set(entry.routes)];
108
+ entry.sourceFile = [...entry.sourceFiles][0] || null;
109
+ }
110
+ return [...byKey.values()];
111
+ }
112
+
113
+ function capitalize(s) {
114
+ return s.charAt(0).toUpperCase() + s.slice(1);
115
+ }
116
+
117
+ function escapeCodeBlock(s) {
118
+ return String(s).replace(/```/g, "\\`\\`\\`");
119
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderFindingsMarkdown } from "./markdown.js";
3
+
4
+ describe("renderFindingsMarkdown", () => {
5
+ const baseFinding = {
6
+ source: "axe",
7
+ ruleId: "image-alt",
8
+ criteria: ["1.1.1"],
9
+ level: "A",
10
+ impact: "critical",
11
+ description: "Images must have alternate text",
12
+ help: "Add an alt attribute describing the image",
13
+ helpUrl: "https://dequeuniversity.com/rules/axe/4.10/image-alt",
14
+ selector: "img.hero",
15
+ evidence: "<img src='/hero.png'>",
16
+ };
17
+
18
+ it("includes a header with project + generator metadata", () => {
19
+ const md = renderFindingsMarkdown({
20
+ projectName: "my-nextjs-app",
21
+ generator: "@wcagaudit/cli v1.0.0-alpha.1",
22
+ totalPages: 47,
23
+ wcagVersion: "2.2",
24
+ levels: ["A", "AA"],
25
+ findings: [],
26
+ });
27
+ expect(md).toMatch(/# WCAG Audit Findings — my-nextjs-app/);
28
+ expect(md).toMatch(/@wcagaudit\/cli v1\.0\.0-alpha\.1/);
29
+ expect(md).toMatch(/Total pages audited: 47/);
30
+ expect(md).toMatch(/WCAG 2\.2 Level A \+ AA/);
31
+ expect(md).toMatch(/No issues found!/);
32
+ });
33
+
34
+ it("groups findings by impact descending", () => {
35
+ const md = renderFindingsMarkdown({
36
+ projectName: "x",
37
+ generator: "x",
38
+ totalPages: 1,
39
+ wcagVersion: "2.2",
40
+ levels: ["A"],
41
+ findings: [
42
+ { ...baseFinding, impact: "minor", ruleId: "minor-rule" },
43
+ { ...baseFinding, impact: "critical", ruleId: "image-alt" },
44
+ { ...baseFinding, impact: "serious", ruleId: "serious-rule" },
45
+ ],
46
+ });
47
+ const criticalIdx = md.indexOf("## Critical");
48
+ const seriousIdx = md.indexOf("## Serious");
49
+ const minorIdx = md.indexOf("## Minor");
50
+ expect(criticalIdx).toBeGreaterThan(-1);
51
+ expect(criticalIdx).toBeLessThan(seriousIdx);
52
+ expect(seriousIdx).toBeLessThan(minorIdx);
53
+ });
54
+
55
+ it("renders a finding with route, file, criterion, and snippet", () => {
56
+ const md = renderFindingsMarkdown({
57
+ projectName: "x",
58
+ generator: "x",
59
+ totalPages: 1,
60
+ wcagVersion: "2.2",
61
+ levels: ["A"],
62
+ findings: [
63
+ {
64
+ ...baseFinding,
65
+ route: "/",
66
+ sourceFile: "src/app/page.tsx",
67
+ },
68
+ ],
69
+ });
70
+ expect(md).toMatch(/\*\*Route:\*\* `\/`/);
71
+ expect(md).toMatch(/\*\*File:\*\* `src\/app\/page\.tsx`/);
72
+ expect(md).toMatch(/1\.1\.1/);
73
+ expect(md).toMatch(/image-alt/);
74
+ expect(md).toMatch(/<img src='\/hero\.png'>/);
75
+ });
76
+
77
+ it("merges findings that share ruleId but appear on multiple routes", () => {
78
+ const md = renderFindingsMarkdown({
79
+ projectName: "x",
80
+ generator: "x",
81
+ totalPages: 3,
82
+ wcagVersion: "2.2",
83
+ levels: ["A"],
84
+ findings: [
85
+ { ...baseFinding, route: "/", sourceFile: "src/app/page.tsx" },
86
+ { ...baseFinding, route: "/about", sourceFile: "src/app/about/page.tsx" },
87
+ { ...baseFinding, route: "/pricing", sourceFile: "src/app/pricing/page.tsx" },
88
+ ],
89
+ });
90
+ expect(md).toMatch(/Routes affected: 3/);
91
+ expect(md).toMatch(/- `\/`/);
92
+ expect(md).toMatch(/- `\/about`/);
93
+ expect(md).toMatch(/- `\/pricing`/);
94
+ });
95
+ });