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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/qa/qa-engine.js +1137 -751
@@ -1,32 +1,16 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
- // Backlist Enterprise AI QA Platform — qa-engine.js v12.0
3
- // Copyright (c) W.A.H.ISHAN MIT License
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 from '@clack/prompts';
10
- import chalk from 'chalk';
11
- import fs from 'fs-extra';
12
- import path from 'node:path';
13
- import os from 'node:os';
14
- import { performance } from 'node:perf_hooks';
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 function timestamp() { return new Date().toISOString(); }
40
- export function shortId() { return Math.random().toString(36).slice(2, 9); }
41
- export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
42
- export function formatDuration(ms) {
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 function formatBytes(b) {
47
- if (!b || b < 0) return '0B';
48
- if (b < 1024) return `${b}B`;
49
- if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
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
- // ── Ask yes/no in terminal without async-inside-Promise issue ─────────────
54
- function askQuestion(question) {
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
- input : process.stdin,
58
- output: process.stdout,
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(answer.toLowerCase().trim() === 'y');
46
+ resolve(ans.toLowerCase().trim() === 'y');
70
47
  });
71
48
  });
72
49
  }
73
50
 
74
- // ── QA Session ────────────────────────────────────────────────────────────
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(result) { this.results.push(result); }
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
- // ── Main QA Engine ────────────────────────────────────────────────────────
118
- export class QAEngine extends EventEmitter {
119
- #session;
120
- #terminal;
121
- #crawler;
122
- #interactor;
123
- #screenshotter;
124
- #apiValidator;
125
- #security;
126
- #performance;
127
- #a11y;
128
- #seo;
129
- #aiClassifier;
130
- #aborted = false;
131
-
132
- constructor(session, options = {}) {
133
- super();
134
- this.#session = session;
135
- this.#terminal = new TerminalDashboard(session);
136
- this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
137
- this.#aiClassifier = new AIClassifier();
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
- // ── FIX: init() — no await inside non-async callbacks ─────────────────
141
- async init() {
142
- // Dynamic import Playwright — optional dependency
143
- let playwright = null;
144
- try {
145
- playwright = await import('playwright');
146
- } catch {
147
- // Will use HTTP fallback throughout — playwright is optional
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
- // Resolve browser launch options (handles all detection logic)
151
- const { getBrowserLaunchOptions, installPlaywrightBrowsers } = await import('./browser/installer.js');
152
- const launchOpts = await getBrowserLaunchOptions();
153
-
154
- if (!launchOpts.available) {
155
- console.log(chalk.yellow('\n ⚠ Playwright browser not found.'));
156
- console.log(chalk.gray(' The QA engine will run in HTTP-only mode.'));
157
- console.log(chalk.gray(' Browser-based tests (JS errors, screenshots, real Web Vitals)'));
158
- console.log(chalk.gray(' will be skipped. All HTTP-based tests will still run.\n'));
159
- console.log(chalk.dim(' To enable full browser testing:'));
160
- console.log(chalk.white(' npx playwright install chromium\n'));
161
-
162
- // ── FIX: use the extracted askQuestion() helper no await in Promise ──
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
- } else {
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
- // Initialise all subsystems
179
- this.#crawler = new SmartCrawler(playwright);
180
- this.#interactor = new BrowserInteractor(playwright, this.#session);
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
- async run() {
194
- this.#terminal.start();
195
- this.emit('session:start', this.#session);
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
- this.#terminal.setPhase('🔍 Phase 1: Route Discovery & Crawling');
199
- await this.#phaseDiscovery();
200
-
201
- this.#terminal.setPhase('📡 Phase 2: Real API Validation');
202
- await this.#phaseAPIValidation();
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
- this.#terminal.setPhase('🖱️ Phase 3: Browser Interaction Testing');
205
- await this.#phaseBrowserInteractions();
218
+ return routes;
219
+ }
206
220
 
207
- this.#terminal.setPhase('🛡️ Phase 4: Security Deep Scan');
208
- await this.#phaseSecurityScan();
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
- this.#terminal.setPhase('⚡ Phase 5: Performance Profiling');
211
- await this.#phasePerformance();
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
- this.#terminal.setPhase('♿ Phase 6: Accessibility Testing');
214
- await this.#phaseAccessibility();
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
- this.#terminal.setPhase('🔎 Phase 7: SEO Validation');
217
- await this.#phaseSEO();
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
- this.#terminal.setPhase('🤖 Phase 8: AI Bug Classification');
220
- await this.#phaseAIClassification();
301
+ return findings;
302
+ }
221
303
 
222
- } catch (err) {
223
- this.emit('engine:error', err);
224
- throw err;
225
- } finally {
226
- this.#terminal.stop();
227
- await this.#interactor.close().catch(() => {});
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
- return this.#session;
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
- // Run a single named phase (used by manual QA)
234
- async runPhase(name) {
235
- this.#terminal.start();
236
- try {
237
- switch (name) {
238
- case 'full-url':
239
- await this.#phaseDiscovery();
240
- await this.#phaseAPIValidation();
241
- await this.#phaseBrowserInteractions();
242
- await this.#phaseSecurityScan();
243
- await this.#phasePerformance();
244
- await this.#phaseAccessibility();
245
- await this.#phaseSEO();
246
- await this.#phaseAIClassification();
247
- break;
248
- case 'security': await this.#phaseSecurityScan(); break;
249
- case 'perf': await this.#phasePerformance(); break;
250
- case 'a11y': await this.#phaseAccessibility(); break;
251
- case 'seo': await this.#phaseSEO(); break;
252
- case 'api': await this.#phaseAPIValidation(); break;
253
- default:
254
- await this.run();
255
- }
256
- } finally {
257
- this.#terminal.stop();
258
- await this.#interactor.close().catch(() => {});
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
- abort() {
264
- this.#aborted = true;
265
- this.#terminal.stop();
266
- this.#interactor.close().catch(() => {});
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
- // ── Phase 1: Discovery ─────────────────────────────────────────────────
270
- async #phaseDiscovery() {
271
- for (const [label, url] of Object.entries(this.#session.urls)) {
272
- if (!url) continue;
273
- this.#terminal.log(`Crawling ${label}: ${url}`);
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
- this.#addResult({
285
- name : `[${label}] Route Discovery`,
286
- type : 'discovery',
287
- category: 'crawl',
288
- status : routes.length > 0 ? 'PASS' : 'FAIL',
289
- message : routes.length > 0
290
- ? `Discovered ${routes.length} routes`
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
- // ── Phase 2: API Validation ────────────────────────────────────────────
299
- async #phaseAPIValidation() {
300
- const apiRoutes = this.#session.routeMap.filter(r =>
301
- r.type === 'api' || r.url?.includes('/api/')
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
- this.#terminal.log(`Validating ${apiRoutes.length} API endpoints...`);
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
- for (const route of apiRoutes) {
307
- if (this.#aborted) break;
308
- this.#terminal.setCurrentTest(`API: ${route.url}`);
309
-
310
- const result = await this.#apiValidator.probe(route.url);
311
- this.#session.apiLog.push(result);
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
- if (!result.pass) {
331
- this.#session.addBug({
332
- title : `API Failure: ${route.url}`,
333
- severity : result.statusCode >= 500 ? 'P0' : 'P1',
334
- type : 'api',
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
- const discoveredAPIs = await this.#apiValidator.discoverFromNetworkLog(
342
- this.#session.networkLog
343
- );
344
- for (const api of discoveredAPIs) {
345
- if (!apiRoutes.find(r => r.url === api.url)) {
346
- this.#session.apiLog.push(api);
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
- // ── Phase 3: Browser Interactions ─────────────────────────────────────
352
- async #phaseBrowserInteractions() {
353
- const pageRoutes = this.#session.routeMap.filter(r =>
354
- r.type === 'page' || r.type === 'unknown'
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
- for (const route of pageRoutes.slice(0, 25)) {
358
- if (this.#aborted) break;
359
- this.#terminal.setCurrentTest(`Browser: ${route.url}`);
591
+ return out;
592
+ }
360
593
 
361
- const result = await this.#interactor.testPage(route.url, {
362
- onConsoleError: (err) => {
363
- this.#session.consoleErrors.push({ url: route.url, ...err });
364
- },
365
- onNetworkEvent: (event) => {
366
- this.#session.networkLog.push({ url: route.url, ...event });
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
- if (!result.pass || result.consoleErrors.length > 0) {
371
- const screenshot = await this.#screenshotter.capture(
372
- result.page,
373
- `fail-${shortId()}`
374
- );
375
- if (screenshot) {
376
- result.screenshotPath = screenshot;
377
- this.#session.screenshots.push({
378
- url : route.url,
379
- path : screenshot,
380
- reason: result.failReason,
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
- this.#addResult({
386
- name : `Page: ${route.url}`,
387
- type : 'browser',
388
- category: 'interaction',
389
- status : result.pass
390
- ? (result.consoleErrors.length > 0 ? 'FLAKY' : 'PASS')
391
- : 'FAIL',
392
- message : result.message,
393
- data : {
394
- loadTime : result.loadTime,
395
- consoleErrors : result.consoleErrors,
396
- networkErrors : result.networkErrors,
397
- interactedElements: result.interactedElements,
398
- screenshotPath : result.screenshotPath,
399
- jsErrors : result.jsErrors,
400
- resourcesFailed : result.resourcesFailed,
401
- renderTime : result.renderTime,
402
- domContentLoaded : result.domContentLoaded,
403
- },
404
- url : route.url,
405
- duration : result.loadTime,
406
- screenshotPath: result.screenshotPath,
407
- });
628
+ const esc = (s) => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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
- for (const err of (result.consoleErrors || [])) {
410
- this.#session.addBug({
411
- title : `JS Error: ${err.text?.slice(0, 80)}`,
412
- severity : err.type === 'error' ? 'P1' : 'P2',
413
- type : 'javascript',
414
- description: err.text,
415
- url : route.url,
416
- evidence : err,
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
- for (const nErr of (result.networkErrors || [])) {
421
- this.#session.addBug({
422
- title : `Network Failure: ${nErr.url}`,
423
- severity : 'P2',
424
- type : 'network',
425
- description: `${nErr.method} ${nErr.url} → ${nErr.failure}`,
426
- url : route.url,
427
- evidence : nErr,
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
- if (result.forms?.length > 0) {
432
- await this.#testForms(route.url, result.forms, result.page);
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
- if (this.#isAuthPage(route.url)) {
436
- await this.#testAuthFlow(route.url, result.page);
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
- // ── Phase 4: Security ─────────────────────────────────────────────────
442
- async #phaseSecurityScan() {
443
- for (const [label, url] of Object.entries(this.#session.urls)) {
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 this.#security.scan(url);
447
- this.#session.secFindings.push(...findings);
448
-
449
- for (const finding of findings) {
450
- this.#addResult({
451
- name : `Security: ${finding.check}`,
452
- type : 'security',
453
- category: finding.category,
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
- // ── Phase 5: Performance ──────────────────────────────────────────────
477
- async #phasePerformance() {
478
- for (const [label, url] of Object.entries(this.#session.urls)) {
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 metrics = await this.#performance.profile(url);
482
- this.#session.perfMetrics[label] = metrics;
483
-
484
- const vitals = [
485
- { name: 'LCP', value: metrics.lcp, threshold: 2500, unit: 'ms' },
486
- { name: 'FID', value: metrics.fid, threshold: 100, unit: 'ms' },
487
- { name: 'CLS', value: metrics.cls, threshold: 0.1, unit: '' },
488
- { name: 'FCP', value: metrics.fcp, threshold: 1800, unit: 'ms' },
489
- { name: 'TTFB', value: metrics.ttfb, threshold: 800, unit: 'ms' },
490
- { name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
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
- if (this.#aborted) break;
546
- this.#terminal.setCurrentTest(`A11y: ${route.url}`);
547
-
548
- const result = await this.#a11y.check(route.url);
549
- this.#session.a11yResults.push({ url: route.url, ...result });
550
-
551
- for (const violation of (result.violations || [])) {
552
- this.#addResult({
553
- name : `A11y [${violation.impact}]: ${violation.description}`,
554
- type : 'accessibility',
555
- category: violation.category || 'wcag',
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
- for (const pass of (result.passes || []).slice(0, 5)) {
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
- for (const route of pageRoutes) {
604
- if (this.#aborted) break;
605
- this.#terminal.setCurrentTest(`SEO: ${route.url}`);
606
-
607
- const result = await this.#seo.scan(route.url);
608
- this.#session.seoResults.push({ url: route.url, ...result });
609
-
610
- for (const check of (result.checks || [])) {
611
- this.#addResult({
612
- name : `SEO: ${check.name} ${new URL(route.url).pathname}`,
613
- type : 'seo',
614
- category: check.category,
615
- status : check.pass ? 'PASS' : 'FAIL',
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
- if (!result.pass) {
681
- this.#session.addBug({
682
- title : `Form broken: ${form.action || url}`,
683
- severity : 'P1',
684
- type : 'form',
685
- description: result.message,
686
- url,
687
- evidence : result.errors,
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
- // ── Auth Flow Testing ─────────────────────────────────────────────────
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
- this.#addResult({
706
- name : `Auth flow: ${url}`,
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
- #isAuthPage(url) {
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
- // Public API — exported functions
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
- export async function saveSession(session) {
760
- const history = await loadHistory();
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 : session.id,
764
- startedAt : session.startedAt,
765
- urls : session.urls,
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
- export async function loadHistory() {
776
- try { return await fs.readJson(HISTORY_FILE); }
777
- catch { return { runs: [], version: VERSION }; }
778
- }
1191
+ // ═══════════════════════════════════════════════════════════════════════════
1192
+ // Public API
1193
+ // ═══════════════════════════════════════════════════════════════════════════
779
1194
 
780
- // ── URL QA entry point ────────────────────────────────────────────────────
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 === 0) {
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
- const engine = new QAEngine(session, options);
1204
+ await runQAEngine(session);
1205
+ const { htmlPath, jsonPath } = await generateReports(session);
1206
+ await saveToHistory(session, htmlPath, jsonPath);
794
1207
 
795
- await engine.init();
796
- await engine.run();
797
- await saveSession(session);
798
-
799
- const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
800
- const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
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
- // ── Automated QA entry point ──────────────────────────────────────────────
806
- export async function runAutomatedQA({ continuous = false, localUrl, prodUrl, stagingUrl } = {}) {
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
- const engine = new QAEngine(session);
819
-
820
- await engine.init();
821
- await engine.run();
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 runOnce();
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} ── ${new Date().toLocaleTimeString()}`));
842
- await runOnce();
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 — what to run?',
1250
+ message: 'Manual QA mode:',
853
1251
  options: [
854
- { value: 'full-url', label: '🌐 Full URL-Based Real Scan', hint: 'Browser + API + Security + Perf + SEO + A11y' },
855
- { value: 'security', label: '🛡️ Security Only', hint: 'Real HTTP security header + vuln scan' },
856
- { value: 'perf', label: ' Performance Only', hint: 'Real Core Web Vitals measurement' },
857
- { value: 'a11y', label: '♿ Accessibility Only', hint: 'Real axe-core WCAG scan' },
858
- { value: 'seo', label: '🔎 SEO Only', hint: 'Real meta, og, robots, sitemap scan' },
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 prodUrl = await p.text({
871
- message : 'Production URL (blank to skip):',
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
- const session = new QASession(urls);
881
- const engine = new QAEngine(session);
882
- await engine.init();
883
- await engine.runPhase(action);
884
-
885
- await saveSession(session);
886
- const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
887
- if (htmlPath) {
888
- p.outro(chalk.hex('#00F5FF').bold('✓ QA complete'));
889
- console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
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
- // ── Post-generation validation ────────────────────────────────────────────
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
- const history = await loadHistory();
916
- if (!history.runs?.length) {
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 = run.summary?.passRate ?? '–';
927
- const color = Number(rate) >= 90 ? chalk.green
928
- : Number(rate) >= 70 ? chalk.yellow : chalk.red;
929
- const bugs = run.bugCount ?? 0;
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 — may have been deleted.'));
1354
+ console.log(chalk.yellow(' Report file not found.'));
969
1355
  }
970
1356
  }