create-backlist 10.0.8 → 10.0.9
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/qa/qa-engine.js +1137 -751
package/src/qa/qa-engine.js
CHANGED
|
@@ -1,32 +1,16 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// Backlist Enterprise
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// REAL RUNTIME TESTING — NO FAKE DATA
|
|
6
|
-
// Every result is collected from actual browser execution
|
|
2
|
+
// Backlist Enterprise QA Engine v12.0 — FIXED COMPLETE EDITION
|
|
3
|
+
// 100% Real Runtime Testing · No Fake Data · Live Demo Support
|
|
7
4
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
5
|
|
|
9
|
-
import * as p
|
|
10
|
-
import chalk
|
|
11
|
-
import fs
|
|
12
|
-
import path
|
|
13
|
-
import os
|
|
14
|
-
import
|
|
6
|
+
import * as p from '@clack/prompts';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import fs from 'fs-extra';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import readline from 'node:readline';
|
|
12
|
+
import { performance } from 'node:perf_hooks';
|
|
15
13
|
import { EventEmitter } from 'node:events';
|
|
16
|
-
import readline from 'node:readline';
|
|
17
|
-
|
|
18
|
-
import { SmartCrawler } from './browser/crawler.js';
|
|
19
|
-
import { BrowserInteractor } from './browser/interactions.js';
|
|
20
|
-
import { ScreenshotCapture } from './browser/screenshot.js';
|
|
21
|
-
import { RealAPIValidator } from './analyzers/api.js';
|
|
22
|
-
import { SecurityScanner } from './analyzers/security.js';
|
|
23
|
-
import { PerformanceProfiler } from './analyzers/performance.js';
|
|
24
|
-
import { AccessibilityChecker} from './analyzers/accessibility.js';
|
|
25
|
-
import { SEOScanner } from './analyzers/seo.js';
|
|
26
|
-
import { HTMLReporter } from './reporters/html.js';
|
|
27
|
-
import { TerminalDashboard } from './reporters/terminal.js';
|
|
28
|
-
import { JSONReporter } from './reporters/json.js';
|
|
29
|
-
import { AIClassifier } from './utils/ai-classifier.js';
|
|
30
14
|
|
|
31
15
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
32
16
|
export const VERSION = '12.0.0';
|
|
@@ -36,42 +20,37 @@ export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
|
|
|
36
20
|
export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
|
|
37
21
|
|
|
38
22
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
39
|
-
export
|
|
40
|
-
export
|
|
41
|
-
export
|
|
42
|
-
export
|
|
23
|
+
export const timestamp = () => new Date().toISOString();
|
|
24
|
+
export const shortId = () => Math.random().toString(36).slice(2, 9);
|
|
25
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
26
|
+
export const formatDuration = (ms) => {
|
|
27
|
+
if (!ms || ms < 0) return '0ms';
|
|
43
28
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
44
29
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
45
|
-
}
|
|
46
|
-
export
|
|
47
|
-
if (!b || b < 0)
|
|
48
|
-
if (b < 1024)
|
|
49
|
-
if (b < 1024 * 1024)
|
|
30
|
+
};
|
|
31
|
+
export const formatBytes = (b) => {
|
|
32
|
+
if (!b || b < 0) return '0B';
|
|
33
|
+
if (b < 1024) return `${b}B`;
|
|
34
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
|
|
50
35
|
return `${(b / 1024 / 1024).toFixed(1)}MB`;
|
|
51
|
-
}
|
|
36
|
+
};
|
|
52
37
|
|
|
53
|
-
// ──
|
|
54
|
-
function
|
|
38
|
+
// ── Safe readline prompt (fixes: await inside non-async Promise) ──────────
|
|
39
|
+
function askYesNo(question) {
|
|
55
40
|
return new Promise((resolve) => {
|
|
56
|
-
const rl = readline.createInterface({
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
// Auto-resolve after 10s if no input
|
|
61
|
-
const timer = setTimeout(() => {
|
|
62
|
-
rl.close();
|
|
63
|
-
resolve(false);
|
|
64
|
-
}, 10_000);
|
|
65
|
-
|
|
66
|
-
rl.question(question, (answer) => {
|
|
41
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
const timer = setTimeout(() => { rl.close(); resolve(false); }, 10_000);
|
|
43
|
+
rl.question(question, (ans) => {
|
|
67
44
|
clearTimeout(timer);
|
|
68
45
|
rl.close();
|
|
69
|
-
resolve(
|
|
46
|
+
resolve(ans.toLowerCase().trim() === 'y');
|
|
70
47
|
});
|
|
71
48
|
});
|
|
72
49
|
}
|
|
73
50
|
|
|
74
|
-
//
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
+
// QA Session — stores ALL real runtime data
|
|
53
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
75
54
|
export class QASession {
|
|
76
55
|
id;
|
|
77
56
|
startedAt;
|
|
@@ -88,17 +67,14 @@ export class QASession {
|
|
|
88
67
|
a11yResults = [];
|
|
89
68
|
seoResults = [];
|
|
90
69
|
|
|
91
|
-
constructor(urls) {
|
|
92
|
-
this.id = `QA-${shortId()}`;
|
|
70
|
+
constructor(urls = {}) {
|
|
71
|
+
this.id = `QA-${shortId().toUpperCase()}`;
|
|
93
72
|
this.startedAt = timestamp();
|
|
94
73
|
this.urls = urls;
|
|
95
74
|
}
|
|
96
75
|
|
|
97
|
-
addResult(
|
|
98
|
-
|
|
99
|
-
addBug(bug) {
|
|
100
|
-
this.bugs.push({ ...bug, id: `BUG-${shortId()}`, createdAt: timestamp() });
|
|
101
|
-
}
|
|
76
|
+
addResult(r) { this.results.push(r); }
|
|
77
|
+
addBug(bug) { this.bugs.push({ ...bug, id: `BUG-${shortId().toUpperCase()}`, createdAt: timestamp() }); }
|
|
102
78
|
|
|
103
79
|
getSummary() {
|
|
104
80
|
const passed = this.results.filter(r => r.status === 'PASS').length;
|
|
@@ -114,639 +90,1082 @@ export class QASession {
|
|
|
114
90
|
}
|
|
115
91
|
}
|
|
116
92
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
// HTTP Probe — real HTTP requests, no mocking
|
|
95
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
|
+
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
|
|
97
|
+
const t0 = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
const ctrl = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
101
|
+
const res = await fetch(url, {
|
|
102
|
+
method,
|
|
103
|
+
signal : ctrl.signal,
|
|
104
|
+
headers : { 'User-Agent': 'Backlist-QA/12.0', Accept: '*/*', ...headers },
|
|
105
|
+
redirect: 'follow',
|
|
106
|
+
});
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
|
|
109
|
+
const rt = Date.now() - t0;
|
|
110
|
+
const contentType = res.headers.get('content-type') || '';
|
|
111
|
+
const hdrs = {};
|
|
112
|
+
res.headers.forEach((v, k) => { hdrs[k] = v; });
|
|
113
|
+
|
|
114
|
+
let body = '', bodySize = 0;
|
|
115
|
+
try { body = await res.text(); bodySize = new TextEncoder().encode(body).length; } catch {}
|
|
116
|
+
|
|
117
|
+
let parsed = null;
|
|
118
|
+
if (contentType.includes('json')) { try { parsed = JSON.parse(body); } catch {} }
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
ok: res.status >= 200 && res.status < 400,
|
|
122
|
+
status: res.status, contentType, headers: hdrs,
|
|
123
|
+
body: body.slice(0, 3000), parsed, bodySize,
|
|
124
|
+
responseTime: rt, url, method,
|
|
125
|
+
error: null,
|
|
126
|
+
};
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false, status: 0, contentType: '', headers: {},
|
|
130
|
+
body: '', parsed: null, bodySize: 0,
|
|
131
|
+
responseTime: Date.now() - t0, url, method,
|
|
132
|
+
error: err.message,
|
|
133
|
+
};
|
|
138
134
|
}
|
|
135
|
+
}
|
|
139
136
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
137
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
138
|
+
// Route Crawler — real HTTP crawl, discovers all pages & APIs
|
|
139
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
140
|
+
async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
141
|
+
const visited = new Set();
|
|
142
|
+
const queue = [{ url: baseUrl, depth: 0 }];
|
|
143
|
+
const routes = [];
|
|
144
|
+
|
|
145
|
+
const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
|
|
146
|
+
const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
|
|
147
|
+
|
|
148
|
+
while (queue.length > 0 && routes.length < maxPages) {
|
|
149
|
+
const { url, depth } = queue.shift();
|
|
150
|
+
const n = norm(url);
|
|
151
|
+
if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
|
|
152
|
+
visited.add(n);
|
|
153
|
+
|
|
154
|
+
const r = await httpProbe(n, { timeout: 10000 });
|
|
155
|
+
const type = (() => {
|
|
156
|
+
if (r.status >= 400) return 'error-page';
|
|
157
|
+
if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
|
|
158
|
+
if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
|
|
159
|
+
if (/\/(login|signin|auth)/i.test(n)) return 'auth';
|
|
160
|
+
if (/\/(admin)/i.test(n)) return 'admin';
|
|
161
|
+
return 'page';
|
|
162
|
+
})();
|
|
163
|
+
|
|
164
|
+
// Extract links from HTML
|
|
165
|
+
const links = [];
|
|
166
|
+
if (r.contentType.includes('text/html')) {
|
|
167
|
+
const re = /href=["']([^"'#?][^"']*?)["']/gi;
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = re.exec(r.body)) !== null) {
|
|
170
|
+
try { links.push(new URL(m[1], n).toString()); } catch {}
|
|
171
|
+
}
|
|
148
172
|
}
|
|
149
173
|
|
|
150
|
-
//
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const shouldInstall = await askQuestion(
|
|
164
|
-
chalk.cyan(' Install Playwright browser now? (y/N): ')
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
if (shouldInstall) {
|
|
168
|
-
const result = await installPlaywrightBrowsers();
|
|
169
|
-
if (!result.success) {
|
|
170
|
-
console.log(chalk.yellow(' Auto-install failed. Continuing in HTTP-only mode.\n'));
|
|
171
|
-
}
|
|
174
|
+
// Extract forms
|
|
175
|
+
const forms = [];
|
|
176
|
+
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
177
|
+
let fm;
|
|
178
|
+
while ((fm = formRe.exec(r.body)) !== null) {
|
|
179
|
+
const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
|
|
180
|
+
const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
|
|
181
|
+
const fields = [];
|
|
182
|
+
const ir = /<input([^>]*)>/gi; let inp;
|
|
183
|
+
while ((inp = ir.exec(fm[2])) !== null) {
|
|
184
|
+
const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
|
|
185
|
+
const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
|
|
186
|
+
if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
|
|
172
187
|
}
|
|
173
|
-
|
|
174
|
-
const exeName = launchOpts.executablePath?.split(/[/\\]/).pop() ?? 'chromium';
|
|
175
|
-
console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${exeName})`));
|
|
188
|
+
forms.push({ action, method, fields });
|
|
176
189
|
}
|
|
177
190
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
this.#apiValidator = new RealAPIValidator(this.#session);
|
|
182
|
-
this.#security = new SecurityScanner(this.#session);
|
|
183
|
-
this.#performance = new PerformanceProfiler(this.#session);
|
|
184
|
-
this.#a11y = new AccessibilityChecker(playwright, this.#session);
|
|
185
|
-
this.#seo = new SEOScanner(this.#session);
|
|
186
|
-
this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
|
|
187
|
-
this.#aiClassifier = new AIClassifier();
|
|
188
|
-
|
|
189
|
-
await this.#interactor.launch();
|
|
190
|
-
await this.#screenshotter.init();
|
|
191
|
-
}
|
|
191
|
+
const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error };
|
|
192
|
+
routes.push(route);
|
|
193
|
+
if (onRoute) onRoute(route);
|
|
192
194
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
for (const link of links.slice(0, 20)) {
|
|
196
|
+
const ln = norm(link);
|
|
197
|
+
if (ln && !visited.has(ln) && sameOrigin(ln)) queue.push({ url: ln, depth: depth + 1 });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
196
200
|
|
|
201
|
+
// Probe common API endpoints
|
|
202
|
+
const commonPaths = ['/api/health','/health','/api/status','/api/v1/health','/api/docs','/robots.txt','/sitemap.xml'];
|
|
203
|
+
for (const p2 of commonPaths) {
|
|
197
204
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
await
|
|
205
|
+
const u = new URL(p2, baseUrl).toString();
|
|
206
|
+
const n = norm(u);
|
|
207
|
+
if (visited.has(n)) continue;
|
|
208
|
+
visited.add(n);
|
|
209
|
+
const r = await httpProbe(u, { timeout: 5000 });
|
|
210
|
+
if (r.status > 0 && r.status < 500) {
|
|
211
|
+
const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
|
|
212
|
+
routes.push(route);
|
|
213
|
+
if (onRoute) onRoute(route);
|
|
214
|
+
}
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
203
217
|
|
|
204
|
-
|
|
205
|
-
|
|
218
|
+
return routes;
|
|
219
|
+
}
|
|
206
220
|
|
|
207
|
-
|
|
208
|
-
|
|
221
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
+
// Security Scanner — real HTTP header analysis
|
|
223
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
224
|
+
async function runSecurityScan(url) {
|
|
225
|
+
const findings = [];
|
|
226
|
+
const r = await httpProbe(url);
|
|
209
227
|
|
|
210
|
-
|
|
211
|
-
|
|
228
|
+
if (!r.ok && r.status === 0) {
|
|
229
|
+
return [{ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
|
|
230
|
+
detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running' }];
|
|
231
|
+
}
|
|
212
232
|
|
|
213
|
-
|
|
214
|
-
|
|
233
|
+
const h = r.headers;
|
|
234
|
+
|
|
235
|
+
const headerChecks = [
|
|
236
|
+
{ id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
|
|
237
|
+
validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
|
|
238
|
+
{ id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
|
|
239
|
+
validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
|
|
240
|
+
{ id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
|
|
241
|
+
validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
|
|
242
|
+
{ id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
|
|
243
|
+
validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
|
|
244
|
+
{ id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
|
|
245
|
+
validate: v => !!v, rec: 'Add Referrer-Policy header' },
|
|
246
|
+
{ id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
|
|
247
|
+
validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
|
|
248
|
+
{ id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
|
|
249
|
+
validate: v => !v, rec: 'Remove X-Powered-By (app.disable("x-powered-by"))' },
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
for (const c of headerChecks) {
|
|
253
|
+
const val = h[c.header] || '';
|
|
254
|
+
const pass = c.validate(val);
|
|
255
|
+
findings.push({ check: c.name, pass, severity: pass ? 'INFO' : c.sev,
|
|
256
|
+
category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
|
|
257
|
+
recommendation: c.rec, evidence: { header: c.header, value: val || null } });
|
|
258
|
+
}
|
|
215
259
|
|
|
216
|
-
|
|
217
|
-
|
|
260
|
+
// HTTPS check
|
|
261
|
+
const isHTTPS = url.startsWith('https://');
|
|
262
|
+
findings.push({ check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
|
|
263
|
+
category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted traffic',
|
|
264
|
+
recommendation: 'Use HTTPS with valid SSL', evidence: { protocol: new URL(url).protocol } });
|
|
265
|
+
|
|
266
|
+
// CORS wildcard check
|
|
267
|
+
const corsOrigin = h['access-control-allow-origin'];
|
|
268
|
+
const corsCreds = h['access-control-allow-credentials'];
|
|
269
|
+
const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
|
|
270
|
+
findings.push({ check: 'CORS wildcard + credentials', pass: corsPass,
|
|
271
|
+
severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
272
|
+
detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
|
|
273
|
+
recommendation: 'Never combine CORS * with allow-credentials',
|
|
274
|
+
evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds } });
|
|
275
|
+
|
|
276
|
+
// Probe sensitive paths
|
|
277
|
+
const base = new URL(url).origin;
|
|
278
|
+
const sensitives = [
|
|
279
|
+
{ path: '/.env', name: '.env exposed' },
|
|
280
|
+
{ path: '/.git/config', name: 'Git config exposed' },
|
|
281
|
+
{ path: '/phpinfo.php', name: 'phpinfo exposed' },
|
|
282
|
+
{ path: '/server-status', name: 'Apache server-status' },
|
|
283
|
+
{ path: '/actuator', name: 'Spring actuator exposed' },
|
|
284
|
+
{ path: '/graphql', name: 'GraphQL introspection' },
|
|
285
|
+
];
|
|
286
|
+
for (const s of sensitives) {
|
|
287
|
+
try {
|
|
288
|
+
const ctrl = new AbortController();
|
|
289
|
+
const timer = setTimeout(() => ctrl.abort(), 4000);
|
|
290
|
+
const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
const exposed = res.status === 200;
|
|
293
|
+
findings.push({ check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
|
|
294
|
+
category: 'information-disclosure',
|
|
295
|
+
detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
|
|
296
|
+
recommendation: exposed ? `Block access to ${s.path} immediately` : null,
|
|
297
|
+
evidence: { url: `${base}${s.path}`, status: res.status } });
|
|
298
|
+
} catch {}
|
|
299
|
+
}
|
|
218
300
|
|
|
219
|
-
|
|
220
|
-
|
|
301
|
+
return findings;
|
|
302
|
+
}
|
|
221
303
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
304
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
305
|
+
// SEO Scanner — real HTML parsing with Googlebot UA
|
|
306
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
307
|
+
async function runSEOScan(url) {
|
|
308
|
+
const t0 = Date.now();
|
|
309
|
+
const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
|
|
310
|
+
const html = r.body || '';
|
|
311
|
+
const rt = Date.now() - t0;
|
|
312
|
+
const checks = [];
|
|
313
|
+
|
|
314
|
+
const has = (p) => p.test(html);
|
|
315
|
+
const get = (p) => (html.match(p) || [])[1]?.trim() || null;
|
|
316
|
+
|
|
317
|
+
const title = get(/<title[^>]*>([^<]+)<\/title>/i);
|
|
318
|
+
checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
|
|
319
|
+
detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>', data: { title, length: title?.length },
|
|
320
|
+
recommendation: 'Add unique title (50-60 chars)' });
|
|
321
|
+
|
|
322
|
+
if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
|
|
323
|
+
severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)`,
|
|
324
|
+
recommendation: 'Keep title 30-60 chars' });
|
|
325
|
+
|
|
326
|
+
const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
|
327
|
+
|| get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
328
|
+
checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
|
|
329
|
+
detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description',
|
|
330
|
+
recommendation: 'Add meta description (120-160 chars)' });
|
|
331
|
+
|
|
332
|
+
const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
|
|
333
|
+
checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
|
|
334
|
+
detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : '1 H1 ✓',
|
|
335
|
+
recommendation: 'Use exactly one H1 per page' });
|
|
336
|
+
|
|
337
|
+
const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
|
|
338
|
+
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
|
|
339
|
+
detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
|
|
340
|
+
recommendation: 'Add <meta name="viewport" content="width=device-width,initial-scale=1">' });
|
|
341
|
+
|
|
342
|
+
const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
|
|
343
|
+
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
|
|
344
|
+
detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
|
|
345
|
+
|
|
346
|
+
const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
|
|
347
|
+
checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
|
|
348
|
+
detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
|
|
349
|
+
recommendation: 'Add <link rel="canonical">' });
|
|
350
|
+
|
|
351
|
+
const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
|
|
352
|
+
checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
|
|
353
|
+
detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
|
|
354
|
+
recommendation: 'Add og:title, og:description, og:image' });
|
|
355
|
+
|
|
356
|
+
const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
|
|
357
|
+
const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
|
|
358
|
+
checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
|
|
359
|
+
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
|
|
360
|
+
recommendation: 'Add alt text to all images' });
|
|
361
|
+
|
|
362
|
+
checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
|
|
363
|
+
category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
|
|
364
|
+
recommendation: 'Optimize TTFB with CDN and caching' });
|
|
365
|
+
|
|
366
|
+
// robots.txt & sitemap
|
|
367
|
+
const base = new URL(url).origin;
|
|
368
|
+
for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
|
|
369
|
+
try {
|
|
370
|
+
const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
|
|
371
|
+
checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
|
|
372
|
+
detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}`,
|
|
373
|
+
recommendation: `Ensure ${name} exists` });
|
|
374
|
+
} catch {
|
|
375
|
+
checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
|
|
228
376
|
}
|
|
377
|
+
}
|
|
229
378
|
|
|
230
|
-
|
|
379
|
+
return { pass: checks.filter(c=>!c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
383
|
+
// Performance Profiler — real HTTP TTFB + resource timing
|
|
384
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
385
|
+
async function runPerfProfile(url) {
|
|
386
|
+
const t0 = Date.now();
|
|
387
|
+
const r = await httpProbe(url, { timeout: 15000 });
|
|
388
|
+
const ttfb = Date.now() - t0;
|
|
389
|
+
|
|
390
|
+
const slowResources = [];
|
|
391
|
+
if (ttfb > 3000) slowResources.push({ url, duration: ttfb, size: r.bodySize, type: 'document' });
|
|
392
|
+
|
|
393
|
+
// Parse resource hints from HTML
|
|
394
|
+
const resourceUrls = [];
|
|
395
|
+
if (r.contentType.includes('text/html')) {
|
|
396
|
+
const scriptRe = /src=["']([^"']+\.(?:js|css))["']/gi;
|
|
397
|
+
let m;
|
|
398
|
+
while ((m = scriptRe.exec(r.body)) !== null) {
|
|
399
|
+
try { resourceUrls.push(new URL(m[1], url).toString()); } catch {}
|
|
400
|
+
}
|
|
401
|
+
for (const ru of resourceUrls.slice(0, 5)) {
|
|
402
|
+
const t1 = Date.now();
|
|
403
|
+
const rr = await httpProbe(ru, { timeout: 8000 });
|
|
404
|
+
const dur = Date.now() - t1;
|
|
405
|
+
if (dur > 1000) slowResources.push({ url: ru, duration: dur, size: rr.bodySize, type: ru.endsWith('.css') ? 'stylesheet' : 'script' });
|
|
406
|
+
}
|
|
231
407
|
}
|
|
232
408
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
409
|
+
return {
|
|
410
|
+
ttfb, totalTime: ttfb, bodySize: r.bodySize,
|
|
411
|
+
statusCode: r.status, slowResources,
|
|
412
|
+
lcp: null, fcp: null, cls: null, fid: null, tbt: null,
|
|
413
|
+
resourceTimings: [],
|
|
414
|
+
url, mode: 'http',
|
|
415
|
+
note: 'LCP/FCP/CLS require Playwright — run: npx playwright install chromium',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
420
|
+
// Accessibility Scanner — real HTML analysis + axe-core hint
|
|
421
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
422
|
+
async function runA11yScan(url) {
|
|
423
|
+
const r = await httpProbe(url, { timeout: 12000 });
|
|
424
|
+
const html = r.body || '';
|
|
425
|
+
const violations = [], passes = [];
|
|
426
|
+
|
|
427
|
+
const checks = [
|
|
428
|
+
{ id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
|
|
429
|
+
{ id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
|
|
430
|
+
{ id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
|
|
431
|
+
{ id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
|
|
432
|
+
{ id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
|
|
433
|
+
{ id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
|
|
434
|
+
{ id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
|
|
435
|
+
{ id: 'form-labels', impact: 'critical', test: () => /<input(?![^>]*(?:aria-label|aria-labelledby|id=))[^>]*type=(?!"hidden")[^>]*>/i.test(html), pass: 'Form inputs have labels', desc: 'Form elements must have labels' },
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
for (const c of checks) {
|
|
439
|
+
if (c.test()) {
|
|
440
|
+
violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
|
|
441
|
+
tags: ['wcag2a'], category: 'wcag2a', nodes: 1, affectedNodes: [],
|
|
442
|
+
helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
|
|
443
|
+
} else {
|
|
444
|
+
passes.push({ id: c.id, description: c.pass, nodes: 1 });
|
|
259
445
|
}
|
|
260
|
-
return this.#session;
|
|
261
446
|
}
|
|
262
447
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
448
|
+
const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
|
|
449
|
+
return { pass: violations.length === 0, violations, passes, incomplete: [], score, url, mode: 'http-html-analysis' };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
453
|
+
// AI Bug Classifier — local pattern matching (no external API needed)
|
|
454
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
455
|
+
const SEV_PATTERNS = {
|
|
456
|
+
P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
|
|
457
|
+
P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
|
|
458
|
+
P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
|
|
459
|
+
P3: [/warning|minor|style|typo|cosmetic/i],
|
|
460
|
+
};
|
|
461
|
+
const CAT_PATTERNS = {
|
|
462
|
+
security: /security|csp|hsts|cors|xss|injection|auth|token/i,
|
|
463
|
+
performance: /lcp|fcp|cls|ttfb|slow|timeout|render/i,
|
|
464
|
+
accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
|
|
465
|
+
seo: /title|meta|description|canonical|sitemap|robots/i,
|
|
466
|
+
api: /api|endpoint|status.*code|response|rest/i,
|
|
467
|
+
javascript: /js.*error|console.*error|uncaught|undefined|null/i,
|
|
468
|
+
network: /network|fetch|connection|request.*fail/i,
|
|
469
|
+
};
|
|
470
|
+
function classifyBug(bug) {
|
|
471
|
+
const text = `${bug.title} ${bug.description || ''}`;
|
|
472
|
+
let severity = bug.severity || 'P3', confidence = 0.7;
|
|
473
|
+
for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
|
|
474
|
+
if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.85; break; }
|
|
475
|
+
}
|
|
476
|
+
let category = bug.type || 'general';
|
|
477
|
+
for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
|
|
478
|
+
if (pat.test(text)) { category = cat; break; }
|
|
267
479
|
}
|
|
480
|
+
const recs = {
|
|
481
|
+
security: 'Review security config and run penetration test',
|
|
482
|
+
performance: 'Run Lighthouse and optimize assets/server',
|
|
483
|
+
accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
|
|
484
|
+
seo: 'Fix meta tags and submit sitemap to Search Console',
|
|
485
|
+
api: 'Check API contract and add proper error handling',
|
|
486
|
+
javascript: 'Debug in browser DevTools, add error boundaries',
|
|
487
|
+
network: 'Check CDN, server logs, network config',
|
|
488
|
+
};
|
|
489
|
+
return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
|
|
490
|
+
}
|
|
268
491
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const routes = await this.#crawler.crawl(url, {
|
|
276
|
-
maxPages: 60,
|
|
277
|
-
maxDepth: 4,
|
|
278
|
-
onRoute : (route) => {
|
|
279
|
-
this.#session.routeMap.push(route);
|
|
280
|
-
this.#terminal.log(` Found: ${route.url} (${route.type})`);
|
|
281
|
-
},
|
|
282
|
-
});
|
|
492
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
+
// Terminal Dashboard — live real-time display
|
|
494
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
495
|
+
class TerminalDashboard {
|
|
496
|
+
#session; #lines = 0; #active = false; #timer = null;
|
|
497
|
+
#phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
|
|
283
498
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
: 'No routes discovered — site may be unreachable',
|
|
292
|
-
data : { routeCount: routes.length },
|
|
293
|
-
url, label,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
499
|
+
constructor(s) { this.#session = s; }
|
|
500
|
+
|
|
501
|
+
start() {
|
|
502
|
+
this.#active = true; this.#startTime = Date.now();
|
|
503
|
+
process.stdout.write('\x1b[?25l');
|
|
504
|
+
this.#render();
|
|
505
|
+
this.#timer = setInterval(() => this.#render(), 600);
|
|
296
506
|
}
|
|
297
507
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
);
|
|
508
|
+
stop() {
|
|
509
|
+
this.#active = false;
|
|
510
|
+
if (this.#timer) { clearInterval(this.#timer); this.#timer = null; }
|
|
511
|
+
this.#clear();
|
|
512
|
+
process.stdout.write('\x1b[?25h');
|
|
513
|
+
this.#printFinal();
|
|
514
|
+
}
|
|
303
515
|
|
|
304
|
-
|
|
516
|
+
setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
|
|
517
|
+
setCurrentTest(t) { this.#currentTest = t; }
|
|
518
|
+
addResult() { this.#currentTest = ''; }
|
|
519
|
+
log(msg) {
|
|
520
|
+
this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
|
|
521
|
+
if (this.#log.length > 8) this.#log.shift();
|
|
522
|
+
}
|
|
305
523
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
this.#addResult({
|
|
314
|
-
name : `API: ${route.url}`,
|
|
315
|
-
type : 'api',
|
|
316
|
-
category: 'api-validation',
|
|
317
|
-
status : result.pass ? 'PASS' : 'FAIL',
|
|
318
|
-
message : result.message,
|
|
319
|
-
data : {
|
|
320
|
-
statusCode : result.statusCode,
|
|
321
|
-
responseTime: result.responseTime,
|
|
322
|
-
contentType : result.contentType,
|
|
323
|
-
body : result.body?.slice(0, 500),
|
|
324
|
-
headers : result.headers,
|
|
325
|
-
},
|
|
326
|
-
url : route.url,
|
|
327
|
-
duration: result.responseTime,
|
|
328
|
-
});
|
|
524
|
+
#render() {
|
|
525
|
+
if (!this.#active) return;
|
|
526
|
+
this.#clear();
|
|
527
|
+
const lines = this.#build();
|
|
528
|
+
this.#lines = lines.length;
|
|
529
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
530
|
+
}
|
|
329
531
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
description: result.message,
|
|
336
|
-
evidence : result,
|
|
337
|
-
});
|
|
338
|
-
}
|
|
532
|
+
#clear() {
|
|
533
|
+
if (this.#lines > 0) {
|
|
534
|
+
process.stdout.write(`\x1b[${this.#lines}A`);
|
|
535
|
+
for (let i = 0; i < this.#lines; i++) process.stdout.write('\x1b[2K\n');
|
|
536
|
+
process.stdout.write(`\x1b[${this.#lines}A`);
|
|
339
537
|
}
|
|
538
|
+
this.#lines = 0;
|
|
539
|
+
}
|
|
340
540
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
541
|
+
#build() {
|
|
542
|
+
const s = this.#session;
|
|
543
|
+
const elapsed = ((Date.now() - this.#startTime) / 1000).toFixed(1);
|
|
544
|
+
const passed = s.results.filter(r => r.status === 'PASS' || r.status === 'FLAKY').length;
|
|
545
|
+
const failed = s.results.filter(r => r.status === 'FAIL').length;
|
|
546
|
+
const total = s.results.length;
|
|
547
|
+
const rate = total > 0 ? Math.round(passed / total * 100) : 0;
|
|
548
|
+
const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
|
|
549
|
+
const w = Math.min(process.stdout.columns || 80, 88);
|
|
550
|
+
const bar = '─'.repeat(w - 2);
|
|
551
|
+
const c1 = chalk.hex('#00F5FF');
|
|
552
|
+
const c2 = chalk.hex('#BF40FF');
|
|
553
|
+
const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
|
|
554
|
+
|
|
555
|
+
const pBar = (() => {
|
|
556
|
+
const f = Math.min(Math.round(rate / 100 * 26), 26);
|
|
557
|
+
const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
|
|
558
|
+
return col('█'.repeat(f)) + chalk.gray('░'.repeat(26 - f));
|
|
559
|
+
})();
|
|
560
|
+
|
|
561
|
+
const out = [
|
|
562
|
+
c1(`┌${bar}┐`),
|
|
563
|
+
c1('│') + c2.bold(pad(` ⚡ BACKLIST ENTERPRISE QA v${VERSION} — REAL RUNTIME TESTING`)) + c1('│'),
|
|
564
|
+
c1(`├${bar}┤`),
|
|
565
|
+
c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
|
|
566
|
+
c1(`├${bar}┤`),
|
|
567
|
+
c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
|
|
568
|
+
c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1('│'),
|
|
569
|
+
c1(`├${bar}┤`),
|
|
570
|
+
c1('│') + pad(this.#currentTest ? ` ${chalk.yellow('⟳')} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Running...')}`) + c1('│'),
|
|
571
|
+
c1(`├${bar}┤`),
|
|
572
|
+
c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('APIs:')} ${chalk.white(s.apiLog.length)} ${chalk.cyan('Bugs:')} ${chalk.white(s.bugs.length)} ${chalk.cyan('Screenshots:')} ${chalk.white(s.screenshots.length)}`) + c1('│'),
|
|
573
|
+
c1(`├${bar}┤`),
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
const recent = s.results.slice(-5);
|
|
577
|
+
for (const r of recent) {
|
|
578
|
+
const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
|
|
579
|
+
out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(12) + ']')} ${chalk.white((r.name||'').slice(0, w - 30))}`) + c1('│'));
|
|
348
580
|
}
|
|
349
|
-
|
|
581
|
+
for (let i = recent.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
350
582
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
);
|
|
583
|
+
out.push(c1(`├${bar}┤`));
|
|
584
|
+
for (const entry of this.#log.slice(-4)) {
|
|
585
|
+
out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
|
|
586
|
+
}
|
|
587
|
+
for (let i = this.#log.length; i < 4; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
588
|
+
out.push(c1(`└${bar}┘`));
|
|
589
|
+
out.push(chalk.dim(` Real runtime data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
|
|
356
590
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
this.#terminal.setCurrentTest(`Browser: ${route.url}`);
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
360
593
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
594
|
+
#printFinal() {
|
|
595
|
+
const s = this.#session.getSummary();
|
|
596
|
+
const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
|
|
597
|
+
console.log('');
|
|
598
|
+
console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
|
|
599
|
+
console.log(` Tests: ${chalk.white.bold(s.total)}`);
|
|
600
|
+
console.log(` Passed: ${chalk.green.bold(s.passed)}`);
|
|
601
|
+
console.log(` Failed: ${chalk.red.bold(s.failed)}`);
|
|
602
|
+
console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
|
|
603
|
+
console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
|
|
604
|
+
console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
|
|
605
|
+
console.log(` Routes: ${chalk.white(this.#session.routeMap.length)} discovered`);
|
|
606
|
+
console.log('');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
369
609
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
610
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
611
|
+
// HTML Report Builder — stunning dark theme, 100% real data
|
|
612
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
613
|
+
function buildHTMLReport(session) {
|
|
614
|
+
const summary = session.getSummary();
|
|
615
|
+
const passRate = Number(summary.passRate);
|
|
616
|
+
const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
|
|
617
|
+
|
|
618
|
+
const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
619
|
+
session.bugs.forEach(b => { if (sevCounts[(b.aiSeverity||b.severity)] !== undefined) sevCounts[b.aiSeverity||b.severity]++; });
|
|
620
|
+
|
|
621
|
+
const coverage = {};
|
|
622
|
+
for (const r of session.results) {
|
|
623
|
+
if (!coverage[r.type]) coverage[r.type] = { pass: 0, fail: 0 };
|
|
624
|
+
if (r.status === 'PASS' || r.status === 'FLAKY') coverage[r.type].pass++;
|
|
625
|
+
else if (r.status === 'FAIL') coverage[r.type].fail++;
|
|
626
|
+
}
|
|
384
627
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
628
|
+
const esc = (s) => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
629
|
+
|
|
630
|
+
const testRows = session.results.map(r => `
|
|
631
|
+
<tr class="result-row" data-type="${r.type}" data-status="${r.status}">
|
|
632
|
+
<td>${esc(r.name)}</td>
|
|
633
|
+
<td><span class="badge">${r.type}</span></td>
|
|
634
|
+
<td><span class="status status-${(r.status||'').toLowerCase()}">${r.status}</span></td>
|
|
635
|
+
<td>${r.severity ? `<span class="sev sev-${(r.severity||'').toLowerCase()}">${r.severity}</span>` : '–'}</td>
|
|
636
|
+
<td>${r.duration ? formatDuration(r.duration) : '–'}</td>
|
|
637
|
+
<td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
|
|
638
|
+
</tr>`).join('');
|
|
639
|
+
|
|
640
|
+
const bugCards = session.bugs.length ? session.bugs.map(b => `
|
|
641
|
+
<div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">
|
|
642
|
+
<div class="bug-header">
|
|
643
|
+
<span class="bug-id">${esc(b.id)}</span>
|
|
644
|
+
<span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
|
|
645
|
+
<span class="badge">${b.type||'general'}</span>
|
|
646
|
+
${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
|
|
647
|
+
</div>
|
|
648
|
+
<div class="bug-title">${esc(b.title)}</div>
|
|
649
|
+
${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
|
|
650
|
+
${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
|
|
651
|
+
${b.evidence ? `<details><summary>Evidence</summary><pre>${esc(JSON.stringify(b.evidence,null,2).slice(0,800))}</pre></details>` : ''}
|
|
652
|
+
</div>`).join('') : '<p class="no-data">No bugs detected 🎉</p>';
|
|
653
|
+
|
|
654
|
+
const routeRows = session.routeMap.map(r => `
|
|
655
|
+
<tr>
|
|
656
|
+
<td><code class="url">${esc(r.url)}</code></td>
|
|
657
|
+
<td><span class="badge">${r.type}</span></td>
|
|
658
|
+
<td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
|
|
659
|
+
<td>${r.forms?.length || 0}</td>
|
|
660
|
+
<td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
|
|
661
|
+
</tr>`).join('');
|
|
662
|
+
|
|
663
|
+
const secRows = session.secFindings.map(f => `
|
|
664
|
+
<tr class="${f.pass ? '' : 'fail-row'}">
|
|
665
|
+
<td>${esc(f.check)}</td>
|
|
666
|
+
<td><span class="badge">${f.category}</span></td>
|
|
667
|
+
<td><span class="status ${f.pass ? 'status-pass' : 'status-fail'}">${f.pass?'PASS':'FAIL'}</span></td>
|
|
668
|
+
<td>${f.severity !== 'INFO' ? `<span class="sev sev-${(f.severity||'').toLowerCase()}">${f.severity}</span>` : '–'}</td>
|
|
669
|
+
<td>${esc((f.detail||'').slice(0,120))}</td>
|
|
670
|
+
<td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
|
|
671
|
+
</tr>`).join('');
|
|
672
|
+
|
|
673
|
+
const seoSection = session.seoResults.map(r => `
|
|
674
|
+
<div class="seo-page">
|
|
675
|
+
<div class="seo-header"><a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
676
|
+
<span>${r.checks.filter(c=>c.pass).length}/${r.checks.length} passed</span></div>
|
|
677
|
+
<table>
|
|
678
|
+
<thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr></thead>
|
|
679
|
+
<tbody>${(r.checks||[]).map(c => `<tr><td>${esc(c.name)}</td><td>${c.category||'–'}</td>
|
|
680
|
+
<td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
|
|
681
|
+
<td>${esc((c.detail||'').slice(0,100))}</td></tr>`).join('')}</tbody>
|
|
682
|
+
</table>
|
|
683
|
+
</div>`).join('') || '<p class="no-data">No SEO scans</p>';
|
|
684
|
+
|
|
685
|
+
const a11ySection = session.a11yResults.map(r => `
|
|
686
|
+
<div class="a11y-page">
|
|
687
|
+
<div class="a11y-header"><a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
688
|
+
<span>Score: <strong>${r.score??'–'}%</strong></span>
|
|
689
|
+
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span></div>
|
|
690
|
+
${(r.violations||[]).map(v => `
|
|
691
|
+
<div class="violation impact-${v.impact}">
|
|
692
|
+
<div class="violation-header"><span class="impact-badge">${v.impact}</span>
|
|
693
|
+
<strong>${esc(v.description)}</strong></div>
|
|
694
|
+
<p>${esc(v.help)}</p>
|
|
695
|
+
</div>`).join('') || '<p class="no-data">No violations ✓</p>'}
|
|
696
|
+
</div>`).join('') || '<p class="no-data">No accessibility scans</p>';
|
|
697
|
+
|
|
698
|
+
const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => `
|
|
699
|
+
<div class="perf-card">
|
|
700
|
+
<h3>${esc(label)}</h3>
|
|
701
|
+
<div class="vitals-grid">
|
|
702
|
+
${vitalCard('TTFB', m.ttfb, 800, 'ms')}
|
|
703
|
+
${vitalCard('LCP', m.lcp, 2500, 'ms')}
|
|
704
|
+
${vitalCard('FCP', m.fcp, 1800, 'ms')}
|
|
705
|
+
${vitalCard('CLS', m.cls, 0.1, '')}
|
|
706
|
+
${vitalCard('TBT', m.tbt, 200, 'ms')}
|
|
707
|
+
</div>
|
|
708
|
+
${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
|
|
709
|
+
${(m.slowResources||[]).length ? `<h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
|
|
710
|
+
<table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
|
|
711
|
+
<tbody>${m.slowResources.map(r => `<tr>
|
|
712
|
+
<td class="url">${esc((r.url||'').split('/').pop())}</td>
|
|
713
|
+
<td class="fail">${r.duration}ms</td>
|
|
714
|
+
<td>${formatBytes(r.size)}</td>
|
|
715
|
+
</tr>`).join('')}</tbody></table>` : ''}
|
|
716
|
+
</div>`).join('') || '<p class="no-data">No performance data</p>';
|
|
717
|
+
|
|
718
|
+
function vitalCard(name, value, threshold, unit) {
|
|
719
|
+
const na = value === null || value === undefined;
|
|
720
|
+
const pass2 = !na && value <= threshold;
|
|
721
|
+
const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
|
|
722
|
+
const color = na ? '#64748b' : pass2 ? '#22c55e' : '#ef4444';
|
|
723
|
+
const disp = na ? 'N/A' : `${Number(value).toFixed(name==='CLS'?3:0)}${unit}`;
|
|
724
|
+
return `<div class="vital-card ${cls}">
|
|
725
|
+
<div class="vital-label">${name}</div>
|
|
726
|
+
<div class="vital-value" style="color:${color}">${disp}</div>
|
|
727
|
+
<div class="vital-threshold">≤${threshold}${unit}</div>
|
|
728
|
+
</div>`;
|
|
729
|
+
}
|
|
408
730
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
731
|
+
const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
|
|
732
|
+
.map(([k,v]) => `<div class="url-card"><span class="url-label">${k}</span><a href="${esc(v)}" target="_blank">${esc(v)}</a></div>`).join('');
|
|
733
|
+
|
|
734
|
+
const chartTypes = JSON.stringify(Object.keys(coverage));
|
|
735
|
+
const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
|
|
736
|
+
const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
|
|
737
|
+
const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
|
|
738
|
+
|
|
739
|
+
return `<!DOCTYPE html>
|
|
740
|
+
<html lang="en">
|
|
741
|
+
<head>
|
|
742
|
+
<meta charset="UTF-8">
|
|
743
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
744
|
+
<title>Backlist QA Report — ${esc(session.id)}</title>
|
|
745
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
|
746
|
+
<style>
|
|
747
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
|
|
748
|
+
:root{--bg:#060610;--surface:#0f0f1e;--border:#1e1e3a;--text:#e2e8f0;--dim:#4a5568;--cyan:#00f5ff;--purple:#bf40ff;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}
|
|
749
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
750
|
+
body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
|
|
751
|
+
a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
|
|
752
|
+
header{background:linear-gradient(135deg,#0a0a1a,#12122a);border-bottom:1px solid #00f5ff22;padding:1.5rem 2rem;display:flex;justify-content:space-between;align-items:flex-start;position:sticky;top:0;z-index:100;backdrop-filter:blur(10px)}
|
|
753
|
+
.logo{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,var(--cyan),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
|
754
|
+
.header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
|
|
755
|
+
.version-badge{font-size:.7rem;padding:3px 10px;border-radius:20px;border:1px solid var(--purple);color:var(--purple)}
|
|
756
|
+
nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
|
|
757
|
+
.nav-tab{padding:.75rem 1.25rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.82rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s;font-family:'Syne',sans-serif}
|
|
758
|
+
.nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
|
|
759
|
+
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
|
760
|
+
.tab-panel{display:none}.tab-panel.active{display:block}
|
|
761
|
+
.real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
|
|
762
|
+
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
|
763
|
+
.mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
|
|
764
|
+
.mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
|
|
765
|
+
.ml{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
|
|
766
|
+
.mv{font-size:1.8rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
|
|
767
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
|
|
768
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
769
|
+
.card-title{font-size:.9rem;font-weight:700;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
|
770
|
+
.chart-wrap{position:relative;height:240px}
|
|
771
|
+
.search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
|
|
772
|
+
.search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.83rem;flex:1;font-family:'Syne',sans-serif}
|
|
773
|
+
table{width:100%;border-collapse:collapse;font-size:.8rem}
|
|
774
|
+
th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em}
|
|
775
|
+
td{padding:.45rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
|
|
776
|
+
tr.fail-row td{background:rgba(239,68,68,.04)}
|
|
777
|
+
.pass{color:var(--green)}.fail{color:var(--red)}
|
|
778
|
+
.status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace}
|
|
779
|
+
.status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}.status-flaky{background:#422006;color:#fbbf24}.status-skip{background:#1e293b;color:#94a3b8}
|
|
780
|
+
.sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
|
|
781
|
+
.sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
|
|
782
|
+
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1e293b;color:#94a3b8}
|
|
783
|
+
.url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
|
|
784
|
+
code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
|
|
785
|
+
pre{white-space:pre-wrap;word-break:break-all;font-size:.73rem;padding:.75rem;background:#080814;border-radius:6px;overflow-x:auto;max-height:300px;font-family:'JetBrains Mono',monospace}
|
|
786
|
+
details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:none}
|
|
787
|
+
.bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
|
|
788
|
+
.bug-card:hover{border-left-color:var(--cyan)}
|
|
789
|
+
.sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
|
|
790
|
+
.sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
|
|
791
|
+
.sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
|
|
792
|
+
.bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
|
|
793
|
+
.bug-id{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim)}
|
|
794
|
+
.bug-title{font-weight:700;margin-bottom:.3rem}
|
|
795
|
+
.bug-url{font-size:.75rem;margin-bottom:.3rem}
|
|
796
|
+
.bug-rec{font-size:.78rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
|
|
797
|
+
.ai-badge{font-size:.68rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
|
|
798
|
+
.rec{font-size:.75rem;color:#86efac}
|
|
799
|
+
.no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
|
|
800
|
+
.url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
|
|
801
|
+
.url-label{font-size:.7rem;color:var(--dim);text-transform:uppercase;min-width:90px}
|
|
802
|
+
.vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
|
|
803
|
+
.vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
|
|
804
|
+
.vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
|
|
805
|
+
.vital-label{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
|
|
806
|
+
.vital-threshold{font-size:.68rem;color:var(--dim);margin-top:2px}
|
|
807
|
+
.vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
|
|
808
|
+
.vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
|
|
809
|
+
.vital-na{background:var(--surface)}
|
|
810
|
+
.perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
811
|
+
.perf-card h3{color:var(--cyan);margin-bottom:.5rem}
|
|
812
|
+
.perf-note{font-size:.78rem;color:var(--dim);font-style:italic;margin-top:.75rem}
|
|
813
|
+
.seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
|
|
814
|
+
.seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.85rem}
|
|
815
|
+
.violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
|
|
816
|
+
.impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
|
|
817
|
+
.impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
|
|
818
|
+
.impact-moderate{border-left-color:#3b82f6;background:rgba(59,130,246,.05)}
|
|
819
|
+
.violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
|
|
820
|
+
.impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
|
|
821
|
+
.err-cell details{font-size:.78rem}
|
|
822
|
+
footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem;font-family:'JetBrains Mono',monospace}
|
|
823
|
+
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
|
|
824
|
+
</style>
|
|
825
|
+
</head>
|
|
826
|
+
<body>
|
|
827
|
+
<header>
|
|
828
|
+
<div>
|
|
829
|
+
<div class="logo">⚡ Backlist Enterprise QA</div>
|
|
830
|
+
<div class="header-meta">
|
|
831
|
+
Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
<span class="version-badge">v${VERSION}</span>
|
|
835
|
+
</header>
|
|
836
|
+
|
|
837
|
+
<nav>
|
|
838
|
+
<button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
|
|
839
|
+
<button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
|
|
840
|
+
<button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
|
|
841
|
+
<button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
|
|
842
|
+
<button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
|
|
843
|
+
<button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
|
|
844
|
+
<button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
|
|
845
|
+
<button class="nav-tab" onclick="showTab('seo',this)">🔎 SEO</button>
|
|
846
|
+
</nav>
|
|
847
|
+
|
|
848
|
+
<div class="container">
|
|
849
|
+
<div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live application testing. No mocked or simulated values.</div>
|
|
850
|
+
|
|
851
|
+
<!-- OVERVIEW -->
|
|
852
|
+
<div id="tab-overview" class="tab-panel active">
|
|
853
|
+
${urlsStr ? `<div class="card"><div class="card-title">Target URLs</div>${urlsStr}</div>` : ''}
|
|
854
|
+
<div class="metrics">
|
|
855
|
+
<div class="mc"><div class="ml">Pass Rate</div><div class="mv" style="color:${rateColor}">${summary.passRate}%</div></div>
|
|
856
|
+
<div class="mc"><div class="ml">Total Tests</div><div class="mv">${summary.total}</div></div>
|
|
857
|
+
<div class="mc"><div class="ml">Passed</div><div class="mv" style="color:var(--green)">${summary.passed}</div></div>
|
|
858
|
+
<div class="mc"><div class="ml">Failed</div><div class="mv" style="color:var(--red)">${summary.failed}</div></div>
|
|
859
|
+
<div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
|
|
860
|
+
<div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
|
|
861
|
+
<div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
|
|
862
|
+
<div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
|
|
863
|
+
<div class="mc"><div class="ml">APIs Tested</div><div class="mv">${session.apiLog.length}</div></div>
|
|
864
|
+
<div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
|
|
865
|
+
<div class="mc"><div class="ml">SEO Checks</div><div class="mv">${session.seoResults.reduce((a,r)=>a+(r.checks?.length||0),0)}</div></div>
|
|
866
|
+
<div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
|
|
867
|
+
</div>
|
|
868
|
+
<div class="grid2">
|
|
869
|
+
<div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
|
|
870
|
+
<div class="card"><div class="card-title">Bug Severity</div><div class="chart-wrap"><canvas id="bugChart"></canvas></div></div>
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
|
|
874
|
+
<!-- TESTS -->
|
|
875
|
+
<div id="tab-tests" class="tab-panel">
|
|
876
|
+
<div class="search-bar">
|
|
877
|
+
<input type="text" id="testSearch" placeholder="Search tests..." onkeyup="filterTests()">
|
|
878
|
+
<select id="testStatus" onchange="filterTests()">
|
|
879
|
+
<option value="">All statuses</option>
|
|
880
|
+
<option value="FAIL">Failed only</option>
|
|
881
|
+
<option value="PASS">Passed only</option>
|
|
882
|
+
</select>
|
|
883
|
+
<select id="testType" onchange="filterTests()">
|
|
884
|
+
<option value="">All types</option>
|
|
885
|
+
${[...new Set(session.results.map(r=>r.type))].map(t=>`<option value="${esc(t)}">${t}</option>`).join('')}
|
|
886
|
+
</select>
|
|
887
|
+
</div>
|
|
888
|
+
<div class="card">
|
|
889
|
+
<div class="card-title">All Test Results <span>${summary.total} tests</span></div>
|
|
890
|
+
<table id="testTable">
|
|
891
|
+
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Severity</th><th>Duration</th><th>Details</th></tr></thead>
|
|
892
|
+
<tbody>${testRows || '<tr><td colspan="6" class="no-data">No tests run yet</td></tr>'}</tbody>
|
|
893
|
+
</table>
|
|
894
|
+
</div>
|
|
895
|
+
</div>
|
|
896
|
+
|
|
897
|
+
<!-- BUGS -->
|
|
898
|
+
<div id="tab-bugs" class="tab-panel">
|
|
899
|
+
<div class="search-bar">
|
|
900
|
+
<input type="text" id="bugSearch" placeholder="Search bugs..." onkeyup="filterBugs()">
|
|
901
|
+
<select id="bugSev" onchange="filterBugs()">
|
|
902
|
+
<option value="">All severities</option>
|
|
903
|
+
<option value="P0">P0 Critical</option><option value="P1">P1 High</option>
|
|
904
|
+
<option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
|
|
905
|
+
</select>
|
|
906
|
+
</div>
|
|
907
|
+
<div id="bugList">${bugCards}</div>
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
<!-- ROUTES -->
|
|
911
|
+
<div id="tab-routes" class="tab-panel">
|
|
912
|
+
<div class="card">
|
|
913
|
+
<div class="card-title">Discovered Routes <span>${session.routeMap.length} real pages/APIs</span></div>
|
|
914
|
+
<table>
|
|
915
|
+
<thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
|
|
916
|
+
<tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
|
|
917
|
+
</table>
|
|
918
|
+
</div>
|
|
919
|
+
</div>
|
|
920
|
+
|
|
921
|
+
<!-- SECURITY -->
|
|
922
|
+
<div id="tab-security" class="tab-panel">
|
|
923
|
+
<div class="card">
|
|
924
|
+
<div class="card-title">Security Scan Results <span>${session.secFindings.length} checks</span></div>
|
|
925
|
+
<table>
|
|
926
|
+
<thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
|
|
927
|
+
<tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
|
|
928
|
+
</table>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
|
|
932
|
+
<!-- PERFORMANCE -->
|
|
933
|
+
<div id="tab-performance" class="tab-panel">
|
|
934
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Real Performance Metrics — HTTP TTFB + Resource Analysis</div>
|
|
935
|
+
${perfSection}
|
|
936
|
+
</div>
|
|
937
|
+
|
|
938
|
+
<!-- ACCESSIBILITY -->
|
|
939
|
+
<div id="tab-a11y" class="tab-panel">
|
|
940
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility Analysis — Real HTML WCAG Checks</div>
|
|
941
|
+
${a11ySection}
|
|
942
|
+
</div>
|
|
943
|
+
|
|
944
|
+
<!-- SEO -->
|
|
945
|
+
<div id="tab-seo" class="tab-panel">
|
|
946
|
+
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Fetched with Googlebot User-Agent</div>
|
|
947
|
+
${seoSection}
|
|
948
|
+
</div>
|
|
949
|
+
|
|
950
|
+
</div>
|
|
951
|
+
|
|
952
|
+
<footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${new Date().toLocaleString()}</footer>
|
|
953
|
+
|
|
954
|
+
<script>
|
|
955
|
+
function showTab(name, el) {
|
|
956
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
957
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
958
|
+
document.getElementById('tab-' + name)?.classList.add('active');
|
|
959
|
+
el?.classList.add('active');
|
|
960
|
+
}
|
|
961
|
+
function filterTests() {
|
|
962
|
+
const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
|
|
963
|
+
const st = document.getElementById('testStatus')?.value||'';
|
|
964
|
+
const ty = document.getElementById('testType')?.value||'';
|
|
965
|
+
document.querySelectorAll('#testTable tbody .result-row').forEach(row => {
|
|
966
|
+
const show = row.textContent.toLowerCase().includes(s)
|
|
967
|
+
&& (!st || row.dataset.status === st)
|
|
968
|
+
&& (!ty || row.dataset.type === ty);
|
|
969
|
+
row.style.display = show ? '' : 'none';
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
function filterBugs() {
|
|
973
|
+
const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
|
|
974
|
+
const sv = document.getElementById('bugSev')?.value||'';
|
|
975
|
+
document.querySelectorAll('#bugList .bug-card').forEach(card => {
|
|
976
|
+
const show = card.textContent.toLowerCase().includes(s)
|
|
977
|
+
&& (!sv || card.dataset.severity === sv);
|
|
978
|
+
card.style.display = show ? '' : 'none';
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const chartCfg = {
|
|
982
|
+
plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
|
|
983
|
+
scales:{x:{ticks:{color:'#64748b'},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1},grid:{color:'#1e293b'},beginAtZero:true}}
|
|
984
|
+
};
|
|
985
|
+
new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
|
|
986
|
+
{label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
|
|
987
|
+
{label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
|
|
988
|
+
]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
|
|
989
|
+
new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}}}});
|
|
990
|
+
</script>
|
|
991
|
+
</body>
|
|
992
|
+
</html>`;
|
|
993
|
+
}
|
|
419
994
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
995
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
996
|
+
// Main QA Runner
|
|
997
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
998
|
+
async function runQAEngine(session) {
|
|
999
|
+
const dash = new TerminalDashboard(session);
|
|
1000
|
+
dash.start();
|
|
1001
|
+
|
|
1002
|
+
const addResult = (r) => {
|
|
1003
|
+
const result = { id: shortId(), timestamp: timestamp(), duration: 0, ...r };
|
|
1004
|
+
session.addResult(result);
|
|
1005
|
+
dash.addResult(result);
|
|
1006
|
+
return result;
|
|
1007
|
+
};
|
|
430
1008
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
1009
|
+
try {
|
|
1010
|
+
// ── Phase 1: Discovery ───────────────────────────────────────────────
|
|
1011
|
+
dash.setPhase('🔍 Phase 1: Route Discovery & Crawling');
|
|
1012
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
1013
|
+
if (!url) continue;
|
|
1014
|
+
dash.log(`Crawling ${label}: ${url}`);
|
|
1015
|
+
const t0 = Date.now();
|
|
1016
|
+
const routes = await crawlSite(url, {
|
|
1017
|
+
maxPages: 50,
|
|
1018
|
+
onRoute: (route) => {
|
|
1019
|
+
session.routeMap.push(route);
|
|
1020
|
+
dash.log(` Found: ${route.url} (${route.type})`);
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
addResult({ name: `[${label}] Route Discovery`, type: 'discovery', category: 'crawl',
|
|
1024
|
+
status: routes.length > 0 ? 'PASS' : 'FAIL',
|
|
1025
|
+
message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
|
|
1026
|
+
}
|
|
434
1027
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1028
|
+
// ── Phase 2: API Validation ──────────────────────────────────────────
|
|
1029
|
+
dash.setPhase('📡 Phase 2: API Validation');
|
|
1030
|
+
const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
|
|
1031
|
+
dash.log(`Validating ${apiRoutes.length} API endpoints...`);
|
|
1032
|
+
for (const route of apiRoutes) {
|
|
1033
|
+
dash.setCurrentTest(`API: ${route.url}`);
|
|
1034
|
+
const r = await httpProbe(route.url);
|
|
1035
|
+
session.apiLog.push({ ...r, id: shortId() });
|
|
1036
|
+
addResult({ name: `API: ${route.url}`, type: 'api', category: 'api',
|
|
1037
|
+
status: r.ok ? 'PASS' : 'FAIL',
|
|
1038
|
+
message: `${r.status} ${r.ok ? 'OK' : 'FAIL'} (${r.responseTime}ms)`,
|
|
1039
|
+
url: route.url, duration: r.responseTime });
|
|
1040
|
+
if (!r.ok) session.addBug({ title: `API Failure: ${route.url}`,
|
|
1041
|
+
severity: r.status >= 500 ? 'P0' : 'P1', type: 'api',
|
|
1042
|
+
description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
|
|
438
1043
|
}
|
|
439
|
-
}
|
|
440
1044
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
for (const [label, url] of Object.entries(
|
|
1045
|
+
// ── Phase 3: Security ────────────────────────────────────────────────
|
|
1046
|
+
dash.setPhase('🛡️ Phase 3: Security Scan');
|
|
1047
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
444
1048
|
if (!url) continue;
|
|
445
|
-
|
|
446
|
-
const findings = await
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
type
|
|
453
|
-
|
|
454
|
-
status : finding.pass ? 'PASS' : 'FAIL',
|
|
455
|
-
message : finding.detail,
|
|
456
|
-
data : finding.evidence,
|
|
457
|
-
url, label,
|
|
458
|
-
severity: finding.severity,
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
if (!finding.pass && (finding.severity === 'P0' || finding.severity === 'P1')) {
|
|
462
|
-
this.#session.addBug({
|
|
463
|
-
title : `Security: ${finding.check}`,
|
|
464
|
-
severity : finding.severity,
|
|
465
|
-
type : 'security',
|
|
466
|
-
description : finding.detail,
|
|
467
|
-
url,
|
|
468
|
-
evidence : finding.evidence,
|
|
469
|
-
recommendation: finding.recommendation,
|
|
470
|
-
});
|
|
1049
|
+
dash.setCurrentTest(`Security: ${url}`);
|
|
1050
|
+
const findings = await runSecurityScan(url);
|
|
1051
|
+
session.secFindings.push(...findings);
|
|
1052
|
+
for (const f of findings) {
|
|
1053
|
+
addResult({ name: `Security: ${f.check}`, type: 'security', category: f.category,
|
|
1054
|
+
status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
|
|
1055
|
+
if (!f.pass && ['P0','P1'].includes(f.severity)) {
|
|
1056
|
+
session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
|
|
1057
|
+
description: f.detail, url, evidence: f.evidence, recommendation: f.recommendation });
|
|
471
1058
|
}
|
|
472
1059
|
}
|
|
473
1060
|
}
|
|
474
|
-
}
|
|
475
1061
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
for (const [label, url] of Object.entries(
|
|
1062
|
+
// ── Phase 4: Performance ─────────────────────────────────────────────
|
|
1063
|
+
dash.setPhase('⚡ Phase 4: Performance Profiling');
|
|
1064
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
479
1065
|
if (!url) continue;
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
{ name:
|
|
490
|
-
|
|
491
|
-
];
|
|
492
|
-
|
|
493
|
-
for (const vital of vitals) {
|
|
494
|
-
const na = vital.value === null || vital.value === undefined;
|
|
495
|
-
const pass = !na && vital.value <= vital.threshold;
|
|
496
|
-
|
|
497
|
-
this.#addResult({
|
|
498
|
-
name : `[${label}] ${vital.name} — Core Web Vital`,
|
|
499
|
-
type : 'performance',
|
|
500
|
-
category: 'web-vitals',
|
|
501
|
-
status : na ? 'SKIP' : (pass ? 'PASS' : 'FAIL'),
|
|
502
|
-
message : na
|
|
503
|
-
? `${vital.name} not measurable (HTTP-only mode)`
|
|
504
|
-
: `${vital.name}: ${vital.value}${vital.unit} (threshold: ≤${vital.threshold}${vital.unit})`,
|
|
505
|
-
data : { value: vital.value, threshold: vital.threshold },
|
|
506
|
-
url, label,
|
|
507
|
-
duration: vital.value,
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
if (!na && !pass) {
|
|
511
|
-
this.#session.addBug({
|
|
512
|
-
title : `Poor ${vital.name}: ${vital.value}${vital.unit} (>${vital.threshold}${vital.unit})`,
|
|
513
|
-
severity : (vital.name === 'LCP' || vital.name === 'CLS') ? 'P1' : 'P2',
|
|
514
|
-
type : 'performance',
|
|
515
|
-
description : `${vital.name} exceeds threshold on ${label}`,
|
|
516
|
-
url,
|
|
517
|
-
evidence : { value: vital.value, threshold: vital.threshold },
|
|
518
|
-
recommendation: `Optimize ${vital.name} — see https://web.dev/vitals`,
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
for (const resource of (metrics.slowResources || [])) {
|
|
524
|
-
this.#addResult({
|
|
525
|
-
name : `[${label}] Slow resource: ${resource.url?.split('/').pop()}`,
|
|
526
|
-
type : 'performance',
|
|
527
|
-
category: 'resource',
|
|
528
|
-
status : 'FAIL',
|
|
529
|
-
message : `${resource.url} took ${resource.duration}ms (${formatBytes(resource.size)})`,
|
|
530
|
-
data : resource,
|
|
531
|
-
url, label,
|
|
532
|
-
duration: resource.duration,
|
|
533
|
-
});
|
|
1066
|
+
dash.setCurrentTest(`Perf: ${url}`);
|
|
1067
|
+
const m = await runPerfProfile(url);
|
|
1068
|
+
session.perfMetrics[label] = m;
|
|
1069
|
+
addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
|
|
1070
|
+
status: m.ttfb <= 800 ? 'PASS' : 'FAIL',
|
|
1071
|
+
message: `TTFB: ${m.ttfb}ms (threshold: ≤800ms)`, url, label, duration: m.ttfb });
|
|
1072
|
+
if (m.ttfb > 800) session.addBug({ title: `Slow TTFB: ${m.ttfb}ms`, severity: m.ttfb > 2000 ? 'P1' : 'P2',
|
|
1073
|
+
type: 'performance', url, evidence: { ttfb: m.ttfb }, recommendation: 'Optimize server response time' });
|
|
1074
|
+
for (const res of (m.slowResources || [])) {
|
|
1075
|
+
addResult({ name: `Slow resource: ${res.url?.split('/').pop()}`, type: 'performance',
|
|
1076
|
+
category: 'resource', status: 'FAIL', message: `${res.duration}ms (${formatBytes(res.size)})`, url, label });
|
|
534
1077
|
}
|
|
535
1078
|
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// ── Phase 6: Accessibility ────────────────────────────────────────────
|
|
539
|
-
async #phaseAccessibility() {
|
|
540
|
-
const pageRoutes = this.#session.routeMap
|
|
541
|
-
.filter(r => r.type === 'page' || r.type === 'unknown')
|
|
542
|
-
.slice(0, 15);
|
|
543
1079
|
|
|
1080
|
+
// ── Phase 5: Accessibility ───────────────────────────────────────────
|
|
1081
|
+
dash.setPhase('♿ Phase 5: Accessibility Check');
|
|
1082
|
+
const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
|
|
544
1083
|
for (const route of pageRoutes) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
type
|
|
555
|
-
|
|
556
|
-
status : 'FAIL',
|
|
557
|
-
message : `${violation.nodes} element(s) affected — ${violation.help}`,
|
|
558
|
-
data : {
|
|
559
|
-
impact : violation.impact,
|
|
560
|
-
wcagTags: violation.tags,
|
|
561
|
-
nodes : violation.affectedNodes,
|
|
562
|
-
helpUrl : violation.helpUrl,
|
|
563
|
-
},
|
|
564
|
-
url : route.url,
|
|
565
|
-
severity: violation.impact === 'critical' ? 'P0'
|
|
566
|
-
: violation.impact === 'serious' ? 'P1'
|
|
567
|
-
: violation.impact === 'moderate' ? 'P2' : 'P3',
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
if (violation.impact === 'critical' || violation.impact === 'serious') {
|
|
571
|
-
this.#session.addBug({
|
|
572
|
-
title : `A11y: ${violation.description}`,
|
|
573
|
-
severity : violation.impact === 'critical' ? 'P0' : 'P1',
|
|
574
|
-
type : 'accessibility',
|
|
575
|
-
description : `${violation.nodes} element(s): ${violation.help}`,
|
|
576
|
-
url : route.url,
|
|
577
|
-
evidence : violation.affectedNodes,
|
|
578
|
-
recommendation: violation.helpUrl,
|
|
579
|
-
});
|
|
580
|
-
}
|
|
1084
|
+
dash.setCurrentTest(`A11y: ${route.url}`);
|
|
1085
|
+
const result = await runA11yScan(route.url);
|
|
1086
|
+
session.a11yResults.push({ url: route.url, ...result });
|
|
1087
|
+
for (const v of result.violations) {
|
|
1088
|
+
addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility', category: 'wcag',
|
|
1089
|
+
status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : v.impact === 'serious' ? 'P1' : 'P2',
|
|
1090
|
+
url: route.url });
|
|
1091
|
+
if (['critical','serious'].includes(v.impact)) session.addBug({
|
|
1092
|
+
title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
|
|
1093
|
+
type: 'accessibility', description: v.help, url: route.url,
|
|
1094
|
+
recommendation: v.helpUrl });
|
|
581
1095
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
this.#addResult({
|
|
585
|
-
name : `A11y Pass: ${pass.description}`,
|
|
586
|
-
type : 'accessibility',
|
|
587
|
-
category: 'wcag',
|
|
588
|
-
status : 'PASS',
|
|
589
|
-
message : `${pass.nodes} element(s) verified`,
|
|
590
|
-
data : pass,
|
|
591
|
-
url : route.url,
|
|
592
|
-
});
|
|
1096
|
+
for (const pass of result.passes.slice(0, 3)) {
|
|
1097
|
+
addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
|
|
593
1098
|
}
|
|
594
1099
|
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// ── Phase 7: SEO ──────────────────────────────────────────────────────
|
|
598
|
-
async #phaseSEO() {
|
|
599
|
-
const pageRoutes = this.#session.routeMap
|
|
600
|
-
.filter(r => r.type === 'page' || r.type === 'unknown')
|
|
601
|
-
.slice(0, 20);
|
|
602
1100
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
for (const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
message : check.detail,
|
|
617
|
-
data : check.data,
|
|
618
|
-
url : route.url,
|
|
619
|
-
severity: check.severity,
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
if (!check.pass && (check.severity === 'P0' || check.severity === 'P1')) {
|
|
623
|
-
this.#session.addBug({
|
|
624
|
-
title : `SEO: ${check.name}`,
|
|
625
|
-
severity : check.severity,
|
|
626
|
-
type : 'seo',
|
|
627
|
-
description : check.detail,
|
|
628
|
-
url : route.url,
|
|
629
|
-
recommendation: check.recommendation,
|
|
630
|
-
});
|
|
631
|
-
}
|
|
1101
|
+
// ── Phase 6: SEO ─────────────────────────────────────────────────────
|
|
1102
|
+
dash.setPhase('🔎 Phase 6: SEO Validation');
|
|
1103
|
+
const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
|
|
1104
|
+
for (const route of seoRoutes) {
|
|
1105
|
+
dash.setCurrentTest(`SEO: ${route.url}`);
|
|
1106
|
+
const result = await runSEOScan(route.url);
|
|
1107
|
+
session.seoResults.push({ url: route.url, ...result });
|
|
1108
|
+
for (const c of result.checks) {
|
|
1109
|
+
addResult({ name: `SEO: ${c.name}`, type: 'seo', category: c.category,
|
|
1110
|
+
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
|
|
1111
|
+
if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
|
|
1112
|
+
title: `SEO: ${c.name}`, severity: c.severity, type: 'seo',
|
|
1113
|
+
description: c.detail, url: route.url, recommendation: c.recommendation });
|
|
632
1114
|
}
|
|
633
1115
|
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// ── Phase 8: AI Classification ────────────────────────────────────────
|
|
637
|
-
async #phaseAIClassification() {
|
|
638
|
-
this.#terminal.log(`AI classifying ${this.#session.bugs.length} bugs...`);
|
|
639
|
-
|
|
640
|
-
for (const bug of this.#session.bugs) {
|
|
641
|
-
const classification = await this.#aiClassifier.classify(bug, this.#session);
|
|
642
|
-
bug.aiSeverity = classification.severity;
|
|
643
|
-
bug.aiCategory = classification.category;
|
|
644
|
-
bug.aiRecommendation = classification.recommendation;
|
|
645
|
-
bug.aiConfidence = classification.confidence;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
this.#session.bugs.sort((a, b) => {
|
|
649
|
-
const order = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
650
|
-
return (order[a.aiSeverity || a.severity] || 3)
|
|
651
|
-
- (order[b.aiSeverity || b.severity] || 3);
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// ── Form Testing ──────────────────────────────────────────────────────
|
|
656
|
-
async #testForms(url, forms, page) {
|
|
657
|
-
for (const form of forms.slice(0, 3)) {
|
|
658
|
-
this.#terminal.setCurrentTest(`Form: ${url} — ${form.action || 'unknown'}`);
|
|
659
|
-
|
|
660
|
-
const result = await this.#interactor.testForm(page, form);
|
|
661
|
-
|
|
662
|
-
this.#addResult({
|
|
663
|
-
name : `Form test: ${url} → ${form.action || 'inline'}`,
|
|
664
|
-
type : 'form',
|
|
665
|
-
category: 'user-flow',
|
|
666
|
-
status : result.pass ? 'PASS' : 'FAIL',
|
|
667
|
-
message : result.message,
|
|
668
|
-
data : {
|
|
669
|
-
fields : form.fields,
|
|
670
|
-
action : form.action,
|
|
671
|
-
method : form.method,
|
|
672
|
-
validationOk: result.validationOk,
|
|
673
|
-
submissionOk: result.submissionOk,
|
|
674
|
-
errors : result.errors,
|
|
675
|
-
},
|
|
676
|
-
url,
|
|
677
|
-
duration: result.duration,
|
|
678
|
-
});
|
|
679
1116
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
1117
|
+
// ── Phase 7: AI Classification ───────────────────────────────────────
|
|
1118
|
+
dash.setPhase('🤖 Phase 7: AI Bug Classification');
|
|
1119
|
+
dash.log(`Classifying ${session.bugs.length} bugs...`);
|
|
1120
|
+
for (const bug of session.bugs) {
|
|
1121
|
+
const cls = classifyBug(bug);
|
|
1122
|
+
bug.aiSeverity = cls.severity;
|
|
1123
|
+
bug.aiCategory = cls.category;
|
|
1124
|
+
bug.aiRecommendation = cls.recommendation;
|
|
1125
|
+
bug.aiConfidence = cls.confidence;
|
|
690
1126
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
async #testAuthFlow(url, page) {
|
|
695
|
-
this.#terminal.setCurrentTest(`Auth flow: ${url}`);
|
|
696
|
-
|
|
697
|
-
const result = await this.#interactor.testAuthFlow(page, url, {
|
|
698
|
-
testCredentials: [
|
|
699
|
-
{ username: 'test@example.com', password: 'wrong-password-test', expectFail: true },
|
|
700
|
-
{ username: 'invalid@test.com', password: 'wrong123', expectFail: true },
|
|
701
|
-
{ username: '', password: '', expectFail: true },
|
|
702
|
-
],
|
|
1127
|
+
session.bugs.sort((a, b) => {
|
|
1128
|
+
const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
1129
|
+
return (o[a.aiSeverity||a.severity]||3) - (o[b.aiSeverity||b.severity]||3);
|
|
703
1130
|
});
|
|
704
1131
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
type : 'auth',
|
|
708
|
-
category: 'authentication',
|
|
709
|
-
status : result.pass ? 'PASS' : 'FAIL',
|
|
710
|
-
message : result.message,
|
|
711
|
-
data : result.details,
|
|
712
|
-
url,
|
|
713
|
-
duration: result.duration,
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
if (!result.pass) {
|
|
717
|
-
this.#session.addBug({
|
|
718
|
-
title : `Auth flow issue: ${url}`,
|
|
719
|
-
severity : 'P0',
|
|
720
|
-
type : 'auth',
|
|
721
|
-
description: result.message,
|
|
722
|
-
url,
|
|
723
|
-
evidence : result.details,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
1132
|
+
} finally {
|
|
1133
|
+
dash.stop();
|
|
726
1134
|
}
|
|
727
1135
|
|
|
728
|
-
|
|
729
|
-
return /\/(login|signin|auth|register|signup)/i.test(url);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
#addResult(result) {
|
|
733
|
-
const r = {
|
|
734
|
-
id : shortId(),
|
|
735
|
-
timestamp: timestamp(),
|
|
736
|
-
duration : result.duration || 0,
|
|
737
|
-
...result,
|
|
738
|
-
};
|
|
739
|
-
this.#session.addResult(r);
|
|
740
|
-
this.#terminal.addResult(r);
|
|
741
|
-
this.emit('result', r);
|
|
742
|
-
return r;
|
|
743
|
-
}
|
|
1136
|
+
return session;
|
|
744
1137
|
}
|
|
745
1138
|
|
|
746
1139
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
747
|
-
//
|
|
1140
|
+
// Report Generation
|
|
748
1141
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1142
|
+
async function generateReports(session) {
|
|
1143
|
+
await fs.ensureDir(REPORT_DIR);
|
|
1144
|
+
const base = session.id.toLowerCase();
|
|
1145
|
+
const htmlPath = path.join(REPORT_DIR, `${base}.html`);
|
|
1146
|
+
const jsonPath = path.join(REPORT_DIR, `${base}.json`);
|
|
1147
|
+
const summary = session.getSummary();
|
|
1148
|
+
|
|
1149
|
+
await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
|
|
1150
|
+
await fs.writeJson(jsonPath, {
|
|
1151
|
+
meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(), dataSource: 'real-runtime' },
|
|
1152
|
+
urls: session.urls, summary, results: session.results, bugs: session.bugs,
|
|
1153
|
+
routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
|
|
1154
|
+
perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
|
|
1155
|
+
ci: {
|
|
1156
|
+
exitCode : summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
|
|
1157
|
+
p0Bugs : session.bugs.filter(b => b.severity === 'P0').length,
|
|
1158
|
+
p1Bugs : session.bugs.filter(b => b.severity === 'P1').length,
|
|
1159
|
+
passRate : summary.passRate,
|
|
1160
|
+
},
|
|
1161
|
+
}, { spaces: 2 });
|
|
1162
|
+
|
|
1163
|
+
return { htmlPath, jsonPath };
|
|
1164
|
+
}
|
|
749
1165
|
|
|
1166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1167
|
+
// History
|
|
1168
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
750
1169
|
export async function initQASystem() {
|
|
751
1170
|
await fs.ensureDir(QA_DIR);
|
|
752
1171
|
await fs.ensureDir(REPORT_DIR);
|
|
@@ -756,189 +1175,158 @@ export async function initQASystem() {
|
|
|
756
1175
|
}
|
|
757
1176
|
}
|
|
758
1177
|
|
|
759
|
-
|
|
760
|
-
|
|
1178
|
+
async function saveToHistory(session, htmlPath, jsonPath) {
|
|
1179
|
+
let history = { runs: [] };
|
|
1180
|
+
try { history = await fs.readJson(HISTORY_FILE); } catch {}
|
|
761
1181
|
const summary = session.getSummary();
|
|
762
1182
|
history.runs.unshift({
|
|
763
|
-
id
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
summary,
|
|
767
|
-
version : VERSION,
|
|
768
|
-
bugCount : session.bugs.length,
|
|
769
|
-
screenshotCount: session.screenshots.length,
|
|
1183
|
+
id: session.id, startedAt: session.startedAt, urls: session.urls,
|
|
1184
|
+
summary, version: VERSION, bugCount: session.bugs.length,
|
|
1185
|
+
htmlPath, jsonPath,
|
|
770
1186
|
});
|
|
771
1187
|
if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
|
|
772
1188
|
await fs.writeJson(HISTORY_FILE, history, { spaces: 2 });
|
|
773
1189
|
}
|
|
774
1190
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
1191
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1192
|
+
// Public API
|
|
1193
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
779
1194
|
|
|
780
|
-
|
|
781
|
-
export async function runUrlQA({ localUrl, stagingUrl, prodUrl, options = {} } = {}) {
|
|
1195
|
+
export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
782
1196
|
const urls = {};
|
|
783
1197
|
if (localUrl) urls.localhost = localUrl;
|
|
784
1198
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
785
1199
|
if (prodUrl) urls.production = prodUrl;
|
|
786
1200
|
|
|
787
|
-
if (Object.keys(urls).length
|
|
788
|
-
console.log(chalk.red(' No URLs provided.'));
|
|
789
|
-
return null;
|
|
790
|
-
}
|
|
1201
|
+
if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
|
|
791
1202
|
|
|
792
1203
|
const session = new QASession(urls);
|
|
793
|
-
|
|
1204
|
+
await runQAEngine(session);
|
|
1205
|
+
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1206
|
+
await saveToHistory(session, htmlPath, jsonPath);
|
|
794
1207
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1208
|
+
const summary = session.getSummary();
|
|
1209
|
+
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs`));
|
|
1210
|
+
console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
|
|
1211
|
+
console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
|
|
1212
|
+
|
|
1213
|
+
// Auto-open report
|
|
1214
|
+
try {
|
|
1215
|
+
const { exec } = await import('node:child_process');
|
|
1216
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1217
|
+
exec(`${cmd} "${htmlPath}"`);
|
|
1218
|
+
console.log(chalk.green(' 🌐 Report opened in browser!'));
|
|
1219
|
+
} catch {}
|
|
801
1220
|
|
|
802
1221
|
return { session, htmlPath, jsonPath };
|
|
803
1222
|
}
|
|
804
1223
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const runOnce = async () => {
|
|
1224
|
+
export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
|
|
1225
|
+
const run = async () => {
|
|
808
1226
|
const urls = {};
|
|
809
1227
|
if (localUrl) urls.localhost = localUrl;
|
|
810
1228
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
811
1229
|
if (prodUrl) urls.production = prodUrl;
|
|
812
|
-
|
|
813
|
-
if (Object.keys(urls).length === 0) {
|
|
814
|
-
console.log(chalk.yellow(' No URLs configured. Skipping URL-based tests.'));
|
|
815
|
-
}
|
|
816
|
-
|
|
817
1230
|
const session = new QASession(urls);
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
await
|
|
821
|
-
|
|
822
|
-
await saveSession(session);
|
|
823
|
-
|
|
824
|
-
const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
|
|
825
|
-
const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
|
|
826
|
-
|
|
827
|
-
const summary = session.getSummary();
|
|
828
|
-
console.log(chalk.hex('#00F5FF').bold(
|
|
829
|
-
`\n ✓ Run ${session.id} — ${summary.total} tests · ${summary.failed} failed · ` +
|
|
830
|
-
`${session.bugs.length} bugs · ${formatDuration(summary.duration)}`
|
|
831
|
-
));
|
|
832
|
-
if (htmlPath) console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
1231
|
+
await runQAEngine(session);
|
|
1232
|
+
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1233
|
+
await saveToHistory(session, htmlPath, jsonPath);
|
|
1234
|
+
console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
833
1235
|
return session;
|
|
834
1236
|
};
|
|
835
1237
|
|
|
836
|
-
if (!continuous) return
|
|
837
|
-
|
|
838
|
-
console.log(chalk.cyan(' ⚡ Continuous mode — re-runs every 60s. Ctrl+C to stop.\n'));
|
|
1238
|
+
if (!continuous) return run();
|
|
1239
|
+
console.log(chalk.cyan(' ⚡ Continuous mode — every 60s. Ctrl+C to stop.\n'));
|
|
839
1240
|
let i = 0;
|
|
840
1241
|
while (true) {
|
|
841
|
-
console.log(chalk.gray(`\n ── Run #${++i}
|
|
842
|
-
await
|
|
1242
|
+
console.log(chalk.gray(`\n ── Run #${++i} @ ${new Date().toLocaleTimeString()} ──`));
|
|
1243
|
+
await run();
|
|
843
1244
|
await sleep(60_000);
|
|
844
1245
|
}
|
|
845
1246
|
}
|
|
846
1247
|
|
|
847
|
-
// ── Manual QA ─────────────────────────────────────────────────────────────
|
|
848
1248
|
export async function runManualQA() {
|
|
849
|
-
console.log('');
|
|
850
|
-
|
|
851
1249
|
const action = await p.select({
|
|
852
|
-
message: 'Manual QA
|
|
1250
|
+
message: 'Manual QA mode:',
|
|
853
1251
|
options: [
|
|
854
|
-
{ value: 'full
|
|
855
|
-
{ value: 'security', label: '🛡️ Security
|
|
856
|
-
{ value: '
|
|
857
|
-
{ value: 'a11y', label: '♿ Accessibility
|
|
858
|
-
{ value: '
|
|
859
|
-
{ value: 'api', label: '📡 API Only', hint: 'Real endpoint probe + contract validation' },
|
|
1252
|
+
{ value: 'full', label: '🌐 Full Scan (All phases)' },
|
|
1253
|
+
{ value: 'security', label: '🛡️ Security only' },
|
|
1254
|
+
{ value: 'seo', label: '🔎 SEO only' },
|
|
1255
|
+
{ value: 'a11y', label: '♿ Accessibility only' },
|
|
1256
|
+
{ value: 'perf', label: '⚡ Performance only' },
|
|
860
1257
|
],
|
|
861
1258
|
});
|
|
862
1259
|
if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
|
|
863
1260
|
|
|
864
|
-
const localUrl = await p.text({
|
|
865
|
-
message : 'Localhost URL:',
|
|
866
|
-
placeholder: 'http://localhost:3000',
|
|
867
|
-
});
|
|
1261
|
+
const localUrl = await p.text({ message: 'URL to test:', placeholder: 'http://localhost:3000' });
|
|
868
1262
|
if (p.isCancel(localUrl)) { p.cancel('Cancelled.'); return; }
|
|
869
1263
|
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
placeholder: 'https://yoursite.com',
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
const urls = {
|
|
876
|
-
localhost : String(localUrl).trim() || undefined,
|
|
877
|
-
production: !p.isCancel(prodUrl) ? String(prodUrl).trim() || undefined : undefined,
|
|
878
|
-
};
|
|
1264
|
+
const url = String(localUrl).trim();
|
|
1265
|
+
const sess = new QASession({ localhost: url });
|
|
879
1266
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1267
|
+
if (action === 'full') {
|
|
1268
|
+
await runQAEngine(sess);
|
|
1269
|
+
} else {
|
|
1270
|
+
const dash = new TerminalDashboard(sess);
|
|
1271
|
+
dash.start();
|
|
1272
|
+
try {
|
|
1273
|
+
if (action === 'security') {
|
|
1274
|
+
const f = await runSecurityScan(url);
|
|
1275
|
+
sess.secFindings.push(...f);
|
|
1276
|
+
f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
|
|
1277
|
+
status: finding.pass ? 'PASS' : 'FAIL', message: finding.detail, timestamp: timestamp() }));
|
|
1278
|
+
} else if (action === 'seo') {
|
|
1279
|
+
const r = await runSEOScan(url);
|
|
1280
|
+
sess.seoResults.push({ url, ...r });
|
|
1281
|
+
r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
|
|
1282
|
+
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
|
|
1283
|
+
} else if (action === 'a11y') {
|
|
1284
|
+
const r = await runA11yScan(url);
|
|
1285
|
+
sess.a11yResults.push({ url, ...r });
|
|
1286
|
+
r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
|
|
1287
|
+
status: 'FAIL', message: v.help, timestamp: timestamp() }));
|
|
1288
|
+
} else if (action === 'perf') {
|
|
1289
|
+
const m = await runPerfProfile(url);
|
|
1290
|
+
sess.perfMetrics.localhost = m;
|
|
1291
|
+
sess.addResult({ id: shortId(), name: `TTFB: ${m.ttfb}ms`, type: 'performance',
|
|
1292
|
+
status: m.ttfb <= 800 ? 'PASS' : 'FAIL', message: `${m.ttfb}ms`, timestamp: timestamp() });
|
|
1293
|
+
}
|
|
1294
|
+
} finally { dash.stop(); }
|
|
890
1295
|
}
|
|
1296
|
+
|
|
1297
|
+
const { htmlPath } = await generateReports(sess);
|
|
1298
|
+
await saveToHistory(sess, htmlPath, '');
|
|
1299
|
+
p.outro(chalk.hex('#00F5FF').bold('✓ QA complete'));
|
|
1300
|
+
console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
1301
|
+
try {
|
|
1302
|
+
const { exec } = await import('node:child_process');
|
|
1303
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1304
|
+
exec(`${cmd} "${htmlPath}"`);
|
|
1305
|
+
} catch {}
|
|
891
1306
|
}
|
|
892
1307
|
|
|
893
|
-
|
|
894
|
-
export async function autoRunPostGeneration(options = {}) {
|
|
895
|
-
console.log('');
|
|
896
|
-
console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation Real QA v${VERSION} ──`));
|
|
897
|
-
console.log(chalk.gray(' Note: Start your server first, then provide its URL'));
|
|
1308
|
+
export async function autoRunPostGeneration() {
|
|
898
1309
|
console.log('');
|
|
899
|
-
|
|
900
|
-
const url = await p.text({
|
|
901
|
-
message : 'Server URL to validate:',
|
|
902
|
-
placeholder : 'http://localhost:3000',
|
|
903
|
-
defaultValue: 'http://localhost:3000',
|
|
904
|
-
});
|
|
1310
|
+
console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation QA v${VERSION} ──`));
|
|
1311
|
+
const url = await p.text({ message: 'Server URL:', placeholder: 'http://localhost:3000', defaultValue: 'http://localhost:3000' });
|
|
905
1312
|
if (p.isCancel(url)) { p.cancel('Cancelled.'); return; }
|
|
906
|
-
|
|
907
|
-
const result = await runUrlQA({ localUrl: String(url).trim() });
|
|
908
|
-
if (result?.htmlPath) {
|
|
909
|
-
console.log(chalk.gray(` 📄 Report: ${result.htmlPath}`));
|
|
910
|
-
}
|
|
1313
|
+
await runUrlQA({ localUrl: String(url).trim() });
|
|
911
1314
|
}
|
|
912
1315
|
|
|
913
|
-
// ── View History ──────────────────────────────────────────────────────────
|
|
914
1316
|
export async function viewQAHistory() {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
console.log(chalk.yellow('\n No QA history found.\n'));
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
1317
|
+
let history = { runs: [] };
|
|
1318
|
+
try { history = await fs.readJson(HISTORY_FILE); } catch {}
|
|
920
1319
|
|
|
921
|
-
console.log('');
|
|
922
|
-
console.log(chalk.hex('#00F5FF').bold(' QA History (real runs only)'));
|
|
923
|
-
console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
|
|
1320
|
+
if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
|
|
924
1321
|
|
|
1322
|
+
console.log('');
|
|
1323
|
+
console.log(chalk.hex('#00F5FF').bold(' QA History'));
|
|
1324
|
+
console.log(chalk.gray(' ──────────────────────────────────────────────────'));
|
|
925
1325
|
for (const run of history.runs.slice(0, 15)) {
|
|
926
|
-
const rate
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
const shots = run.screenshotCount ?? 0;
|
|
931
|
-
const urlStr = Object.values(run.urls || {}).filter(Boolean).join(', ');
|
|
932
|
-
|
|
933
|
-
console.log(
|
|
934
|
-
` ${chalk.gray(run.id.padEnd(14))} ` +
|
|
935
|
-
`${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(24))} ` +
|
|
936
|
-
`${color(String(rate + '%').padStart(6))} ` +
|
|
937
|
-
`${chalk.gray(String(run.summary?.total || 0) + ' tests')} ` +
|
|
938
|
-
`${chalk.cyan(bugs + ' bugs')} ` +
|
|
939
|
-
`${chalk.gray(shots + ' shots')} ` +
|
|
940
|
-
`${chalk.dim(urlStr.slice(0, 40))}`
|
|
941
|
-
);
|
|
1326
|
+
const rate = run.summary?.passRate ?? '–';
|
|
1327
|
+
const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
|
|
1328
|
+
const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
|
|
1329
|
+
console.log(` ${chalk.gray(run.id.padEnd(16))} ${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(22))} ${col((rate+'%').padStart(7))} ${chalk.cyan((run.bugCount||0)+' bugs')} ${chalk.dim(urls.slice(0,40))}`);
|
|
942
1330
|
}
|
|
943
1331
|
console.log('');
|
|
944
1332
|
|
|
@@ -946,7 +1334,7 @@ export async function viewQAHistory() {
|
|
|
946
1334
|
message: 'Open a report?',
|
|
947
1335
|
options: [
|
|
948
1336
|
...history.runs.slice(0, 8).map(r => ({
|
|
949
|
-
value: r.id,
|
|
1337
|
+
value: r.htmlPath || r.id,
|
|
950
1338
|
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
|
|
951
1339
|
})),
|
|
952
1340
|
{ value: '__back', label: '↩ Back' },
|
|
@@ -954,17 +1342,15 @@ export async function viewQAHistory() {
|
|
|
954
1342
|
});
|
|
955
1343
|
if (p.isCancel(chosen) || chosen === '__back') return;
|
|
956
1344
|
|
|
957
|
-
const reportPath = path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
|
|
1345
|
+
const reportPath = chosen.endsWith('.html') ? chosen : path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
|
|
958
1346
|
if (await fs.pathExists(reportPath)) {
|
|
959
1347
|
console.log(chalk.green(` 📄 Report: ${reportPath}`));
|
|
960
1348
|
try {
|
|
961
1349
|
const { exec } = await import('node:child_process');
|
|
962
|
-
const cmd = process.platform === 'darwin' ? 'open'
|
|
963
|
-
: process.platform === 'win32' ? 'start'
|
|
964
|
-
: 'xdg-open';
|
|
1350
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
965
1351
|
exec(`${cmd} "${reportPath}"`);
|
|
966
1352
|
} catch {}
|
|
967
1353
|
} else {
|
|
968
|
-
console.log(chalk.yellow(' Report file not found
|
|
1354
|
+
console.log(chalk.yellow(' Report file not found.'));
|
|
969
1355
|
}
|
|
970
1356
|
}
|