@wcag-audit/cli 1.0.0-alpha.5 → 1.0.0-alpha.8
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/package.json +1 -1
- package/src/cli.js +14 -1
- package/src/commands/doctor.js +1 -1
- package/src/commands/init.js +39 -3
- package/src/commands/scan.js +43 -5
- package/src/commands/watch.js +89 -0
- package/src/config/project.js +2 -2
- package/src/license/request-free.js +46 -0
- package/src/license/request-free.test.js +57 -0
- package/src/output/excel-project.js +212 -0
- package/src/output/excel-project.test.js +140 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runInit } from "./commands/init.js";
|
|
|
10
10
|
import { runScan } from "./commands/scan.js";
|
|
11
11
|
import { runCi, FAIL_ON_LEVELS } from "./commands/ci.js";
|
|
12
12
|
import { runDoctor } from "./commands/doctor.js";
|
|
13
|
+
import { runWatch } from "./commands/watch.js";
|
|
13
14
|
import { readGlobalConfig } from "./config/global.js";
|
|
14
15
|
import { runAudit } from "./audit.js";
|
|
15
16
|
|
|
@@ -18,7 +19,7 @@ const program = new Command();
|
|
|
18
19
|
program
|
|
19
20
|
.name("wcag-audit")
|
|
20
21
|
.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.
|
|
22
|
+
.version("1.0.0-alpha.8");
|
|
22
23
|
|
|
23
24
|
// ── init ─────────────────────────────────────────────────────────
|
|
24
25
|
program
|
|
@@ -71,6 +72,18 @@ program
|
|
|
71
72
|
console.log(JSON.stringify(redacted, null, 2));
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
// ── watch ────────────────────────────────────────────────────────
|
|
76
|
+
program
|
|
77
|
+
.command("watch")
|
|
78
|
+
.description("Watch source files and rescan on every change (debounced 2s).")
|
|
79
|
+
.option("--debounce <ms>", "Debounce interval between rescans", "2000")
|
|
80
|
+
.action(async (opts) => {
|
|
81
|
+
const result = await runWatch({
|
|
82
|
+
debounceMs: parseInt(opts.debounce, 10) || 2000,
|
|
83
|
+
});
|
|
84
|
+
if (!result.ok) process.exit(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
74
87
|
// ── ci ───────────────────────────────────────────────────────────
|
|
75
88
|
program
|
|
76
89
|
.command("ci")
|
package/src/commands/doctor.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readGlobalConfig } from "../config/global.js";
|
|
|
5
5
|
import { validateLicense } from "../license/validate.js";
|
|
6
6
|
import { detectFramework } from "../discovery/registry.js";
|
|
7
7
|
|
|
8
|
-
const CLI_VERSION = "1.0.0-alpha.
|
|
8
|
+
const CLI_VERSION = "1.0.0-alpha.8";
|
|
9
9
|
|
|
10
10
|
export async function runDoctor({ cwd = process.cwd(), log = console.log } = {}) {
|
|
11
11
|
const checks = [];
|
package/src/commands/init.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import enquirer from "enquirer";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { validateLicense } from "../license/validate.js";
|
|
4
|
+
import { requestFreeLicense } from "../license/request-free.js";
|
|
4
5
|
import { writeGlobalConfig } from "../config/global.js";
|
|
5
6
|
|
|
6
|
-
const CLI_VERSION = "1.0.0-alpha.
|
|
7
|
+
const CLI_VERSION = "1.0.0-alpha.8";
|
|
7
8
|
|
|
8
9
|
// runInit can be called two ways:
|
|
9
10
|
// 1. Interactive (no answers) — uses enquirer to prompt
|
|
@@ -13,7 +14,7 @@ export async function runInit({ answers, log = console.log } = {}) {
|
|
|
13
14
|
log("Welcome to WCAG Audit CLI");
|
|
14
15
|
log("");
|
|
15
16
|
|
|
16
|
-
const resolved = answers || (await promptUser());
|
|
17
|
+
const resolved = answers || (await promptUser({ log }));
|
|
17
18
|
|
|
18
19
|
// 1. Validate license
|
|
19
20
|
const machineId = randomUUID();
|
|
@@ -68,7 +69,42 @@ function defaultModelFor(provider) {
|
|
|
68
69
|
}[provider] || "claude-sonnet-4-6";
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
async function promptUser() {
|
|
72
|
+
async function promptUser({ log = console.log } = {}) {
|
|
73
|
+
// Step 1: Has a key? Or request free?
|
|
74
|
+
const { hasKey } = await enquirer.prompt([
|
|
75
|
+
{
|
|
76
|
+
type: "confirm",
|
|
77
|
+
name: "hasKey",
|
|
78
|
+
message: "Do you already have a license key?",
|
|
79
|
+
initial: true,
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
if (!hasKey) {
|
|
84
|
+
// Free-tier flow: ask for email, request a free key, wait for them
|
|
85
|
+
// to paste it from their inbox.
|
|
86
|
+
const { email } = await enquirer.prompt([
|
|
87
|
+
{
|
|
88
|
+
type: "input",
|
|
89
|
+
name: "email",
|
|
90
|
+
message: "Enter your email for a free license (1 audit/day):",
|
|
91
|
+
validate: (v) => (/@.+\./.test(v) ? true : "Enter a valid email"),
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
log("");
|
|
95
|
+
log(`Requesting free license for ${email}...`);
|
|
96
|
+
const machineId = randomUUID();
|
|
97
|
+
const result = await requestFreeLicense({ email, machineId });
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
log(`✗ ${result.error}`);
|
|
100
|
+
log(" You can try again or skip and use --help to paste a key later.");
|
|
101
|
+
throw new Error(result.error);
|
|
102
|
+
}
|
|
103
|
+
log(`✓ ${result.message}`);
|
|
104
|
+
log("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Step 2: Paste the key (works for both free and paid)
|
|
72
108
|
const answers = await enquirer.prompt([
|
|
73
109
|
{
|
|
74
110
|
type: "input",
|
package/src/commands/scan.js
CHANGED
|
@@ -15,9 +15,10 @@ import { startDevServer, detectDevCommand } from "../devserver/spawn.js";
|
|
|
15
15
|
import { renderFindingsMarkdown } from "../output/markdown.js";
|
|
16
16
|
import { renderCursorRules } from "../output/cursor-rules.js";
|
|
17
17
|
import { upsertAgentsMd } from "../output/agents-md.js";
|
|
18
|
+
import { writeProjectReport } from "../output/excel-project.js";
|
|
18
19
|
import { hashContent, readCacheEntry, writeCacheEntry } from "../cache/route-cache.js";
|
|
19
20
|
|
|
20
|
-
const CLI_VERSION = "1.0.0-alpha.
|
|
21
|
+
const CLI_VERSION = "1.0.0-alpha.8";
|
|
21
22
|
|
|
22
23
|
export async function runScan({
|
|
23
24
|
cwd = process.cwd(),
|
|
@@ -240,6 +241,31 @@ export async function runScan({
|
|
|
240
241
|
log(`✓ WCAG_FIXES.md (${allFindings.length} findings)`);
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
if (outputs.has("json")) {
|
|
245
|
+
// Structured JSON output for programmatic consumers (custom
|
|
246
|
+
// dashboards, CI integrations, historical trend tracking).
|
|
247
|
+
const json = {
|
|
248
|
+
generator: `@wcag-audit/cli v${CLI_VERSION}`,
|
|
249
|
+
generatedAt: new Date().toISOString(),
|
|
250
|
+
projectName,
|
|
251
|
+
framework,
|
|
252
|
+
strategy,
|
|
253
|
+
wcagVersion: globalCfg.defaults.wcagVersion,
|
|
254
|
+
levels: globalCfg.defaults.conformanceLevel,
|
|
255
|
+
totalPages: routes.length,
|
|
256
|
+
newPagesCount,
|
|
257
|
+
cachedPagesCount,
|
|
258
|
+
totalIssues: allFindings.length,
|
|
259
|
+
findings: allFindings,
|
|
260
|
+
};
|
|
261
|
+
await writeFile(
|
|
262
|
+
resolve(cwd, "wcag-report.json"),
|
|
263
|
+
JSON.stringify(json, null, 2),
|
|
264
|
+
"utf8",
|
|
265
|
+
);
|
|
266
|
+
log(`✓ wcag-report.json`);
|
|
267
|
+
}
|
|
268
|
+
|
|
243
269
|
if (outputs.has("cursor-rules")) {
|
|
244
270
|
const mdc = renderCursorRules({ projectName, findings: allFindings });
|
|
245
271
|
const dir = resolve(cwd, ".cursor/rules");
|
|
@@ -276,11 +302,23 @@ export async function runScan({
|
|
|
276
302
|
log(`✓ AGENTS.md WCAG section updated`);
|
|
277
303
|
}
|
|
278
304
|
|
|
279
|
-
// Note: multi-page Excel is not yet supported. report.js is
|
|
280
|
-
// per-page only (expects meta.url, meta.startedAt, meta.scopeCriteria).
|
|
281
|
-
// Phase 1 ships markdown only; aggregate Excel comes in a later phase.
|
|
282
305
|
if (outputs.has("excel")) {
|
|
283
|
-
|
|
306
|
+
await writeProjectReport({
|
|
307
|
+
findings: allFindings,
|
|
308
|
+
meta: {
|
|
309
|
+
projectName,
|
|
310
|
+
generator: `@wcag-audit/cli v${CLI_VERSION}`,
|
|
311
|
+
framework,
|
|
312
|
+
strategy,
|
|
313
|
+
wcagVersion: globalCfg.defaults.wcagVersion,
|
|
314
|
+
levels: globalCfg.defaults.conformanceLevel,
|
|
315
|
+
totalPages: routes.length,
|
|
316
|
+
newPagesCount,
|
|
317
|
+
cachedPagesCount,
|
|
318
|
+
},
|
|
319
|
+
outPath: resolve(cwd, "wcag-report.xlsx"),
|
|
320
|
+
});
|
|
321
|
+
log(`✓ wcag-report.xlsx`);
|
|
284
322
|
}
|
|
285
323
|
|
|
286
324
|
// 9. Log usage for billing
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { watch } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { runScan } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
// Watch mode: rescan every time a source file changes. Debounced so a
|
|
6
|
+
// burst of saves (prettier-on-save, hot reload) only triggers one scan.
|
|
7
|
+
//
|
|
8
|
+
// The watcher only fires on changes to /src, /app, /pages, /components
|
|
9
|
+
// — not node_modules or .next. Ignores .wcag-audit/cache so cache
|
|
10
|
+
// writes don't re-trigger scans.
|
|
11
|
+
export async function runWatch({
|
|
12
|
+
cwd = process.cwd(),
|
|
13
|
+
debounceMs = 2000,
|
|
14
|
+
log = console.log,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const watchDirs = ["src", "app", "pages", "components"]
|
|
17
|
+
.map((d) => join(cwd, d));
|
|
18
|
+
|
|
19
|
+
log("");
|
|
20
|
+
log("wcag-audit watch — rescanning on source changes");
|
|
21
|
+
log(`Watched dirs: ${watchDirs.map((d) => d.replace(cwd + "/", "")).join(", ")}`);
|
|
22
|
+
log("Press Ctrl+C to stop.");
|
|
23
|
+
log("");
|
|
24
|
+
|
|
25
|
+
// Run once on startup so the first report lands before any edits.
|
|
26
|
+
await runScan({ cwd, log });
|
|
27
|
+
|
|
28
|
+
let pending = null;
|
|
29
|
+
let running = false;
|
|
30
|
+
let needsRerun = false;
|
|
31
|
+
|
|
32
|
+
const trigger = (reason) => {
|
|
33
|
+
if (pending) clearTimeout(pending);
|
|
34
|
+
pending = setTimeout(async () => {
|
|
35
|
+
pending = null;
|
|
36
|
+
if (running) {
|
|
37
|
+
needsRerun = true;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
running = true;
|
|
41
|
+
log("");
|
|
42
|
+
log(`─── change detected (${reason}) — rescanning ───`);
|
|
43
|
+
try {
|
|
44
|
+
await runScan({ cwd, log });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log(`✗ Scan failed: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
running = false;
|
|
49
|
+
if (needsRerun) {
|
|
50
|
+
needsRerun = false;
|
|
51
|
+
trigger("queued changes");
|
|
52
|
+
}
|
|
53
|
+
}, debounceMs);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const watchers = [];
|
|
57
|
+
for (const dir of watchDirs) {
|
|
58
|
+
try {
|
|
59
|
+
const w = watch(dir, { recursive: true }, (_event, filename) => {
|
|
60
|
+
if (!filename) return;
|
|
61
|
+
// Ignore cache writes so our own output doesn't loop
|
|
62
|
+
if (filename.startsWith(".wcag-audit")) return;
|
|
63
|
+
if (filename.includes("node_modules")) return;
|
|
64
|
+
trigger(filename);
|
|
65
|
+
});
|
|
66
|
+
watchers.push(w);
|
|
67
|
+
} catch {
|
|
68
|
+
// Directory doesn't exist — skip silently
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (watchers.length === 0) {
|
|
73
|
+
log("! No source directories found to watch. Tried: " + watchDirs.join(", "));
|
|
74
|
+
return { ok: false, error: "No source directories to watch" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Keep the process alive until SIGINT
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const cleanup = () => {
|
|
80
|
+
for (const w of watchers) w.close();
|
|
81
|
+
if (pending) clearTimeout(pending);
|
|
82
|
+
log("");
|
|
83
|
+
log("Watcher stopped.");
|
|
84
|
+
resolve({ ok: true });
|
|
85
|
+
};
|
|
86
|
+
process.on("SIGINT", cleanup);
|
|
87
|
+
process.on("SIGTERM", cleanup);
|
|
88
|
+
});
|
|
89
|
+
}
|
package/src/config/project.js
CHANGED
|
@@ -5,8 +5,8 @@ export const DEFAULT_PROJECT_CONFIG = {
|
|
|
5
5
|
routes: "auto",
|
|
6
6
|
excludePaths: [],
|
|
7
7
|
failOn: "critical",
|
|
8
|
-
// Valid values: "excel" (deferred
|
|
9
|
-
// "
|
|
8
|
+
// Valid values: "excel" (deferred), "markdown", "cursor-rules",
|
|
9
|
+
// "agents-md", "json". Unknown values are ignored.
|
|
10
10
|
outputs: ["excel", "markdown"],
|
|
11
11
|
dynamicRouteSamples: {},
|
|
12
12
|
devServer: {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Free-tier license request. POSTs to /v1/license/free which creates a
|
|
2
|
+
// User + License row (tier: "free") and emails the key to the user.
|
|
3
|
+
// The key is NEVER returned in the response body — it's mailed out,
|
|
4
|
+
// so users have to paste it from their inbox. This matches the
|
|
5
|
+
// Chrome extension free-tier flow.
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = "https://wcagaudit.io/api/v1/license/free";
|
|
8
|
+
|
|
9
|
+
export async function requestFreeLicense({ email, machineId, apiUrl } = {}) {
|
|
10
|
+
const url = apiUrl || process.env.WCAG_LICENSE_FREE_URL || DEFAULT_API_URL;
|
|
11
|
+
|
|
12
|
+
const trimmedEmail = (email || "").trim();
|
|
13
|
+
if (!trimmedEmail || !trimmedEmail.includes("@")) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: "A valid email address is required",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ email: trimmedEmail, machineId }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const body = await res.json().catch(() => ({}));
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: body.error || `Free license request failed (HTTP ${res.status})`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
message: body.message || "Check your email for your free license key",
|
|
39
|
+
};
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: `Network error: ${err.message}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { requestFreeLicense } from "./request-free.js";
|
|
3
|
+
|
|
4
|
+
describe("requestFreeLicense", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
global.fetch = vi.fn();
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("rejects invalid email without hitting the network", async () => {
|
|
13
|
+
const result = await requestFreeLicense({ email: "not-an-email", machineId: "m1" });
|
|
14
|
+
expect(result.ok).toBe(false);
|
|
15
|
+
expect(result.error).toMatch(/email/i);
|
|
16
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("rejects empty email", async () => {
|
|
20
|
+
const result = await requestFreeLicense({ email: "", machineId: "m1" });
|
|
21
|
+
expect(result.ok).toBe(false);
|
|
22
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns ok when API returns 200", async () => {
|
|
26
|
+
global.fetch.mockResolvedValueOnce({
|
|
27
|
+
ok: true,
|
|
28
|
+
json: async () => ({ ok: true, message: "Check your email for your free license key" }),
|
|
29
|
+
});
|
|
30
|
+
const result = await requestFreeLicense({ email: "sai@example.com", machineId: "m1" });
|
|
31
|
+
expect(result.ok).toBe(true);
|
|
32
|
+
expect(result.message).toMatch(/email/i);
|
|
33
|
+
const [url, init] = global.fetch.mock.calls[0];
|
|
34
|
+
expect(url).toMatch(/\/v1\/license\/free$/);
|
|
35
|
+
const body = JSON.parse(init.body);
|
|
36
|
+
expect(body.email).toBe("sai@example.com");
|
|
37
|
+
expect(body.machineId).toBe("m1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns error on non-2xx", async () => {
|
|
41
|
+
global.fetch.mockResolvedValueOnce({
|
|
42
|
+
ok: false,
|
|
43
|
+
status: 429,
|
|
44
|
+
json: async () => ({ error: "Rate limit exceeded" }),
|
|
45
|
+
});
|
|
46
|
+
const result = await requestFreeLicense({ email: "sai@example.com", machineId: "m1" });
|
|
47
|
+
expect(result.ok).toBe(false);
|
|
48
|
+
expect(result.error).toMatch(/rate limit/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns error when fetch throws", async () => {
|
|
52
|
+
global.fetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
53
|
+
const result = await requestFreeLicense({ email: "sai@example.com", machineId: "m1" });
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
expect(result.error).toMatch(/network/i);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Multi-page (project-wide) Excel report writer.
|
|
2
|
+
//
|
|
3
|
+
// Unlike src/report.js (which writes one Excel per page audit with
|
|
4
|
+
// embedded screenshots), this writer aggregates findings across all
|
|
5
|
+
// routes into three sheets: Summary, Issues, Coverage.
|
|
6
|
+
//
|
|
7
|
+
// No embedded screenshots — a 50-route project would produce a 500MB+
|
|
8
|
+
// file. The markdown output already has the text evidence, and users
|
|
9
|
+
// can run `wcag-audit audit <url>` for a single-page Excel with
|
|
10
|
+
// screenshots if they need them.
|
|
11
|
+
|
|
12
|
+
import ExcelJS from "exceljs";
|
|
13
|
+
|
|
14
|
+
const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"];
|
|
15
|
+
|
|
16
|
+
export async function writeProjectReport({
|
|
17
|
+
findings,
|
|
18
|
+
meta,
|
|
19
|
+
outPath,
|
|
20
|
+
}) {
|
|
21
|
+
const wb = new ExcelJS.Workbook();
|
|
22
|
+
wb.creator = meta.generator || "@wcag-audit/cli";
|
|
23
|
+
wb.created = new Date();
|
|
24
|
+
|
|
25
|
+
// ── Summary sheet ─────────────────────────────────────────────
|
|
26
|
+
const sum = wb.addWorksheet("Summary");
|
|
27
|
+
sum.columns = [
|
|
28
|
+
{ header: "", key: "k", width: 28 },
|
|
29
|
+
{ header: "", key: "v", width: 60 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const impactCounts = countByImpact(findings);
|
|
33
|
+
|
|
34
|
+
const rows = [
|
|
35
|
+
["WCAG Audit — Project Report"],
|
|
36
|
+
[],
|
|
37
|
+
["Project", meta.projectName],
|
|
38
|
+
["Generator", meta.generator],
|
|
39
|
+
["Generated", new Date().toISOString()],
|
|
40
|
+
[],
|
|
41
|
+
["Framework", meta.framework || "—"],
|
|
42
|
+
["Strategy", meta.strategy || "—"],
|
|
43
|
+
["WCAG version", meta.wcagVersion],
|
|
44
|
+
["Levels", (meta.levels || []).join(", ")],
|
|
45
|
+
[],
|
|
46
|
+
["Total pages audited", String(meta.totalPages)],
|
|
47
|
+
["Pages with new audits", String(meta.newPagesCount ?? meta.totalPages)],
|
|
48
|
+
["Pages served from cache", String(meta.cachedPagesCount ?? 0)],
|
|
49
|
+
[],
|
|
50
|
+
["Total issues", String(findings.length)],
|
|
51
|
+
[" Critical", String(impactCounts.critical)],
|
|
52
|
+
[" Serious", String(impactCounts.serious)],
|
|
53
|
+
[" Moderate", String(impactCounts.moderate)],
|
|
54
|
+
[" Minor", String(impactCounts.minor)],
|
|
55
|
+
];
|
|
56
|
+
rows.forEach((r) => sum.addRow(r));
|
|
57
|
+
sum.getRow(1).font = { bold: true, size: 14, color: { argb: "FF0969DA" } };
|
|
58
|
+
|
|
59
|
+
// ── Issues sheet ──────────────────────────────────────────────
|
|
60
|
+
const issues = wb.addWorksheet("Issues");
|
|
61
|
+
issues.columns = [
|
|
62
|
+
{ header: "Route", key: "route", width: 28 },
|
|
63
|
+
{ header: "File", key: "sourceFile", width: 38 },
|
|
64
|
+
{ header: "Criterion", key: "criteria", width: 14 },
|
|
65
|
+
{ header: "Level", key: "level", width: 8 },
|
|
66
|
+
{ header: "Impact", key: "impact", width: 12 },
|
|
67
|
+
{ header: "Rule", key: "ruleId", width: 24 },
|
|
68
|
+
{ header: "Description", key: "description", width: 50 },
|
|
69
|
+
{ header: "Fix", key: "help", width: 50 },
|
|
70
|
+
{ header: "Detected by", key: "source", width: 14 },
|
|
71
|
+
{ header: "Selector", key: "selector", width: 30 },
|
|
72
|
+
{ header: "Evidence", key: "evidence", width: 40 },
|
|
73
|
+
{ header: "Reference", key: "helpUrl", width: 40 },
|
|
74
|
+
];
|
|
75
|
+
issues.getRow(1).font = { bold: true };
|
|
76
|
+
issues.getRow(1).fill = {
|
|
77
|
+
type: "pattern",
|
|
78
|
+
pattern: "solid",
|
|
79
|
+
fgColor: { argb: "FFF1F5F9" },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Sort by impact desc, then by route, so critical issues surface first
|
|
83
|
+
const sorted = [...findings].sort((a, b) => {
|
|
84
|
+
const ai = IMPACT_ORDER.indexOf(a.impact);
|
|
85
|
+
const bi = IMPACT_ORDER.indexOf(b.impact);
|
|
86
|
+
const aImp = ai === -1 ? 4 : ai;
|
|
87
|
+
const bImp = bi === -1 ? 4 : bi;
|
|
88
|
+
if (aImp !== bImp) return aImp - bImp;
|
|
89
|
+
return String(a.route || "").localeCompare(String(b.route || ""));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
for (const f of sorted) {
|
|
93
|
+
const criteria = Array.isArray(f.criteria) ? f.criteria.join(", ") : f.criteria || "";
|
|
94
|
+
issues.addRow({
|
|
95
|
+
route: f.route || "",
|
|
96
|
+
sourceFile: f.sourceFile || "",
|
|
97
|
+
criteria,
|
|
98
|
+
level: f.level || "",
|
|
99
|
+
impact: f.impact || "",
|
|
100
|
+
ruleId: f.ruleId || "",
|
|
101
|
+
description: truncate(f.description, 400),
|
|
102
|
+
help: truncate(f.help, 400),
|
|
103
|
+
source: f.source || "",
|
|
104
|
+
selector: truncate(f.selector, 200),
|
|
105
|
+
evidence: truncate(asText(f.evidence), 400),
|
|
106
|
+
helpUrl: f.helpUrl || "",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Color-code impact column
|
|
111
|
+
issues.eachRow({ includeEmpty: false }, (row, rowNum) => {
|
|
112
|
+
if (rowNum === 1) return;
|
|
113
|
+
const impact = row.getCell("impact").value;
|
|
114
|
+
const fill = impactFill(impact);
|
|
115
|
+
if (fill) row.getCell("impact").fill = fill;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Freeze header row
|
|
119
|
+
issues.views = [{ state: "frozen", ySplit: 1 }];
|
|
120
|
+
// Enable autofilter
|
|
121
|
+
issues.autoFilter = {
|
|
122
|
+
from: { row: 1, column: 1 },
|
|
123
|
+
to: { row: 1, column: issues.columns.length },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// ── Coverage sheet ────────────────────────────────────────────
|
|
127
|
+
// Groups findings by criterion so compliance auditors can see
|
|
128
|
+
// which criteria were violated and how many times.
|
|
129
|
+
const cov = wb.addWorksheet("Coverage");
|
|
130
|
+
cov.columns = [
|
|
131
|
+
{ header: "Criterion", key: "id", width: 14 },
|
|
132
|
+
{ header: "Level", key: "level", width: 8 },
|
|
133
|
+
{ header: "Violations", key: "count", width: 12 },
|
|
134
|
+
{ header: "Routes affected", key: "routes", width: 28 },
|
|
135
|
+
{ header: "Rules triggered", key: "rules", width: 40 },
|
|
136
|
+
];
|
|
137
|
+
cov.getRow(1).font = { bold: true };
|
|
138
|
+
cov.getRow(1).fill = {
|
|
139
|
+
type: "pattern",
|
|
140
|
+
pattern: "solid",
|
|
141
|
+
fgColor: { argb: "FFF1F5F9" },
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const byCriterion = new Map();
|
|
145
|
+
for (const f of findings) {
|
|
146
|
+
const critList = Array.isArray(f.criteria) ? f.criteria : f.criteria ? [f.criteria] : ["?"];
|
|
147
|
+
for (const c of critList) {
|
|
148
|
+
if (!byCriterion.has(c)) {
|
|
149
|
+
byCriterion.set(c, { level: f.level || "", routes: new Set(), rules: new Set(), count: 0 });
|
|
150
|
+
}
|
|
151
|
+
const entry = byCriterion.get(c);
|
|
152
|
+
entry.count++;
|
|
153
|
+
if (f.route) entry.routes.add(f.route);
|
|
154
|
+
if (f.ruleId) entry.rules.add(f.ruleId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sortedCriteria = [...byCriterion.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
|
|
159
|
+
for (const [id, e] of sortedCriteria) {
|
|
160
|
+
cov.addRow({
|
|
161
|
+
id,
|
|
162
|
+
level: e.level,
|
|
163
|
+
count: e.count,
|
|
164
|
+
routes: [...e.routes].sort().join(", "),
|
|
165
|
+
rules: [...e.rules].sort().join(", "),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
cov.views = [{ state: "frozen", ySplit: 1 }];
|
|
170
|
+
|
|
171
|
+
await wb.xlsx.writeFile(outPath);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function countByImpact(findings) {
|
|
175
|
+
const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
const key = IMPACT_ORDER.includes(f.impact) ? f.impact : "minor";
|
|
178
|
+
counts[key]++;
|
|
179
|
+
}
|
|
180
|
+
return counts;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function impactFill(impact) {
|
|
184
|
+
switch (impact) {
|
|
185
|
+
case "critical":
|
|
186
|
+
return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEE2E2" } };
|
|
187
|
+
case "serious":
|
|
188
|
+
return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFFE4CC" } };
|
|
189
|
+
case "moderate":
|
|
190
|
+
return { type: "pattern", pattern: "solid", fgColor: { argb: "FFFEF3C7" } };
|
|
191
|
+
case "minor":
|
|
192
|
+
return { type: "pattern", pattern: "solid", fgColor: { argb: "FFE0E7FF" } };
|
|
193
|
+
default:
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function truncate(value, max) {
|
|
199
|
+
const s = asText(value);
|
|
200
|
+
if (s.length <= max) return s;
|
|
201
|
+
return s.slice(0, max - 1) + "…";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function asText(value) {
|
|
205
|
+
if (value == null) return "";
|
|
206
|
+
if (typeof value === "string") return value;
|
|
207
|
+
try {
|
|
208
|
+
return JSON.stringify(value);
|
|
209
|
+
} catch {
|
|
210
|
+
return String(value);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, readFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import ExcelJS from "exceljs";
|
|
6
|
+
import { writeProjectReport } from "./excel-project.js";
|
|
7
|
+
|
|
8
|
+
let dir;
|
|
9
|
+
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "excel-")); });
|
|
10
|
+
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
11
|
+
|
|
12
|
+
async function loadWorkbook(path) {
|
|
13
|
+
const wb = new ExcelJS.Workbook();
|
|
14
|
+
await wb.xlsx.readFile(path);
|
|
15
|
+
return wb;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sampleMeta = {
|
|
19
|
+
projectName: "my-nextjs-app",
|
|
20
|
+
generator: "@wcag-audit/cli v1.0.0-alpha.8",
|
|
21
|
+
framework: "nextjs-app",
|
|
22
|
+
strategy: "source-walk",
|
|
23
|
+
wcagVersion: "2.2",
|
|
24
|
+
levels: ["A", "AA"],
|
|
25
|
+
totalPages: 3,
|
|
26
|
+
newPagesCount: 3,
|
|
27
|
+
cachedPagesCount: 0,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sampleFindings = [
|
|
31
|
+
{
|
|
32
|
+
source: "axe", ruleId: "image-alt", criteria: ["1.1.1"], level: "A",
|
|
33
|
+
impact: "critical", description: "Images must have alt text",
|
|
34
|
+
help: "Add an alt attribute", helpUrl: "https://example/1",
|
|
35
|
+
selector: "img.hero", evidence: "<img>", route: "/", sourceFile: "app/page.tsx",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
source: "playwright", ruleId: "kbd/focus", criteria: "2.4.7", level: "AA",
|
|
39
|
+
impact: "serious", description: "Focus indicator missing",
|
|
40
|
+
help: "Add :focus-visible", helpUrl: "https://example/2",
|
|
41
|
+
selector: "button", evidence: "<button>", route: "/about", sourceFile: "app/about/page.tsx",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
source: "axe", ruleId: "color-contrast", criteria: "1.4.3", level: "AA",
|
|
45
|
+
impact: "minor", description: "Low contrast",
|
|
46
|
+
help: "Increase contrast", helpUrl: "https://example/3",
|
|
47
|
+
selector: ".muted", evidence: "rgb(150,150,150) on rgb(200,200,200)",
|
|
48
|
+
route: "/pricing", sourceFile: "app/pricing/page.tsx",
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
describe("writeProjectReport", () => {
|
|
53
|
+
it("creates a workbook with 3 sheets: Summary, Issues, Coverage", async () => {
|
|
54
|
+
const outPath = join(dir, "report.xlsx");
|
|
55
|
+
await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
|
|
56
|
+
const wb = await loadWorkbook(outPath);
|
|
57
|
+
const names = wb.worksheets.map((ws) => ws.name);
|
|
58
|
+
expect(names).toEqual(["Summary", "Issues", "Coverage"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("Summary sheet includes project metadata + impact counts", async () => {
|
|
62
|
+
const outPath = join(dir, "report.xlsx");
|
|
63
|
+
await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
|
|
64
|
+
const wb = await loadWorkbook(outPath);
|
|
65
|
+
const sum = wb.getWorksheet("Summary");
|
|
66
|
+
const cells = [];
|
|
67
|
+
sum.eachRow((row) => row.eachCell((c) => cells.push(String(c.value ?? ""))));
|
|
68
|
+
const joined = cells.join(" | ");
|
|
69
|
+
expect(joined).toContain("my-nextjs-app");
|
|
70
|
+
expect(joined).toContain("nextjs-app");
|
|
71
|
+
expect(joined).toContain("source-walk");
|
|
72
|
+
expect(joined).toContain("2.2");
|
|
73
|
+
// Impact counts: 1 critical, 1 serious, 0 moderate, 1 minor
|
|
74
|
+
expect(joined).toMatch(/Critical.*1/);
|
|
75
|
+
expect(joined).toMatch(/Serious.*1/);
|
|
76
|
+
expect(joined).toMatch(/Minor.*1/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("Issues sheet has one row per finding (+ header)", async () => {
|
|
80
|
+
const outPath = join(dir, "report.xlsx");
|
|
81
|
+
await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
|
|
82
|
+
const wb = await loadWorkbook(outPath);
|
|
83
|
+
const issues = wb.getWorksheet("Issues");
|
|
84
|
+
// 1 header row + 3 findings
|
|
85
|
+
expect(issues.rowCount).toBe(4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("Issues sheet sorts by impact severity (critical first)", async () => {
|
|
89
|
+
const outPath = join(dir, "report.xlsx");
|
|
90
|
+
await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
|
|
91
|
+
const wb = await loadWorkbook(outPath);
|
|
92
|
+
const issues = wb.getWorksheet("Issues");
|
|
93
|
+
// Columns: 1=route, 2=sourceFile, 3=criteria, 4=level, 5=impact
|
|
94
|
+
// Row 2 (first data row) should be critical
|
|
95
|
+
expect(String(issues.getRow(2).getCell(5).value)).toBe("critical");
|
|
96
|
+
expect(String(issues.getRow(3).getCell(5).value)).toBe("serious");
|
|
97
|
+
expect(String(issues.getRow(4).getCell(5).value)).toBe("minor");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("Coverage sheet groups findings by criterion", async () => {
|
|
101
|
+
const outPath = join(dir, "report.xlsx");
|
|
102
|
+
await writeProjectReport({ findings: sampleFindings, meta: sampleMeta, outPath });
|
|
103
|
+
const wb = await loadWorkbook(outPath);
|
|
104
|
+
const cov = wb.getWorksheet("Coverage");
|
|
105
|
+
// 1 header + 3 criteria (1.1.1, 1.4.3, 2.4.7)
|
|
106
|
+
expect(cov.rowCount).toBe(4);
|
|
107
|
+
const ids = [];
|
|
108
|
+
cov.eachRow((row, i) => {
|
|
109
|
+
if (i === 1) return;
|
|
110
|
+
// Columns: 1=id, 2=level, 3=count
|
|
111
|
+
ids.push(String(row.getCell(1).value));
|
|
112
|
+
});
|
|
113
|
+
expect(ids.sort()).toEqual(["1.1.1", "1.4.3", "2.4.7"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles zero findings", async () => {
|
|
117
|
+
const outPath = join(dir, "report.xlsx");
|
|
118
|
+
await writeProjectReport({ findings: [], meta: sampleMeta, outPath });
|
|
119
|
+
const wb = await loadWorkbook(outPath);
|
|
120
|
+
const issues = wb.getWorksheet("Issues");
|
|
121
|
+
// Just the header row
|
|
122
|
+
expect(issues.rowCount).toBe(1);
|
|
123
|
+
const cov = wb.getWorksheet("Coverage");
|
|
124
|
+
expect(cov.rowCount).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("handles criteria as string or array", async () => {
|
|
128
|
+
const outPath = join(dir, "report.xlsx");
|
|
129
|
+
const mixed = [
|
|
130
|
+
{ impact: "critical", criteria: ["1.1.1", "4.1.2"], ruleId: "a", route: "/" },
|
|
131
|
+
{ impact: "minor", criteria: "2.4.3", ruleId: "b", route: "/x" },
|
|
132
|
+
];
|
|
133
|
+
await writeProjectReport({ findings: mixed, meta: sampleMeta, outPath });
|
|
134
|
+
const wb = await loadWorkbook(outPath);
|
|
135
|
+
const issues = wb.getWorksheet("Issues");
|
|
136
|
+
// Column 3 = criteria. Impact sort: critical first, then minor.
|
|
137
|
+
expect(String(issues.getRow(2).getCell(3).value)).toBe("1.1.1, 4.1.2");
|
|
138
|
+
expect(String(issues.getRow(3).getCell(3).value)).toBe("2.4.3");
|
|
139
|
+
});
|
|
140
|
+
});
|