create-backlist 10.0.3 → 10.0.5
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/bin/index.js +989 -713
- package/bin/qa.js +138 -183
- package/package.json +6 -1
- package/src/qa/analyzers/accessibility.js +199 -0
- package/src/qa/analyzers/api.js +125 -0
- package/src/qa/analyzers/performance.js +124 -0
- package/src/qa/analyzers/security.js +207 -0
- package/src/qa/analyzers/seo.js +248 -0
- package/src/qa/browser/crawler.js +305 -0
- package/src/qa/browser/installer.js +209 -0
- package/src/qa/browser/interactions.js +320 -0
- package/src/qa/browser/screenshot.js +34 -0
- package/src/qa/qa-engine.js +797 -2936
- package/src/qa/reporters/html.js +623 -0
- package/src/qa/reporters/json.js +49 -0
- package/src/qa/reporters/terminal.js +184 -0
- package/src/qa/utils/ai-classifier.js +98 -0
package/bin/qa.js
CHANGED
|
@@ -1,180 +1,131 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
-
// Backlist QA CLI — qa.js v10.0
|
|
5
|
-
// Standalone QA entry point: `node bin/qa.js [mode] [--flags]`
|
|
6
|
-
// Copyright (c) W.A.H.ISHAN — MIT License
|
|
7
|
-
//
|
|
8
|
-
// NEW in v10.0:
|
|
9
|
-
// ✦ --url=<localhost> --prod=<production> flags for HTTP-based QA
|
|
10
|
-
// ✦ URL QA mode — probe real running URLs (no browser required)
|
|
11
|
-
// ✦ HTTP security header scanner, SEO checker, a11y basics
|
|
12
|
-
// ✦ Route crawler & benchmark per endpoint
|
|
13
|
-
// ✦ Dual-URL diff report (localhost vs production)
|
|
14
|
-
// ✦ All v9.0 features retained (manual, auto, history, post-gen)
|
|
15
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
-
|
|
2
|
+
// ── Backlist Enterprise QA CLI — qa.js v12.0 ─────────────────────────────
|
|
17
3
|
import * as p from '@clack/prompts';
|
|
18
4
|
import chalk from 'chalk';
|
|
19
5
|
import fs from 'fs-extra';
|
|
20
6
|
import path from 'node:path';
|
|
7
|
+
import { URL } from 'node:url';
|
|
21
8
|
import {
|
|
22
|
-
runManualQA,
|
|
23
9
|
runAutomatedQA,
|
|
24
10
|
runUrlQA,
|
|
11
|
+
runManualQA,
|
|
25
12
|
viewQAHistory,
|
|
26
13
|
initQASystem,
|
|
27
14
|
autoRunPostGeneration,
|
|
15
|
+
VERSION,
|
|
28
16
|
} from '../src/qa/qa-engine.js';
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const posArgs = argv.filter(a => !a.startsWith('--'));
|
|
37
|
-
|
|
38
|
-
const isContinuous = flags.has('--continuous') || flags.has('--watch') || flags.has('-w');
|
|
39
|
-
const isCI = process.env.QA_CI === '1' || flags.has('--ci');
|
|
40
|
-
const noColor = flags.has('--no-color') || process.env.NO_COLOR;
|
|
41
|
-
const isPostGen = flags.has('--post-gen');
|
|
42
|
-
const scope = argv.find(a => a.startsWith('--scope='))?.split('=')[1] ?? 'all';
|
|
18
|
+
const VERSION_DISPLAY = `12.0.0`;
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const flags = new Set(argv.filter(a => a.startsWith('--')));
|
|
21
|
+
const posArgs = argv.filter(a => !a.startsWith('--'));
|
|
22
|
+
const isCI = process.env.QA_CI === '1' || flags.has('--ci');
|
|
23
|
+
const modeArg = posArgs[0]?.toLowerCase();
|
|
43
24
|
|
|
44
|
-
|
|
45
|
-
const localUrlFlag = argv.find(a => a.startsWith('--url='))?.split('=').slice(1).join('=') ?? '';
|
|
46
|
-
const prodUrlFlag = argv.find(a => a.startsWith('--prod='))?.split('=').slice(1).join('=') ?? '';
|
|
25
|
+
const getFlag = (name) => argv.find(a => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=') ?? '';
|
|
47
26
|
|
|
48
|
-
if (noColor) chalk.level = 0;
|
|
49
|
-
|
|
50
|
-
// ── Graceful shutdown ─────────────────────────────────────────────────────
|
|
51
27
|
let shuttingDown = false;
|
|
28
|
+
process.on('SIGINT', () => { if (!shuttingDown) { shuttingDown = true; process.stdout.write('\x1b[?25h'); console.log(chalk.gray('\n Shutting down...')); process.exit(0); } });
|
|
29
|
+
process.on('SIGTERM', () => { if (!shuttingDown) { shuttingDown = true; process.exit(0); } });
|
|
52
30
|
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
shuttingDown = true;
|
|
57
|
-
process.stdout.write('\x1b[?25h');
|
|
58
|
-
console.log('\n' + chalk.gray(` ${signal} received — shutting down gracefully…`));
|
|
59
|
-
p.cancel('QA session ended.');
|
|
60
|
-
process.exit(0);
|
|
61
|
-
};
|
|
62
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
63
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── Banner ────────────────────────────────────────────────────────────────
|
|
67
|
-
function printQABanner() {
|
|
68
|
-
if (isCI) return;
|
|
69
|
-
|
|
70
|
-
const c1 = chalk.hex('#00F5FF');
|
|
71
|
-
const c2 = chalk.hex('#BF40FF');
|
|
72
|
-
const c3 = chalk.hex('#FF6B6B');
|
|
73
|
-
const c4 = chalk.hex('#00FF9F');
|
|
74
|
-
const dim = chalk.gray;
|
|
75
|
-
|
|
31
|
+
function printBanner() {
|
|
32
|
+
const c1 = chalk.hex('#00F5FF');
|
|
33
|
+
const c2 = chalk.hex('#BF40FF');
|
|
76
34
|
console.log('');
|
|
77
|
-
console.log(c1('
|
|
78
|
-
console.log(c1(' ║') + c2.bold('
|
|
79
|
-
console.log(c1(' ║') +
|
|
80
|
-
console.log(c1('
|
|
81
|
-
console.log(c1(' ║') + c2.bold('/ /_/ / / ___ |/ /___/ /| | / /____/ / ___/ / ') + c1('║'));
|
|
82
|
-
console.log(c1(' ║') + c2.bold('/_____/ /_/ |_|\\____/_/ |_| /_____/___//____/ ') + c1('║'));
|
|
83
|
-
console.log(c1(' ║') + ' ' + c1('║'));
|
|
84
|
-
console.log(c1(' ║') + c3.bold(` 🧪 Live QA Platform v${VERSION} — HTTP + File + E2E `) + c1('║'));
|
|
85
|
-
console.log(c1(' ║') + dim(' URL Probe · Security · SEO · A11y · Performance · CI ') + c1('║'));
|
|
86
|
-
console.log(c1(' ╚══════════════════════════════════════════════════════════════╝'));
|
|
35
|
+
console.log(c1(' ╔═══════════════════════════════════════════════════════════╗'));
|
|
36
|
+
console.log(c1(' ║') + c2.bold(' BACKLIST ENTERPRISE AI QA PLATFORM v12.0 ') + c1('║'));
|
|
37
|
+
console.log(c1(' ║') + chalk.gray(' Real Browser Testing · Zero Fake Data · AI Classifier ') + c1('║'));
|
|
38
|
+
console.log(c1(' ╚═══════════════════════════════════════════════════════════╝'));
|
|
87
39
|
console.log('');
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
c4('◉ LIVE') + dim(' │ ') +
|
|
95
|
-
dim('Node ') + chalk.white(process.version) + dim(' │ ') +
|
|
96
|
-
dim('Heap ') + chalk.white(heapMB + 'MB') + dim(' │ ') +
|
|
97
|
-
dim('Scope ') + chalk.white(scope) + dim(' │ ') +
|
|
98
|
-
dim('Mode ') + chalk.white(isContinuous ? 'watch' : 'single-run') + dim(' │ ') +
|
|
99
|
-
dim('v') + chalk.white(VERSION)
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
if (localUrlFlag) console.log(dim(' URL → ') + chalk.white(localUrlFlag));
|
|
103
|
-
if (prodUrlFlag) console.log(dim(' Prod → ') + chalk.white(prodUrlFlag));
|
|
104
|
-
|
|
105
|
-
console.log(dim(' Flags: --url=<localhost> --prod=<production> --continuous (-w)'));
|
|
106
|
-
console.log(dim(' --ci --scope=<all|backend|frontend|security> --post-gen --no-color'));
|
|
107
|
-
console.log(dim(' CI: QA_CI=1 node bin/qa.js auto --url=http://localhost:3000'));
|
|
108
|
-
console.log(dim(' ─────────────────────────────────────────────────────────────'));
|
|
40
|
+
console.log(chalk.gray(
|
|
41
|
+
` Node ${process.version} · ` +
|
|
42
|
+
`Heap ${(process.memoryUsage().heapUsed/1024/1024).toFixed(0)}MB · ` +
|
|
43
|
+
`v${VERSION_DISPLAY}`
|
|
44
|
+
));
|
|
45
|
+
console.log(chalk.gray(' Powered by Playwright · axe-core · Real HTTP probes'));
|
|
109
46
|
console.log('');
|
|
110
47
|
}
|
|
111
48
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const maj = parseInt(process.version.replace('v','').split('.')[0]);
|
|
117
|
-
if (maj < 18) throw new Error(`Node.js ${process.version} — requires v18+`);
|
|
118
|
-
}},
|
|
119
|
-
{ name: 'QA directories', fn: async () => {
|
|
120
|
-
await fs.ensureDir(path.join(process.cwd(), '.backlist', 'qa', 'reports'));
|
|
121
|
-
}},
|
|
122
|
-
];
|
|
123
|
-
|
|
124
|
-
const spin = p.spinner();
|
|
125
|
-
spin.start('System health check…');
|
|
126
|
-
for (const check of checks) {
|
|
127
|
-
try { await check.fn(); }
|
|
128
|
-
catch (err) { spin.stop(chalk.red(`✗ ${check.name}: ${err.message}`)); process.exit(1); }
|
|
129
|
-
}
|
|
130
|
-
spin.stop(chalk.green(`✓ System healthy — QA v${VERSION} ready`));
|
|
49
|
+
function validateUrl(u) {
|
|
50
|
+
if (!u) return true; // optional
|
|
51
|
+
try { new URL(u); return true; }
|
|
52
|
+
catch { return false; }
|
|
131
53
|
}
|
|
132
54
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
55
|
+
async function collectUrls() {
|
|
56
|
+
console.log(chalk.hex('#00F5FF').bold('\n ── Target URLs ──────────────────────────────────────'));
|
|
57
|
+
console.log(chalk.gray(' Provide at least one URL. The QA engine will automatically'));
|
|
58
|
+
console.log(chalk.gray(' crawl routes, discover APIs, and test real interactions.\n'));
|
|
59
|
+
|
|
60
|
+
const localUrl = await p.text({
|
|
61
|
+
message : 'Localhost URL:',
|
|
62
|
+
placeholder: 'http://localhost:3000',
|
|
63
|
+
validate : (v) => {
|
|
64
|
+
if (!v?.trim()) return undefined; // optional
|
|
65
|
+
if (!validateUrl(v.trim())) return '❌ Invalid URL';
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
if (p.isCancel(localUrl)) { p.cancel('Cancelled.'); process.exit(0); }
|
|
69
|
+
|
|
70
|
+
const stagingUrl = await p.text({
|
|
71
|
+
message : 'Staging URL (blank to skip):',
|
|
72
|
+
placeholder: 'https://staging.yoursite.com',
|
|
73
|
+
validate : (v) => {
|
|
74
|
+
if (!v?.trim()) return undefined;
|
|
75
|
+
if (!validateUrl(v.trim())) return '❌ Invalid URL';
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (p.isCancel(stagingUrl)) { p.cancel('Cancelled.'); process.exit(0); }
|
|
79
|
+
|
|
80
|
+
const prodUrl = await p.text({
|
|
81
|
+
message : 'Production URL (blank to skip):',
|
|
82
|
+
placeholder: 'https://yoursite.com',
|
|
83
|
+
validate : (v) => {
|
|
84
|
+
if (!v?.trim()) return undefined;
|
|
85
|
+
if (!validateUrl(v.trim())) return '❌ Invalid URL';
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
if (p.isCancel(prodUrl)) { p.cancel('Cancelled.'); process.exit(0); }
|
|
137
89
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
process.exit(
|
|
90
|
+
const urls = {
|
|
91
|
+
localUrl : String(localUrl || '').trim() || undefined,
|
|
92
|
+
stagingUrl: String(stagingUrl || '').trim() || undefined,
|
|
93
|
+
prodUrl : String(prodUrl || '').trim() || undefined,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (!urls.localUrl && !urls.stagingUrl && !urls.prodUrl) {
|
|
97
|
+
console.log(chalk.red(' ❌ At least one URL is required for real testing.'));
|
|
98
|
+
process.exit(1);
|
|
147
99
|
}
|
|
148
100
|
|
|
149
|
-
|
|
150
|
-
|
|
101
|
+
return urls;
|
|
102
|
+
}
|
|
151
103
|
|
|
104
|
+
async function main() {
|
|
105
|
+
printBanner();
|
|
106
|
+
await initQASystem();
|
|
107
|
+
|
|
108
|
+
// ── CI / flag mode ──────────────────────────────────────────────────
|
|
152
109
|
if (isCI || modeArg) {
|
|
153
|
-
const
|
|
154
|
-
|
|
110
|
+
const urls = {
|
|
111
|
+
localUrl : getFlag('url') || getFlag('local') || undefined,
|
|
112
|
+
stagingUrl: getFlag('staging') || undefined,
|
|
113
|
+
prodUrl : getFlag('prod') || undefined,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const mode = modeArg || 'auto';
|
|
155
117
|
|
|
156
118
|
switch (mode) {
|
|
157
|
-
case 'manual':
|
|
158
|
-
await runManualQA();
|
|
159
|
-
break;
|
|
160
|
-
case 'auto':
|
|
161
|
-
await runAutomatedQA({
|
|
162
|
-
continuous: isContinuous,
|
|
163
|
-
localUrl : localUrlFlag || undefined,
|
|
164
|
-
prodUrl : prodUrlFlag || undefined,
|
|
165
|
-
});
|
|
166
|
-
break;
|
|
167
119
|
case 'url':
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
prodUrl : prodUrlFlag || undefined,
|
|
176
|
-
});
|
|
120
|
+
case 'scan': {
|
|
121
|
+
const result = await runUrlQA(urls);
|
|
122
|
+
if (isCI) process.exit(result?.session.getSummary().failed > 0 ? 1 : 0);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'auto': {
|
|
126
|
+
await runAutomatedQA({ ...urls, continuous: flags.has('--continuous') || flags.has('-w') });
|
|
177
127
|
break;
|
|
128
|
+
}
|
|
178
129
|
case 'history':
|
|
179
130
|
await viewQAHistory();
|
|
180
131
|
break;
|
|
@@ -182,69 +133,73 @@ async function main() {
|
|
|
182
133
|
await autoRunPostGeneration();
|
|
183
134
|
break;
|
|
184
135
|
default:
|
|
185
|
-
console.error(chalk.red(`Unknown mode: ${mode}. Use:
|
|
136
|
+
console.error(chalk.red(`Unknown mode: ${mode}. Use: scan | auto | history | post-gen`));
|
|
186
137
|
process.exit(1);
|
|
187
138
|
}
|
|
188
|
-
|
|
189
|
-
// CI exit code: non-zero if any failures
|
|
190
|
-
if (isCI) process.exit(0);
|
|
191
139
|
return;
|
|
192
140
|
}
|
|
193
141
|
|
|
194
|
-
// ── Interactive mode
|
|
195
|
-
p.intro(chalk.hex('#00F5FF').bold(
|
|
142
|
+
// ── Interactive mode ────────────────────────────────────────────────
|
|
143
|
+
p.intro(chalk.hex('#00F5FF').bold(' Backlist Enterprise QA Platform v12.0 '));
|
|
196
144
|
|
|
197
145
|
const mode = await p.select({
|
|
198
146
|
message: 'Select QA mode:',
|
|
199
147
|
options: [
|
|
200
|
-
{ value: '
|
|
201
|
-
{ value: '
|
|
202
|
-
{ value: '
|
|
203
|
-
{ value: '
|
|
204
|
-
{ value: 'post-gen',
|
|
205
|
-
{ value: 'history',
|
|
148
|
+
{ value: 'full-scan', label: '🔬 Full Enterprise Scan', hint: 'Browser + API + Security + Perf + A11y + SEO — real data only' },
|
|
149
|
+
{ value: 'quick-scan', label: '⚡ Quick Scan', hint: 'Fast connectivity + security + SEO check' },
|
|
150
|
+
{ value: 'manual', label: '🧪 Manual QA', hint: 'Choose specific scan types interactively' },
|
|
151
|
+
{ value: 'continuous', label: '🔄 Continuous Monitoring', hint: 'Re-runs every 60s — Ctrl+C to stop' },
|
|
152
|
+
{ value: 'post-gen', label: '🚀 Post-Gen Validation', hint: 'Validate freshly generated backend' },
|
|
153
|
+
{ value: 'history', label: '📜 View History', hint: 'Browse past real QA runs' },
|
|
206
154
|
],
|
|
207
155
|
});
|
|
208
|
-
if (p.isCancel(mode)) { p.cancel('
|
|
156
|
+
if (p.isCancel(mode)) { p.cancel('Cancelled.'); process.exit(0); }
|
|
157
|
+
|
|
158
|
+
if (mode === 'history') { await viewQAHistory(); return; }
|
|
159
|
+
if (mode === 'post-gen') { await autoRunPostGeneration(); return; }
|
|
160
|
+
|
|
161
|
+
// All scan modes need URLs
|
|
162
|
+
const urls = await collectUrls();
|
|
163
|
+
|
|
164
|
+
console.log('');
|
|
165
|
+
console.log(chalk.hex('#BF40FF').bold(' ── QA Configuration ───────────────────────────────'));
|
|
166
|
+
if (urls.localUrl) console.log(chalk.gray(` Localhost: ${urls.localUrl}`));
|
|
167
|
+
if (urls.stagingUrl) console.log(chalk.gray(` Staging: ${urls.stagingUrl}`));
|
|
168
|
+
if (urls.prodUrl) console.log(chalk.gray(` Production: ${urls.prodUrl}`));
|
|
169
|
+
console.log('');
|
|
170
|
+
|
|
171
|
+
const confirm = await p.confirm({
|
|
172
|
+
message : 'Start real browser-based QA scan?',
|
|
173
|
+
initialValue: true,
|
|
174
|
+
});
|
|
175
|
+
if (p.isCancel(confirm) || !confirm) { p.cancel('Aborted.'); process.exit(0); }
|
|
209
176
|
|
|
210
177
|
switch (mode) {
|
|
211
|
-
case '
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
await runUrlQA({ localUrl: localUrl.trim() || undefined, prodUrl: prodUrl.trim() || undefined });
|
|
178
|
+
case 'full-scan':
|
|
179
|
+
case 'quick-scan': {
|
|
180
|
+
const result = await runUrlQA(urls);
|
|
181
|
+
if (result?.htmlPath) {
|
|
182
|
+
p.outro(chalk.hex('#00F5FF').bold('✓ Scan complete — real runtime data only'));
|
|
183
|
+
console.log(chalk.gray(` 📄 HTML Report: ${result.htmlPath}`));
|
|
184
|
+
console.log(chalk.gray(` 📋 JSON Report: ${result.jsonPath}`));
|
|
185
|
+
}
|
|
220
186
|
break;
|
|
221
187
|
}
|
|
222
188
|
case 'manual':
|
|
223
189
|
await runManualQA();
|
|
224
190
|
break;
|
|
225
|
-
case '
|
|
226
|
-
await runAutomatedQA({
|
|
227
|
-
break;
|
|
228
|
-
case 'live':
|
|
229
|
-
await runAutomatedQA({ continuous: true, localUrl: localUrlFlag || undefined, prodUrl: prodUrlFlag || undefined });
|
|
230
|
-
break;
|
|
231
|
-
case 'post-gen':
|
|
232
|
-
await autoRunPostGeneration();
|
|
191
|
+
case 'continuous':
|
|
192
|
+
await runAutomatedQA({ ...urls, continuous: true });
|
|
233
193
|
break;
|
|
234
|
-
case 'history':
|
|
235
|
-
await viewQAHistory();
|
|
236
|
-
p.outro(chalk.hex('#00F5FF').bold('History viewed.'));
|
|
237
|
-
break;
|
|
238
|
-
default:
|
|
239
|
-
p.cancel('Unknown mode.');
|
|
240
|
-
process.exit(1);
|
|
241
194
|
}
|
|
242
195
|
}
|
|
243
196
|
|
|
244
197
|
main().catch(err => {
|
|
245
198
|
if (shuttingDown) return;
|
|
246
199
|
process.stdout.write('\x1b[?25h');
|
|
247
|
-
console.error(chalk.red.bold(`\n
|
|
248
|
-
if (err.stack && !isCI)
|
|
200
|
+
console.error(chalk.red.bold(`\n Fatal: ${err.message || err}`));
|
|
201
|
+
if (err.stack && !isCI) {
|
|
202
|
+
console.error(chalk.gray(err.stack.split('\n').filter(l => !l.includes('node_modules')).slice(0, 5).join('\n')));
|
|
203
|
+
}
|
|
249
204
|
process.exit(1);
|
|
250
205
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-backlist",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.5",
|
|
4
4
|
"description": "An advanced, multi-language backend generator based on frontend analysis. Smart Freemium SaaS CLI with Live QA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
"src",
|
|
13
13
|
"AiModuls"
|
|
14
14
|
],
|
|
15
|
+
|
|
15
16
|
"scripts": {
|
|
16
17
|
"start" : "node bin/index.js",
|
|
17
18
|
"qa" : "node bin/qa.js",
|
|
19
|
+
"qa:scan" : "node bin/qa.js scan --url=http://localhost:3000",
|
|
20
|
+
|
|
21
|
+
"qa:install" : "npx playwright install chromium",
|
|
18
22
|
"qa:auto" : "node bin/qa.js auto",
|
|
19
23
|
"qa:live" : "node bin/qa.js auto --continuous",
|
|
20
24
|
"qa:manual" : "node bin/qa.js manual",
|
|
@@ -26,6 +30,7 @@
|
|
|
26
30
|
"author": "W.A.H.ISHAN",
|
|
27
31
|
"license": "MIT",
|
|
28
32
|
"dependencies": {
|
|
33
|
+
"playwright": "^1.45.0",
|
|
29
34
|
"@babel/parser" : "^7.22.7",
|
|
30
35
|
"@babel/traverse" : "^7.22.8",
|
|
31
36
|
"@clack/prompts" : "^0.7.0",
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Accessibility checker with HTTP fallback
|
|
2
|
+
import { getBrowserLaunchOptions } from '../browser/installer.js';
|
|
3
|
+
|
|
4
|
+
export class AccessibilityChecker {
|
|
5
|
+
#playwright;
|
|
6
|
+
#session;
|
|
7
|
+
|
|
8
|
+
constructor(playwright, session) {
|
|
9
|
+
this.#playwright = playwright;
|
|
10
|
+
this.#session = session;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async check(url) {
|
|
14
|
+
const launchOpts = await getBrowserLaunchOptions();
|
|
15
|
+
|
|
16
|
+
if (!launchOpts.available) {
|
|
17
|
+
return this.#httpFallback(url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let playwright;
|
|
21
|
+
try { playwright = await import('playwright'); }
|
|
22
|
+
catch { return this.#httpFallback(url); }
|
|
23
|
+
|
|
24
|
+
const { executablePath, headless, args } = launchOpts;
|
|
25
|
+
let browser;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
browser = await playwright.chromium.launch({ executablePath, headless, args });
|
|
29
|
+
} catch {
|
|
30
|
+
return this.#httpFallback(url);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
34
|
+
const page = await context.newPage();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
|
|
38
|
+
|
|
39
|
+
await page.addScriptTag({
|
|
40
|
+
url: 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const axeResults = await page.evaluate(async () => {
|
|
44
|
+
return await window.axe.run(document, {
|
|
45
|
+
runOnly: { type: 'tag', values: ['wcag2a','wcag2aa','wcag21aa','best-practice'] },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const violations = axeResults.violations.map(v => ({
|
|
50
|
+
id : v.id,
|
|
51
|
+
description: v.description,
|
|
52
|
+
help : v.help,
|
|
53
|
+
helpUrl : v.helpUrl,
|
|
54
|
+
impact : v.impact,
|
|
55
|
+
tags : v.tags,
|
|
56
|
+
category : v.tags.find(t => t.startsWith('wcag')) || 'best-practice',
|
|
57
|
+
nodes : v.nodes.length,
|
|
58
|
+
affectedNodes: v.nodes.slice(0, 3).map(n => ({ html: n.html, target: n.target, message: n.failureSummary })),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
const passes = axeResults.passes.map(p => ({
|
|
62
|
+
id: p.id, description: p.description, nodes: p.nodes.length,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
pass : violations.length === 0,
|
|
67
|
+
violations, passes,
|
|
68
|
+
incomplete: axeResults.incomplete.map(i => ({ id: i.id, description: i.description, nodes: i.nodes.length })),
|
|
69
|
+
score : passes.length > 0 ? Math.round((passes.length / (passes.length + violations.length)) * 100) : 0,
|
|
70
|
+
url, mode : 'browser-axe',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { pass: false, violations: [], passes: [], incomplete: [], error: err.message, url, mode: 'browser-error' };
|
|
75
|
+
} finally {
|
|
76
|
+
await page.close().catch(() => {});
|
|
77
|
+
await context.close().catch(() => {});
|
|
78
|
+
await browser.close().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// HTTP fallback — parses raw HTML for common a11y issues
|
|
83
|
+
async #httpFallback(url) {
|
|
84
|
+
try {
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => controller.abort(), 12_000);
|
|
87
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
return { pass: false, violations: [], passes: [], url, mode: 'http-fallback', error: `HTTP ${res.status}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const html = await res.text();
|
|
95
|
+
const violations = [];
|
|
96
|
+
const passes = [];
|
|
97
|
+
|
|
98
|
+
// Real HTML analysis checks
|
|
99
|
+
const checks = [
|
|
100
|
+
{
|
|
101
|
+
id : 'html-lang',
|
|
102
|
+
desc : 'HTML element must have a lang attribute',
|
|
103
|
+
help : 'Ensures every HTML document has a lang attribute',
|
|
104
|
+
impact : 'serious',
|
|
105
|
+
test : () => !/<html[^>]+lang=["'][^"']+["']/i.test(html),
|
|
106
|
+
passMsg : 'HTML lang attribute present',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id : 'img-alt',
|
|
110
|
+
desc : 'Images must have alternate text',
|
|
111
|
+
help : 'Ensures <img> elements have alternate text or a role of none/presentation',
|
|
112
|
+
impact : 'critical',
|
|
113
|
+
test : () => /<img(?![^>]*\balt=)[^>]*>/i.test(html),
|
|
114
|
+
passMsg : 'All images have alt attributes',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id : 'document-title',
|
|
118
|
+
desc : 'Documents must have <title>',
|
|
119
|
+
help : 'Ensures every HTML document has a non-empty title element',
|
|
120
|
+
impact : 'serious',
|
|
121
|
+
test : () => !/<title[^>]*>[^<]+<\/title>/i.test(html),
|
|
122
|
+
passMsg : 'Document has a title',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id : 'viewport',
|
|
126
|
+
desc : 'Zoom and scaling must not be disabled',
|
|
127
|
+
help : 'Ensures the viewport meta does not disable text scaling',
|
|
128
|
+
impact : 'critical',
|
|
129
|
+
test : () => /user-scalable=no|maximum-scale=1/i.test(html),
|
|
130
|
+
passMsg : 'Viewport allows scaling',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id : 'region',
|
|
134
|
+
desc : 'Page should have a main landmark',
|
|
135
|
+
help : 'Ensures the page has a <main> element',
|
|
136
|
+
impact : 'moderate',
|
|
137
|
+
test : () => !/<main[^>]*>/i.test(html),
|
|
138
|
+
passMsg : 'Main landmark found',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id : 'heading-order',
|
|
142
|
+
desc : 'Heading levels should not be skipped',
|
|
143
|
+
help : 'Ensures the order of headings is semantically correct',
|
|
144
|
+
impact : 'moderate',
|
|
145
|
+
test : () => !/<h1[^>]*>/i.test(html),
|
|
146
|
+
passMsg : 'H1 heading present',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id : 'label',
|
|
150
|
+
desc : 'Form elements must have labels',
|
|
151
|
+
help : 'Ensures every form element has a label',
|
|
152
|
+
impact : 'critical',
|
|
153
|
+
test : () => /<input(?![^>]*(?:aria-label|aria-labelledby|id=))[^>]*type=(?!"hidden")[^>]*>/i.test(html),
|
|
154
|
+
passMsg : 'Form inputs appear to have labels',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id : 'link-name',
|
|
158
|
+
desc : 'Links must have discernible text',
|
|
159
|
+
help : 'Ensures links have discernible text',
|
|
160
|
+
impact : 'serious',
|
|
161
|
+
test : () => /<a[^>]*>\s*<\/a>/i.test(html),
|
|
162
|
+
passMsg : 'Links appear to have text content',
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
for (const check of checks) {
|
|
167
|
+
if (check.test()) {
|
|
168
|
+
violations.push({
|
|
169
|
+
id : check.id,
|
|
170
|
+
description: check.desc,
|
|
171
|
+
help : check.help,
|
|
172
|
+
impact : check.impact,
|
|
173
|
+
tags : ['wcag2a'],
|
|
174
|
+
category : 'wcag2a',
|
|
175
|
+
nodes : 1,
|
|
176
|
+
affectedNodes: [],
|
|
177
|
+
helpUrl : `https://dequeuniversity.com/rules/axe/4.9/${check.id}`,
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
passes.push({ id: check.id, description: check.passMsg, nodes: 1 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const score = passes.length > 0
|
|
185
|
+
? Math.round((passes.length / (passes.length + violations.length)) * 100)
|
|
186
|
+
: 0;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
pass: violations.length === 0,
|
|
190
|
+
violations, passes, incomplete: [],
|
|
191
|
+
score, url, mode: 'http-html-analysis',
|
|
192
|
+
note: 'Full axe-core WCAG scan requires Playwright browser — run: npx playwright install chromium',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { pass: false, violations: [], passes: [], url, mode: 'http-fallback', error: err.message };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|