@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,126 @@
|
|
|
1
|
+
import enquirer from "enquirer";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { validateLicense } from "../license/validate.js";
|
|
4
|
+
import { writeGlobalConfig } from "../config/global.js";
|
|
5
|
+
|
|
6
|
+
const CLI_VERSION = "1.0.0-alpha.3";
|
|
7
|
+
|
|
8
|
+
// runInit can be called two ways:
|
|
9
|
+
// 1. Interactive (no answers) — uses enquirer to prompt
|
|
10
|
+
// 2. Non-interactive (answers provided) — used by tests and CI
|
|
11
|
+
export async function runInit({ answers, log = console.log } = {}) {
|
|
12
|
+
log("");
|
|
13
|
+
log("Welcome to WCAG Audit CLI");
|
|
14
|
+
log("");
|
|
15
|
+
|
|
16
|
+
const resolved = answers || (await promptUser());
|
|
17
|
+
|
|
18
|
+
// 1. Validate license
|
|
19
|
+
const machineId = randomUUID();
|
|
20
|
+
const license = await validateLicense(resolved.licenseKey, {
|
|
21
|
+
machineId,
|
|
22
|
+
source: "cli",
|
|
23
|
+
version: CLI_VERSION,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!license.valid) {
|
|
27
|
+
log(`✗ License check failed: ${license.error}`);
|
|
28
|
+
return { ok: false, error: license.error };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const credits = license.creditsRemaining?.total ?? "unlimited";
|
|
32
|
+
log(`✓ License valid (${license.tier} plan, ${credits} credits remaining)`);
|
|
33
|
+
|
|
34
|
+
// 2. Write config
|
|
35
|
+
const aiConfig = resolved.enableAi
|
|
36
|
+
? {
|
|
37
|
+
enabled: true,
|
|
38
|
+
provider: resolved.aiProvider,
|
|
39
|
+
apiKey: resolved.aiApiKey,
|
|
40
|
+
model: defaultModelFor(resolved.aiProvider),
|
|
41
|
+
groups: resolved.aiGroups || ["visual", "structure", "language"],
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
provider: "anthropic",
|
|
46
|
+
apiKey: null,
|
|
47
|
+
model: null,
|
|
48
|
+
groups: [],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
await writeGlobalConfig({
|
|
52
|
+
licenseKey: resolved.licenseKey,
|
|
53
|
+
ai: aiConfig,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
log("✓ Config saved to ~/.wcagauditrc (chmod 600)");
|
|
57
|
+
log(" Run `npx wcag-audit scan` in your project directory to start.");
|
|
58
|
+
log("");
|
|
59
|
+
|
|
60
|
+
return { ok: true, tier: license.tier };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultModelFor(provider) {
|
|
64
|
+
return {
|
|
65
|
+
anthropic: "claude-sonnet-4-6",
|
|
66
|
+
openai: "gpt-4.1-mini",
|
|
67
|
+
google: "gemini-2.5-flash",
|
|
68
|
+
}[provider] || "claude-sonnet-4-6";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function promptUser() {
|
|
72
|
+
const answers = await enquirer.prompt([
|
|
73
|
+
{
|
|
74
|
+
type: "input",
|
|
75
|
+
name: "licenseKey",
|
|
76
|
+
message: "Paste your WCAG Audit license key:",
|
|
77
|
+
validate(value) {
|
|
78
|
+
if (!/^WCAG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/i.test(value?.trim())) {
|
|
79
|
+
return "Expected format: WCAG-XXXX-XXXX-XXXX-XXXX";
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
result: (v) => v.trim().toUpperCase(),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: "confirm",
|
|
87
|
+
name: "enableAi",
|
|
88
|
+
message: "Enable AI vision review? (uses your own LLM API key)",
|
|
89
|
+
initial: true,
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
if (!answers.enableAi) return answers;
|
|
94
|
+
|
|
95
|
+
const aiAnswers = await enquirer.prompt([
|
|
96
|
+
{
|
|
97
|
+
type: "select",
|
|
98
|
+
name: "aiProvider",
|
|
99
|
+
message: "Choose AI provider:",
|
|
100
|
+
choices: [
|
|
101
|
+
{ name: "anthropic", message: "Anthropic Claude (recommended)" },
|
|
102
|
+
{ name: "openai", message: "OpenAI GPT" },
|
|
103
|
+
{ name: "google", message: "Google Gemini" },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: "password",
|
|
108
|
+
name: "aiApiKey",
|
|
109
|
+
message: "Paste your API key:",
|
|
110
|
+
validate: (v) => (v && v.length > 10 ? true : "API key seems too short"),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: "multiselect",
|
|
114
|
+
name: "aiGroups",
|
|
115
|
+
message: "Which AI review groups to enable?",
|
|
116
|
+
choices: [
|
|
117
|
+
{ name: "visual", message: "Visual quality (color, layout, hierarchy)" },
|
|
118
|
+
{ name: "structure", message: "Structure & navigation" },
|
|
119
|
+
{ name: "language", message: "Reading & language" },
|
|
120
|
+
],
|
|
121
|
+
initial: [0, 1, 2],
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
return { ...answers, ...aiAnswers };
|
|
126
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, readFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { runInit } from "./init.js";
|
|
6
|
+
|
|
7
|
+
let tmpHome;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tmpHome = await mkdtemp(join(tmpdir(), "wcaginit-"));
|
|
11
|
+
process.env.HOME = tmpHome;
|
|
12
|
+
global.fetch = vi.fn();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(tmpHome, { recursive: true, force: true });
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("runInit", () => {
|
|
21
|
+
it("writes licenseKey + AI config when validation passes", async () => {
|
|
22
|
+
global.fetch.mockResolvedValueOnce({
|
|
23
|
+
ok: true,
|
|
24
|
+
json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
|
|
25
|
+
});
|
|
26
|
+
const logs = [];
|
|
27
|
+
const result = await runInit({
|
|
28
|
+
answers: {
|
|
29
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
30
|
+
enableAi: true,
|
|
31
|
+
aiProvider: "anthropic",
|
|
32
|
+
aiApiKey: "sk-ant-x",
|
|
33
|
+
aiGroups: ["visual", "structure"],
|
|
34
|
+
},
|
|
35
|
+
log: (msg) => logs.push(msg),
|
|
36
|
+
});
|
|
37
|
+
expect(result.ok).toBe(true);
|
|
38
|
+
const raw = await readFile(join(tmpHome, ".wcagauditrc"), "utf8");
|
|
39
|
+
const cfg = JSON.parse(raw);
|
|
40
|
+
expect(cfg.licenseKey).toBe("WCAG-TEST-AAAA-BBBB-CCCC");
|
|
41
|
+
expect(cfg.ai.enabled).toBe(true);
|
|
42
|
+
expect(cfg.ai.provider).toBe("anthropic");
|
|
43
|
+
expect(cfg.ai.apiKey).toBe("sk-ant-x");
|
|
44
|
+
expect(cfg.ai.groups).toEqual(["visual", "structure"]);
|
|
45
|
+
expect(logs.join("\n")).toMatch(/Config saved/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects and exits when license validation fails", async () => {
|
|
49
|
+
global.fetch.mockResolvedValueOnce({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: 403,
|
|
52
|
+
json: async () => ({ valid: false, error: "License revoked" }),
|
|
53
|
+
});
|
|
54
|
+
const result = await runInit({
|
|
55
|
+
answers: {
|
|
56
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
57
|
+
enableAi: false,
|
|
58
|
+
},
|
|
59
|
+
log: () => {},
|
|
60
|
+
});
|
|
61
|
+
expect(result.ok).toBe(false);
|
|
62
|
+
expect(result.error).toMatch(/revoked/i);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not write AI config when enableAi is false", async () => {
|
|
66
|
+
global.fetch.mockResolvedValueOnce({
|
|
67
|
+
ok: true,
|
|
68
|
+
json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
|
|
69
|
+
});
|
|
70
|
+
const result = await runInit({
|
|
71
|
+
answers: {
|
|
72
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
73
|
+
enableAi: false,
|
|
74
|
+
},
|
|
75
|
+
log: () => {},
|
|
76
|
+
});
|
|
77
|
+
expect(result.ok).toBe(true);
|
|
78
|
+
const raw = await readFile(join(tmpHome, ".wcagauditrc"), "utf8");
|
|
79
|
+
const cfg = JSON.parse(raw);
|
|
80
|
+
expect(cfg.ai.enabled).toBe(false);
|
|
81
|
+
expect(cfg.ai.apiKey).toBe(null);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { readFile, writeFile } from "fs/promises";
|
|
2
|
+
import { join, basename, resolve } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import getPort from "get-port";
|
|
5
|
+
|
|
6
|
+
import { readGlobalConfig } from "../config/global.js";
|
|
7
|
+
import { readProjectConfig } from "../config/project.js";
|
|
8
|
+
import { validateLicense } from "../license/validate.js";
|
|
9
|
+
import { logUsage } from "../license/log-usage.js";
|
|
10
|
+
import { detectFramework } from "../discovery/registry.js";
|
|
11
|
+
import { loadManualRoutes } from "../discovery/manual.js";
|
|
12
|
+
import { crawlRoutes } from "../discovery/crawl.js";
|
|
13
|
+
import { expandDynamicRoutes } from "../discovery/dynamic-samples.js";
|
|
14
|
+
import { startDevServer, detectDevCommand } from "../devserver/spawn.js";
|
|
15
|
+
import { renderFindingsMarkdown } from "../output/markdown.js";
|
|
16
|
+
import { renderCursorRules } from "../output/cursor-rules.js";
|
|
17
|
+
import { upsertAgentsMd } from "../output/agents-md.js";
|
|
18
|
+
import { hashContent, readCacheEntry, writeCacheEntry } from "../cache/route-cache.js";
|
|
19
|
+
|
|
20
|
+
const CLI_VERSION = "1.0.0-alpha.3";
|
|
21
|
+
|
|
22
|
+
export async function runScan({
|
|
23
|
+
cwd = process.cwd(),
|
|
24
|
+
dryRun = false,
|
|
25
|
+
noAi = false,
|
|
26
|
+
noCache = false,
|
|
27
|
+
urlMode = null,
|
|
28
|
+
routesFile = null,
|
|
29
|
+
crawlDepth = 2,
|
|
30
|
+
log = console.log,
|
|
31
|
+
runAuditForRoute,
|
|
32
|
+
} = {}) {
|
|
33
|
+
// 1. Load global + project config
|
|
34
|
+
const globalCfg = await readGlobalConfig();
|
|
35
|
+
if (!globalCfg.licenseKey) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: "No license key found. Run `npx wcag-audit init` first.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const projectCfg = await readProjectConfig(cwd);
|
|
42
|
+
|
|
43
|
+
// 2-3. Detect framework + discover routes.
|
|
44
|
+
// Priority: --url crawl > --routes file > framework detection.
|
|
45
|
+
let framework = null;
|
|
46
|
+
let strategy = null;
|
|
47
|
+
let routes = [];
|
|
48
|
+
|
|
49
|
+
if (urlMode) {
|
|
50
|
+
framework = "crawl";
|
|
51
|
+
strategy = "url-crawl";
|
|
52
|
+
log(`✓ Crawl mode: starting at ${urlMode} (depth ${crawlDepth})`);
|
|
53
|
+
routes = await crawlRoutes(urlMode, {
|
|
54
|
+
maxDepth: crawlDepth,
|
|
55
|
+
excludePaths: projectCfg.excludePaths,
|
|
56
|
+
});
|
|
57
|
+
} else if (routesFile) {
|
|
58
|
+
framework = "manual";
|
|
59
|
+
strategy = "routes-file";
|
|
60
|
+
log(`✓ Manual routes: ${routesFile}`);
|
|
61
|
+
routes = await loadManualRoutes(routesFile, {
|
|
62
|
+
excludePaths: projectCfg.excludePaths,
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
const det = await detectFramework(cwd);
|
|
66
|
+
if (det) {
|
|
67
|
+
framework = det.framework;
|
|
68
|
+
strategy = det.strategy;
|
|
69
|
+
log(`✓ Detected ${framework} (${strategy})`);
|
|
70
|
+
routes = await det.discoverRoutes(cwd, {
|
|
71
|
+
excludePaths: projectCfg.excludePaths,
|
|
72
|
+
});
|
|
73
|
+
routes = expandDynamicRoutes(routes, projectCfg.dynamicRouteSamples || {});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (routes.length === 0) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error:
|
|
81
|
+
"No routes discovered. Use --url to crawl a deployed site, --routes to provide a manual list, or add dynamicRouteSamples to .wcagauditrc.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
log(`✓ Discovered ${routes.length} route${routes.length === 1 ? "" : "s"}`);
|
|
85
|
+
|
|
86
|
+
// 4. Validate license + credit budget
|
|
87
|
+
const machineId = randomUUID();
|
|
88
|
+
const license = await validateLicense(globalCfg.licenseKey, {
|
|
89
|
+
machineId,
|
|
90
|
+
source: "cli",
|
|
91
|
+
version: CLI_VERSION,
|
|
92
|
+
});
|
|
93
|
+
if (!license.valid) {
|
|
94
|
+
return { ok: false, error: `License check failed: ${license.error}` };
|
|
95
|
+
}
|
|
96
|
+
const creditsRemaining = license.creditsRemaining?.total ?? Infinity;
|
|
97
|
+
const creditsNeeded = routes.length;
|
|
98
|
+
|
|
99
|
+
log(
|
|
100
|
+
`✓ License: ${license.tier} plan (${creditsRemaining === Infinity ? "unlimited" : creditsRemaining} credits remaining)`,
|
|
101
|
+
);
|
|
102
|
+
log("");
|
|
103
|
+
log("Scan preview:");
|
|
104
|
+
log(` ${routes.length} routes × 1 credit/route = ${creditsNeeded} credits`);
|
|
105
|
+
if (creditsRemaining !== Infinity) {
|
|
106
|
+
log(` Remaining after scan: ${creditsRemaining - creditsNeeded}`);
|
|
107
|
+
}
|
|
108
|
+
log("");
|
|
109
|
+
|
|
110
|
+
if (license.tier !== "enterprise" && creditsNeeded > creditsRemaining) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: `Not enough credits (${creditsNeeded} needed, ${creditsRemaining} remaining). Top up at https://wcagaudit.io/checkout/topup`,
|
|
114
|
+
creditsNeeded,
|
|
115
|
+
creditsRemaining,
|
|
116
|
+
routes,
|
|
117
|
+
framework,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (dryRun) {
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
dryRun: true,
|
|
125
|
+
framework,
|
|
126
|
+
routes,
|
|
127
|
+
creditsNeeded,
|
|
128
|
+
creditsRemaining,
|
|
129
|
+
license,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 5. Spawn dev server
|
|
134
|
+
const pkgJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
135
|
+
const devCmd = projectCfg.devServer.command
|
|
136
|
+
? { cmd: "sh", args: ["-c", projectCfg.devServer.command] }
|
|
137
|
+
: detectDevCommand(pkgJson);
|
|
138
|
+
if (!devCmd) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: "Could not determine dev server command. Add `dev` script to package.json or set devServer.command in .wcagauditrc.",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const port = projectCfg.devServer.port || (await getPort({ port: 3000 }));
|
|
146
|
+
log(`Starting dev server on port ${port}...`);
|
|
147
|
+
|
|
148
|
+
let server;
|
|
149
|
+
try {
|
|
150
|
+
server = await startDevServer({
|
|
151
|
+
cwd,
|
|
152
|
+
cmd: devCmd.cmd,
|
|
153
|
+
args: devCmd.args,
|
|
154
|
+
port,
|
|
155
|
+
healthPath: projectCfg.devServer.healthCheck || "/",
|
|
156
|
+
startupTimeout: projectCfg.devServer.startupTimeout || 60000,
|
|
157
|
+
});
|
|
158
|
+
log(`✓ Server ready on ${server.url}`);
|
|
159
|
+
log("");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { ok: false, error: err.message };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 6. Audit each route
|
|
165
|
+
const allFindings = [];
|
|
166
|
+
const runFn = runAuditForRoute || (await importAuditFn());
|
|
167
|
+
let newPagesCount = 0;
|
|
168
|
+
let cachedPagesCount = 0;
|
|
169
|
+
log("Auditing routes...");
|
|
170
|
+
for (let i = 0; i < routes.length; i++) {
|
|
171
|
+
const route = routes[i];
|
|
172
|
+
const url = `http://localhost:${port}${route.path}`;
|
|
173
|
+
const label = `[${i + 1}/${routes.length}] ${route.path}`;
|
|
174
|
+
try {
|
|
175
|
+
// Fetch the rendered HTML once to compute the cache key. This is
|
|
176
|
+
// cheap (<100ms) compared to running the full checker pipeline.
|
|
177
|
+
const cacheHtml = await fetch(url).then((r) => r.text()).catch(() => "");
|
|
178
|
+
const hash = hashContent(cacheHtml, []);
|
|
179
|
+
const cached = noCache ? null : await readCacheEntry(cwd, route.path, hash);
|
|
180
|
+
if (cached) {
|
|
181
|
+
const tagged = cached.findings.map((f) => ({
|
|
182
|
+
...f,
|
|
183
|
+
route: route.path,
|
|
184
|
+
sourceFile: route.sourceFile,
|
|
185
|
+
}));
|
|
186
|
+
allFindings.push(...tagged);
|
|
187
|
+
cachedPagesCount++;
|
|
188
|
+
log(`${label} → ${tagged.length} issues (cached)`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const findings = await runFn({
|
|
193
|
+
url,
|
|
194
|
+
wcagVersion: globalCfg.defaults.wcagVersion,
|
|
195
|
+
levels: globalCfg.defaults.conformanceLevel,
|
|
196
|
+
maxPages: 1,
|
|
197
|
+
aiEnabled: !noAi && globalCfg.ai.enabled,
|
|
198
|
+
aiProvider: globalCfg.ai.provider,
|
|
199
|
+
aiModel: globalCfg.ai.model,
|
|
200
|
+
aiKey: globalCfg.ai.apiKey,
|
|
201
|
+
headed: false,
|
|
202
|
+
slowMo: 0,
|
|
203
|
+
});
|
|
204
|
+
const tagged = findings.map((f) => ({
|
|
205
|
+
...f,
|
|
206
|
+
route: route.path,
|
|
207
|
+
sourceFile: route.sourceFile,
|
|
208
|
+
}));
|
|
209
|
+
allFindings.push(...tagged);
|
|
210
|
+
newPagesCount++;
|
|
211
|
+
// Store the bare findings (without route/sourceFile stamps) so
|
|
212
|
+
// re-tagging works even if route metadata changes.
|
|
213
|
+
await writeCacheEntry(cwd, route.path, hash, {
|
|
214
|
+
findings,
|
|
215
|
+
auditedAt: Date.now(),
|
|
216
|
+
});
|
|
217
|
+
log(`${label} → ${tagged.length} issues`);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
log(`${label} → ERROR: ${err.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 7. Cleanup dev server
|
|
224
|
+
await server.dispose();
|
|
225
|
+
|
|
226
|
+
// 8. Write outputs
|
|
227
|
+
const outputs = new Set(projectCfg.outputs || ["excel", "markdown"]);
|
|
228
|
+
const projectName = basename(cwd);
|
|
229
|
+
|
|
230
|
+
if (outputs.has("markdown")) {
|
|
231
|
+
const md = renderFindingsMarkdown({
|
|
232
|
+
projectName,
|
|
233
|
+
generator: `@wcagaudit/cli v${CLI_VERSION}`,
|
|
234
|
+
totalPages: routes.length,
|
|
235
|
+
wcagVersion: globalCfg.defaults.wcagVersion,
|
|
236
|
+
levels: globalCfg.defaults.conformanceLevel,
|
|
237
|
+
findings: allFindings,
|
|
238
|
+
});
|
|
239
|
+
await writeFile(resolve(cwd, "WCAG_FIXES.md"), md, "utf8");
|
|
240
|
+
log(`✓ WCAG_FIXES.md (${allFindings.length} findings)`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (outputs.has("cursor-rules")) {
|
|
244
|
+
const mdc = renderCursorRules({ projectName, findings: allFindings });
|
|
245
|
+
const dir = resolve(cwd, ".cursor/rules");
|
|
246
|
+
const { mkdir } = await import("fs/promises");
|
|
247
|
+
await mkdir(dir, { recursive: true });
|
|
248
|
+
await writeFile(resolve(dir, "wcag-fixes.mdc"), mdc, "utf8");
|
|
249
|
+
log(`✓ .cursor/rules/wcag-fixes.mdc`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (outputs.has("agents-md")) {
|
|
253
|
+
// Build a compact bulleted list for AGENTS.md — just the per-file
|
|
254
|
+
// summary, not the full finding detail (full detail lives in
|
|
255
|
+
// WCAG_FIXES.md).
|
|
256
|
+
const byFile = new Map();
|
|
257
|
+
for (const f of allFindings) {
|
|
258
|
+
const key = f.sourceFile || "(unknown)";
|
|
259
|
+
if (!byFile.has(key)) byFile.set(key, []);
|
|
260
|
+
byFile.get(key).push(f);
|
|
261
|
+
}
|
|
262
|
+
let body = "";
|
|
263
|
+
if (byFile.size === 0) {
|
|
264
|
+
body = "No outstanding WCAG issues from the last scan.\n";
|
|
265
|
+
} else {
|
|
266
|
+
for (const [file, items] of byFile.entries()) {
|
|
267
|
+
body += `### ${file}\n`;
|
|
268
|
+
for (const f of items.slice(0, 10)) {
|
|
269
|
+
const crit = Array.isArray(f.criteria) ? f.criteria.join(", ") : f.criteria || "?";
|
|
270
|
+
body += `- ${f.description || f.ruleId} — ${crit} (${f.impact || "minor"})\n`;
|
|
271
|
+
}
|
|
272
|
+
body += "\n";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await upsertAgentsMd(cwd, body);
|
|
276
|
+
log(`✓ AGENTS.md WCAG section updated`);
|
|
277
|
+
}
|
|
278
|
+
|
|
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
|
+
if (outputs.has("excel")) {
|
|
283
|
+
log("! Excel output skipped in Phase 1 — use `wcag-audit audit <url>` for per-page Excel.");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 9. Log usage for billing
|
|
287
|
+
await logUsage({
|
|
288
|
+
licenseKey: globalCfg.licenseKey,
|
|
289
|
+
pagesCount: routes.length,
|
|
290
|
+
newPagesCount,
|
|
291
|
+
cachedPagesCount,
|
|
292
|
+
issuesFound: allFindings.length,
|
|
293
|
+
source: "cli",
|
|
294
|
+
cliVersion: CLI_VERSION,
|
|
295
|
+
framework,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
log("");
|
|
299
|
+
log(`✓ Scan complete — ${allFindings.length} issues across ${routes.length} pages (${newPagesCount} audited, ${cachedPagesCount} cached)`);
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
framework,
|
|
303
|
+
routes,
|
|
304
|
+
findings: allFindings,
|
|
305
|
+
creditsUsed: creditsNeeded,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function importAuditFn() {
|
|
310
|
+
// runAuditCore returns { findings, meta } without writing any files.
|
|
311
|
+
// runAudit (the legacy wrapper) writes a per-page Excel and returns
|
|
312
|
+
// void, which loses the findings.
|
|
313
|
+
const { runAuditCore } = await import("../audit.js");
|
|
314
|
+
return async (opts) => {
|
|
315
|
+
const tmpScreens = `/tmp/wcag-screens-${Math.random().toString(36).slice(2)}`;
|
|
316
|
+
const result = await runAuditCore({
|
|
317
|
+
...opts,
|
|
318
|
+
screenshotsDir: tmpScreens,
|
|
319
|
+
});
|
|
320
|
+
return result?.findings || [];
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, mkdir, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { runScan } from "./scan.js";
|
|
6
|
+
|
|
7
|
+
let tmpHome;
|
|
8
|
+
let projDir;
|
|
9
|
+
|
|
10
|
+
async function touch(relPath) {
|
|
11
|
+
const abs = join(projDir, relPath);
|
|
12
|
+
await mkdir(join(abs, ".."), { recursive: true });
|
|
13
|
+
await writeFile(abs, "// test", "utf8");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tmpHome = await mkdtemp(join(tmpdir(), "wcagscan-home-"));
|
|
18
|
+
projDir = await mkdtemp(join(tmpdir(), "wcagscan-proj-"));
|
|
19
|
+
process.env.HOME = tmpHome;
|
|
20
|
+
global.fetch = vi.fn();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(tmpHome, { recursive: true, force: true });
|
|
25
|
+
await rm(projDir, { recursive: true, force: true });
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("runScan — dry run preview", () => {
|
|
30
|
+
it("fails when no config is found", async () => {
|
|
31
|
+
const result = await runScan({ cwd: projDir, dryRun: true, log: () => {} });
|
|
32
|
+
expect(result.ok).toBe(false);
|
|
33
|
+
expect(result.error).toMatch(/init/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reports discovered routes and credit cost", async () => {
|
|
37
|
+
await writeFile(
|
|
38
|
+
join(tmpHome, ".wcagauditrc"),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
41
|
+
ai: { enabled: false, provider: "anthropic", apiKey: null },
|
|
42
|
+
}),
|
|
43
|
+
"utf8"
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await writeFile(
|
|
47
|
+
join(projDir, "package.json"),
|
|
48
|
+
JSON.stringify({ dependencies: { next: "15.0.0" }, scripts: { dev: "next dev" } }),
|
|
49
|
+
"utf8"
|
|
50
|
+
);
|
|
51
|
+
await touch("app/page.tsx");
|
|
52
|
+
await touch("app/about/page.tsx");
|
|
53
|
+
await touch("app/pricing/page.tsx");
|
|
54
|
+
|
|
55
|
+
global.fetch.mockResolvedValueOnce({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: async () => ({
|
|
58
|
+
valid: true,
|
|
59
|
+
tier: "pro",
|
|
60
|
+
creditsRemaining: { total: 200 },
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const logs = [];
|
|
65
|
+
const result = await runScan({
|
|
66
|
+
cwd: projDir,
|
|
67
|
+
dryRun: true,
|
|
68
|
+
log: (m) => logs.push(m),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.ok).toBe(true);
|
|
72
|
+
expect(result.framework).toBe("nextjs-app");
|
|
73
|
+
expect(result.routes.map((r) => r.path).sort()).toEqual([
|
|
74
|
+
"/",
|
|
75
|
+
"/about",
|
|
76
|
+
"/pricing",
|
|
77
|
+
]);
|
|
78
|
+
expect(result.creditsNeeded).toBe(3);
|
|
79
|
+
expect(result.creditsRemaining).toBe(200);
|
|
80
|
+
const joined = logs.join("\n");
|
|
81
|
+
expect(joined).toMatch(/Detected nextjs-app/);
|
|
82
|
+
expect(joined).toMatch(/3 routes/);
|
|
83
|
+
expect(joined).toMatch(/3 credits/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("fails when credits insufficient", async () => {
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(tmpHome, ".wcagauditrc"),
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
91
|
+
ai: { enabled: false, provider: "anthropic", apiKey: null },
|
|
92
|
+
}),
|
|
93
|
+
"utf8"
|
|
94
|
+
);
|
|
95
|
+
await writeFile(
|
|
96
|
+
join(projDir, "package.json"),
|
|
97
|
+
JSON.stringify({ dependencies: { next: "15.0.0" } }),
|
|
98
|
+
"utf8"
|
|
99
|
+
);
|
|
100
|
+
await touch("app/page.tsx");
|
|
101
|
+
await touch("app/a/page.tsx");
|
|
102
|
+
await touch("app/b/page.tsx");
|
|
103
|
+
await touch("app/c/page.tsx");
|
|
104
|
+
await touch("app/d/page.tsx");
|
|
105
|
+
|
|
106
|
+
global.fetch.mockResolvedValueOnce({
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({
|
|
109
|
+
valid: true,
|
|
110
|
+
tier: "pro",
|
|
111
|
+
creditsRemaining: { total: 2 },
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const result = await runScan({ cwd: projDir, dryRun: true, log: () => {} });
|
|
116
|
+
expect(result.ok).toBe(false);
|
|
117
|
+
expect(result.error).toMatch(/not enough credits/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns error when framework not detected", async () => {
|
|
121
|
+
await writeFile(
|
|
122
|
+
join(tmpHome, ".wcagauditrc"),
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
|
|
125
|
+
ai: { enabled: false },
|
|
126
|
+
}),
|
|
127
|
+
"utf8"
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
global.fetch.mockResolvedValueOnce({
|
|
131
|
+
ok: true,
|
|
132
|
+
json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = await runScan({ cwd: projDir, dryRun: true, log: () => {} });
|
|
136
|
+
expect(result.ok).toBe(false);
|
|
137
|
+
expect(result.error).toMatch(/routes discovered|framework/i);
|
|
138
|
+
});
|
|
139
|
+
});
|