create-backlist 10.0.8 → 10.1.0

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.
@@ -1,77 +1,66 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
- // Backlist Enterprise AI QA Platformqa-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 v13.0 PLAYWRIGHT REAL BROWSER EDITION
3
+ // 100% Real Runtime Testing · Live Playwright Tests · Rich HTML Reports
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
- export const VERSION = '12.0.0';
16
+ export const VERSION = '13.0.0';
33
17
  export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
34
18
  export const REPORT_DIR = path.join(QA_DIR, 'reports');
35
19
  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
+ // ── readline helper ───────────────────────────────────────────────────────
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
+ // ── Playwright availability check ────────────────────────────────────────
52
+ async function getPlaywright() {
53
+ try {
54
+ const pw = await import('playwright');
55
+ return pw.chromium || pw.default?.chromium || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // ═══════════════════════════════════════════════════════════════════════════
62
+ // QA Session
63
+ // ═══════════════════════════════════════════════════════════════════════════
75
64
  export class QASession {
76
65
  id;
77
66
  startedAt;
@@ -87,17 +76,21 @@ export class QASession {
87
76
  secFindings = [];
88
77
  a11yResults = [];
89
78
  seoResults = [];
79
+ playwrightMode = false;
90
80
 
91
- constructor(urls) {
92
- this.id = `QA-${shortId()}`;
81
+ constructor(urls = {}) {
82
+ this.id = `QA-${shortId().toUpperCase()}`;
93
83
  this.startedAt = timestamp();
94
84
  this.urls = urls;
95
85
  }
96
86
 
97
- addResult(result) { this.results.push(result); }
98
-
99
- addBug(bug) {
100
- this.bugs.push({ ...bug, id: `BUG-${shortId()}`, createdAt: timestamp() });
87
+ addResult(r) { this.results.push(r); }
88
+ addBug(bug) {
89
+ this.bugs.push({
90
+ ...bug,
91
+ id: `BUG-${shortId().toUpperCase()}`,
92
+ createdAt: timestamp(),
93
+ });
101
94
  }
102
95
 
103
96
  getSummary() {
@@ -114,639 +107,1637 @@ export class QASession {
114
107
  }
115
108
  }
116
109
 
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();
138
- }
139
-
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
148
- }
110
+ // ═══════════════════════════════════════════════════════════════════════════
111
+ // HTTP Probe real HTTP requests
112
+ // ═══════════════════════════════════════════════════════════════════════════
113
+ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
114
+ const t0 = Date.now();
115
+ try {
116
+ const ctrl = new AbortController();
117
+ const timer = setTimeout(() => ctrl.abort(), timeout);
118
+ const res = await fetch(url, {
119
+ method,
120
+ signal : ctrl.signal,
121
+ headers : { 'User-Agent': 'Backlist-QA/13.0', Accept: '*/*', ...headers },
122
+ redirect: 'follow',
123
+ });
124
+ clearTimeout(timer);
149
125
 
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
- }
172
- }
173
- } else {
174
- const exeName = launchOpts.executablePath?.split(/[/\\]/).pop() ?? 'chromium';
175
- console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${exeName})`));
176
- }
126
+ const rt = Date.now() - t0;
127
+ const contentType = res.headers.get('content-type') || '';
128
+ const hdrs = {};
129
+ res.headers.forEach((v, k) => { hdrs[k] = v; });
130
+
131
+ let body = '', bodySize = 0;
132
+ try { body = await res.text(); bodySize = new TextEncoder().encode(body).length; } catch {}
177
133
 
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();
134
+ let parsed = null;
135
+ if (contentType.includes('json')) { try { parsed = JSON.parse(body); } catch {} }
188
136
 
189
- await this.#interactor.launch();
190
- await this.#screenshotter.init();
137
+ return {
138
+ ok: res.status >= 200 && res.status < 400,
139
+ status: res.status, contentType, headers: hdrs,
140
+ body: body.slice(0, 3000), parsed, bodySize,
141
+ responseTime: rt, url, method, error: null,
142
+ };
143
+ } catch (err) {
144
+ return {
145
+ ok: false, status: 0, contentType: '', headers: {},
146
+ body: '', parsed: null, bodySize: 0,
147
+ responseTime: Date.now() - t0, url, method,
148
+ error: err.message,
149
+ };
191
150
  }
151
+ }
192
152
 
193
- async run() {
194
- this.#terminal.start();
195
- this.emit('session:start', this.#session);
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+ // PLAYWRIGHT REAL BROWSER ENGINE
155
+ // - Real browser rendering (Chromium)
156
+ // - Console error capture
157
+ // - Network request interception
158
+ // - Real Web Vitals (LCP, FCP, CLS, TBT)
159
+ // - Screenshot capture
160
+ // - DOM interaction tests
161
+ // ═══════════════════════════════════════════════════════════════════════════
162
+ async function runPlaywrightScan(url, session, dash, options = {}) {
163
+ const chromium = await getPlaywright();
164
+ if (!chromium) {
165
+ dash?.log(chalk.yellow(' ⚠ Playwright not found. Run: npm install playwright && npx playwright install chromium'));
166
+ return null;
167
+ }
196
168
 
197
- try {
198
- this.#terminal.setPhase('🔍 Phase 1: Route Discovery & Crawling');
199
- await this.#phaseDiscovery();
169
+ dash?.log(chalk.cyan(` 🎭 Playwright browser launching for ${url}...`));
170
+
171
+ let browser, context, page;
172
+ const results = {
173
+ consoleErrors : [],
174
+ networkFails : [],
175
+ screenshots : [],
176
+ vitals : {},
177
+ interactions : [],
178
+ domChecks : [],
179
+ jsErrors : [],
180
+ networkRequests: [],
181
+ };
182
+
183
+ try {
184
+ browser = await chromium.launch({
185
+ headless: options.headless !== false,
186
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
187
+ });
200
188
 
201
- this.#terminal.setPhase('📡 Phase 2: Real API Validation');
202
- await this.#phaseAPIValidation();
189
+ context = await browser.newContext({
190
+ viewport: { width: 1280, height: 900 },
191
+ userAgent: 'Backlist-QA/13.0 (Playwright)',
192
+ ignoreHTTPSErrors: true,
193
+ recordVideo: options.recordVideo ? { dir: SCREENSHOT_DIR } : undefined,
194
+ });
203
195
 
204
- this.#terminal.setPhase('🖱️ Phase 3: Browser Interaction Testing');
205
- await this.#phaseBrowserInteractions();
196
+ page = await context.newPage();
206
197
 
207
- this.#terminal.setPhase('🛡️ Phase 4: Security Deep Scan');
208
- await this.#phaseSecurityScan();
198
+ // ── Capture console messages ─────────────────────────────────────────
199
+ page.on('console', (msg) => {
200
+ const type = msg.type();
201
+ const text = msg.text();
202
+ if (['error', 'warning'].includes(type)) {
203
+ const entry = { type, text, timestamp: Date.now(), url: page.url() };
204
+ results.consoleErrors.push(entry);
205
+ session.consoleErrors.push(entry);
206
+ }
207
+ });
209
208
 
210
- this.#terminal.setPhase('⚡ Phase 5: Performance Profiling');
211
- await this.#phasePerformance();
209
+ // ── Capture JS errors ────────────────────────────────────────────────
210
+ page.on('pageerror', (err) => {
211
+ const entry = { message: err.message, stack: err.stack, url: page.url(), timestamp: Date.now() };
212
+ results.jsErrors.push(entry);
213
+ session.consoleErrors.push({ type: 'pageerror', text: err.message, url: page.url() });
214
+ });
212
215
 
213
- this.#terminal.setPhase('♿ Phase 6: Accessibility Testing');
214
- await this.#phaseAccessibility();
216
+ // ── Network monitoring ───────────────────────────────────────────────
217
+ const requestTimings = new Map();
218
+ page.on('request', (req) => {
219
+ requestTimings.set(req.url(), Date.now());
220
+ });
221
+ page.on('requestfailed', (req) => {
222
+ const entry = {
223
+ url : req.url(),
224
+ method : req.method(),
225
+ failure : req.failure()?.errorText || 'unknown',
226
+ timestamp: Date.now(),
227
+ };
228
+ results.networkFails.push(entry);
229
+ session.networkLog.push(entry);
230
+ });
231
+ page.on('response', (res) => {
232
+ const start = requestTimings.get(res.url()) || Date.now();
233
+ const duration = Date.now() - start;
234
+ const entry = {
235
+ url : res.url(),
236
+ status : res.status(),
237
+ duration,
238
+ size : parseInt(res.headers()['content-length'] || '0'),
239
+ type : res.headers()['content-type'] || '',
240
+ };
241
+ results.networkRequests.push(entry);
242
+ if (res.status() >= 400) {
243
+ results.networkFails.push({ url: res.url(), status: res.status(), duration });
244
+ }
245
+ });
215
246
 
216
- this.#terminal.setPhase('🔎 Phase 7: SEO Validation');
217
- await this.#phaseSEO();
247
+ // ── Navigate ─────────────────────────────────────────────────────────
248
+ const navStart = Date.now();
249
+ const response = await page.goto(url, {
250
+ waitUntil: 'networkidle',
251
+ timeout : 30000,
252
+ }).catch(err => ({ error: err.message }));
253
+ const navDuration = Date.now() - navStart;
254
+
255
+ if (response?.error) {
256
+ dash?.log(chalk.red(` ✗ Navigation failed: ${response.error}`));
257
+ return { error: response.error, results };
258
+ }
218
259
 
219
- this.#terminal.setPhase('🤖 Phase 8: AI Bug Classification');
220
- await this.#phaseAIClassification();
260
+ // ── Screenshot: Desktop ──────────────────────────────────────────────
261
+ await fs.ensureDir(SCREENSHOT_DIR);
262
+ const screenshotName = `${session.id}-desktop-${shortId()}.png`;
263
+ const screenshotPath = path.join(SCREENSHOT_DIR, screenshotName);
264
+ await page.screenshot({ path: screenshotPath, fullPage: true });
265
+ results.screenshots.push({ path: screenshotPath, name: screenshotName, type: 'desktop', url });
266
+ session.screenshots.push({ path: screenshotPath, name: screenshotName, type: 'desktop', url });
267
+ dash?.log(chalk.green(` 📸 Desktop screenshot: ${screenshotName}`));
268
+
269
+ // ── Screenshot: Mobile (viewport switch) ─────────────────────────────
270
+ await page.setViewportSize({ width: 390, height: 844 });
271
+ await page.waitForTimeout(500);
272
+ const mobileScreenshotName = `${session.id}-mobile-${shortId()}.png`;
273
+ const mobileScreenshotPath = path.join(SCREENSHOT_DIR, mobileScreenshotName);
274
+ await page.screenshot({ path: mobileScreenshotPath, fullPage: false });
275
+ results.screenshots.push({ path: mobileScreenshotPath, name: mobileScreenshotName, type: 'mobile', url });
276
+ session.screenshots.push({ path: mobileScreenshotPath, name: mobileScreenshotName, type: 'mobile', url });
277
+ dash?.log(chalk.green(` 📸 Mobile screenshot: ${mobileScreenshotName}`));
278
+ await page.setViewportSize({ width: 1280, height: 900 });
279
+
280
+ // ── Real Web Vitals via PerformanceObserver ───────────────────────────
281
+ dash?.log(chalk.cyan(' ⚡ Measuring real Web Vitals...'));
282
+ const vitals = await page.evaluate(() => {
283
+ return new Promise((resolve) => {
284
+ const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
285
+ let clsVal = 0;
286
+
287
+ // Navigation timing (TTFB)
288
+ const navEntry = performance.getEntriesByType('navigation')[0];
289
+ if (navEntry) v.ttfb = Math.round(navEntry.responseStart - navEntry.requestStart);
290
+
291
+ // FCP
292
+ const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
293
+ if (fcpEntry) v.fcp = Math.round(fcpEntry.startTime);
294
+
295
+ // Paint entries
296
+ const paintEntries = performance.getEntriesByType('paint');
297
+ paintEntries.forEach(entry => {
298
+ if (entry.name === 'first-contentful-paint') v.fcp = Math.round(entry.startTime);
299
+ });
221
300
 
301
+ // LCP Observer
302
+ try {
303
+ new PerformanceObserver((list) => {
304
+ const entries = list.getEntries();
305
+ const last = entries[entries.length - 1];
306
+ if (last) v.lcp = Math.round(last.startTime);
307
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
308
+ } catch {}
309
+
310
+ // CLS Observer
311
+ try {
312
+ new PerformanceObserver((list) => {
313
+ for (const entry of list.getEntries()) {
314
+ if (!entry.hadRecentInput) clsVal += entry.value;
315
+ }
316
+ v.cls = parseFloat(clsVal.toFixed(4));
317
+ }).observe({ type: 'layout-shift', buffered: true });
318
+ } catch {}
319
+
320
+ // Long tasks (TBT estimation)
321
+ try {
322
+ new PerformanceObserver((list) => {
323
+ for (const entry of list.getEntries()) {
324
+ if (entry.duration > 50) v.tbt += Math.round(entry.duration - 50);
325
+ }
326
+ }).observe({ type: 'longtask', buffered: true });
327
+ } catch {}
328
+
329
+ // Wait for all observers
330
+ setTimeout(() => {
331
+ v.cls = parseFloat(clsVal.toFixed(4));
332
+ resolve(v);
333
+ }, 2000);
334
+ });
335
+ }).catch(() => ({}));
336
+
337
+ // Merge with navigation timing
338
+ const navTiming = await page.evaluate(() => {
339
+ const nav = performance.getEntriesByType('navigation')[0];
340
+ if (!nav) return {};
341
+ return {
342
+ ttfb : Math.round(nav.responseStart - nav.requestStart),
343
+ domLoad : Math.round(nav.domContentLoadedEventEnd),
344
+ fullLoad : Math.round(nav.loadEventEnd),
345
+ dnsLookup : Math.round(nav.domainLookupEnd - nav.domainLookupStart),
346
+ tcpConnect : Math.round(nav.connectEnd - nav.connectStart),
347
+ transferSize: nav.transferSize,
348
+ };
349
+ }).catch(() => ({}));
350
+
351
+ results.vitals = { ...vitals, ...navTiming, navDuration };
352
+ dash?.log(chalk.green(` ✓ Vitals: TTFB=${navTiming.ttfb||'?'}ms LCP=${vitals.lcp||'?'}ms FCP=${vitals.fcp||'?'}ms CLS=${vitals.cls??'?'}`));
353
+
354
+ // ── DOM Checks ───────────────────────────────────────────────────────
355
+ dash?.log(chalk.cyan(' 🔍 Running DOM checks...'));
356
+ const domChecks = await page.evaluate(() => {
357
+ const checks = [];
358
+
359
+ // Title
360
+ const title = document.title;
361
+ checks.push({ name: 'Page title', pass: !!title && title.length > 0, value: title?.slice(0, 80) });
362
+
363
+ // H1
364
+ const h1s = document.querySelectorAll('h1');
365
+ checks.push({ name: 'Single H1', pass: h1s.length === 1, value: `${h1s.length} H1 tags` });
366
+
367
+ // Images without alt
368
+ const imgs = document.querySelectorAll('img');
369
+ const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
370
+ checks.push({ name: 'Images alt text', pass: noAlt === 0, value: `${noAlt}/${imgs.length} missing alt` });
371
+
372
+ // Buttons accessible
373
+ const btns = document.querySelectorAll('button');
374
+ const noText = [...btns].filter(b => !b.textContent?.trim() && !b.getAttribute('aria-label')).length;
375
+ checks.push({ name: 'Buttons accessible', pass: noText === 0, value: `${noText} buttons missing label` });
376
+
377
+ // Links with href
378
+ const links = document.querySelectorAll('a');
379
+ const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
380
+ checks.push({ name: 'Links have href', pass: noHref === 0, value: `${noHref}/${links.length} empty links` });
381
+
382
+ // Forms with submit
383
+ const forms = document.querySelectorAll('form');
384
+ const noSubmit = [...forms].filter(f => !f.querySelector('[type="submit"], button')).length;
385
+ checks.push({ name: 'Forms have submit', pass: noSubmit === 0 || forms.length === 0, value: `${forms.length} forms` });
386
+
387
+ // Meta viewport
388
+ const vp = document.querySelector('meta[name="viewport"]');
389
+ checks.push({ name: 'Viewport meta', pass: !!vp, value: vp?.content || 'missing' });
390
+
391
+ // Color contrast check (heuristic)
392
+ const body = document.body;
393
+ const bodyStyle = window.getComputedStyle(body);
394
+ checks.push({ name: 'Body has styles', pass: !!bodyStyle.backgroundColor || !!bodyStyle.color, value: 'CSS applied' });
395
+
396
+ // Broken internal links check
397
+ const internalLinks = [...links].filter(l => {
398
+ try { return new URL(l.href).origin === window.location.origin; } catch { return false; }
399
+ });
400
+ checks.push({ name: 'Internal links count', pass: true, value: `${internalLinks.length} internal links` });
401
+
402
+ return checks;
403
+ }).catch(() => []);
404
+
405
+ results.domChecks = domChecks;
406
+ dash?.log(chalk.green(` ✓ DOM: ${domChecks.filter(c => c.pass).length}/${domChecks.length} checks passed`));
407
+
408
+ // ── Interaction Tests ────────────────────────────────────────────────
409
+ dash?.log(chalk.cyan(' 🖱️ Testing interactions...'));
410
+ const interactions = [];
411
+
412
+ // Test all clickable buttons
413
+ const buttonCount = await page.locator('button:visible').count().catch(() => 0);
414
+ interactions.push({ name: 'Visible buttons found', pass: true, value: `${buttonCount} buttons` });
415
+
416
+ // Test form inputs exist
417
+ const inputCount = await page.locator('input:visible').count().catch(() => 0);
418
+ interactions.push({ name: 'Form inputs found', pass: true, value: `${inputCount} inputs` });
419
+
420
+ // Test scroll behavior
421
+ try {
422
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
423
+ await page.waitForTimeout(300);
424
+ await page.evaluate(() => window.scrollTo(0, 0));
425
+ interactions.push({ name: 'Page scroll', pass: true, value: 'Scroll works' });
222
426
  } catch (err) {
223
- this.emit('engine:error', err);
224
- throw err;
225
- } finally {
226
- this.#terminal.stop();
227
- await this.#interactor.close().catch(() => {});
427
+ interactions.push({ name: 'Page scroll', pass: false, value: err.message });
228
428
  }
229
429
 
230
- return this.#session;
231
- }
430
+ // Test keyboard navigation (Tab key)
431
+ try {
432
+ await page.keyboard.press('Tab');
433
+ await page.waitForTimeout(100);
434
+ const focused = await page.evaluate(() => document.activeElement?.tagName || 'none');
435
+ interactions.push({ name: 'Keyboard navigation', pass: focused !== 'BODY', value: `Focus: ${focused}` });
436
+ } catch {
437
+ interactions.push({ name: 'Keyboard navigation', pass: false, value: 'Tab focus failed' });
438
+ }
232
439
 
233
- // Run a single named phase (used by manual QA)
234
- async runPhase(name) {
235
- this.#terminal.start();
440
+ // Hover test on first link
236
441
  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();
442
+ const firstLink = page.locator('a:visible').first();
443
+ if (await firstLink.count() > 0) {
444
+ await firstLink.hover();
445
+ interactions.push({ name: 'Link hover', pass: true, value: 'Hover works' });
255
446
  }
256
- } finally {
257
- this.#terminal.stop();
258
- await this.#interactor.close().catch(() => {});
447
+ } catch {
448
+ interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' });
259
449
  }
260
- return this.#session;
261
- }
262
450
 
263
- abort() {
264
- this.#aborted = true;
265
- this.#terminal.stop();
266
- this.#interactor.close().catch(() => {});
267
- }
451
+ results.interactions = interactions;
452
+ dash?.log(chalk.green(` ✓ Interactions: ${interactions.filter(i => i.pass).length}/${interactions.length} passed`));
453
+
454
+ // ── Resource Analysis ─────────────────────────────────────────────────
455
+ const resourceStats = await page.evaluate(() => {
456
+ const entries = performance.getEntriesByType('resource');
457
+ const byType = {};
458
+ let totalSize = 0;
459
+ let totalTime = 0;
460
+
461
+ for (const e of entries) {
462
+ const t = e.initiatorType || 'other';
463
+ if (!byType[t]) byType[t] = { count: 0, size: 0, time: 0, slow: [] };
464
+ byType[t].count++;
465
+ byType[t].size += e.transferSize || 0;
466
+ byType[t].time += e.duration;
467
+ totalSize += e.transferSize || 0;
468
+ totalTime += e.duration;
469
+ if (e.duration > 500) {
470
+ byType[t].slow.push({ url: e.name.split('/').pop().slice(0, 60), duration: Math.round(e.duration), size: e.transferSize || 0 });
471
+ }
472
+ }
473
+ return { byType, totalSize, totalTime: Math.round(totalTime), count: entries.length };
474
+ }).catch(() => ({}));
268
475
 
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
- });
476
+ results.resourceStats = resourceStats;
283
477
 
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
- }
478
+ return { results, navDuration, error: null };
479
+
480
+ } catch (err) {
481
+ dash?.log(chalk.red(` ✗ Playwright error: ${err.message}`));
482
+ return { error: err.message, results };
483
+ } finally {
484
+ try { await page?.close(); } catch {}
485
+ try { await context?.close(); } catch {}
486
+ try { await browser?.close(); } catch {}
296
487
  }
488
+ }
297
489
 
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
- );
490
+ // ═══════════════════════════════════════════════════════════════════════════
491
+ // Route Crawler — real HTTP crawl
492
+ // ═══════════════════════════════════════════════════════════════════════════
493
+ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
494
+ const visited = new Set();
495
+ const queue = [{ url: baseUrl, depth: 0 }];
496
+ const routes = [];
497
+
498
+ const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
499
+ const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
500
+
501
+ while (queue.length > 0 && routes.length < maxPages) {
502
+ const { url, depth } = queue.shift();
503
+ const n = norm(url);
504
+ if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
505
+ visited.add(n);
506
+
507
+ const r = await httpProbe(n, { timeout: 10000 });
508
+ const type = (() => {
509
+ if (r.status >= 400) return 'error-page';
510
+ if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
511
+ if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
512
+ if (/\/(login|signin|auth)/i.test(n)) return 'auth';
513
+ if (/\/(admin)/i.test(n)) return 'admin';
514
+ return 'page';
515
+ })();
516
+
517
+ const links = [];
518
+ if (r.contentType.includes('text/html')) {
519
+ const re = /href=["']([^"'#?][^"']*?)["']/gi;
520
+ let m;
521
+ while ((m = re.exec(r.body)) !== null) {
522
+ try { links.push(new URL(m[1], n).toString()); } catch {}
523
+ }
524
+ }
303
525
 
304
- this.#terminal.log(`Validating ${apiRoutes.length} API endpoints...`);
526
+ const forms = [];
527
+ const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
528
+ let fm;
529
+ while ((fm = formRe.exec(r.body)) !== null) {
530
+ const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
531
+ const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
532
+ const fields = [];
533
+ const ir = /<input([^>]*)>/gi; let inp;
534
+ while ((inp = ir.exec(fm[2])) !== null) {
535
+ const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
536
+ const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
537
+ if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
538
+ }
539
+ forms.push({ action, method, fields });
540
+ }
305
541
 
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
- });
542
+ const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error };
543
+ routes.push(route);
544
+ if (onRoute) onRoute(route);
329
545
 
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
- }
546
+ for (const link of links.slice(0, 20)) {
547
+ const ln = norm(link);
548
+ if (ln && !visited.has(ln) && sameOrigin(ln)) queue.push({ url: ln, depth: depth + 1 });
339
549
  }
550
+ }
340
551
 
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);
552
+ // Common paths probe
553
+ const commonPaths = ['/api/health','/health','/api/status','/api/v1/health','/api/docs','/robots.txt','/sitemap.xml'];
554
+ for (const p2 of commonPaths) {
555
+ try {
556
+ const u = new URL(p2, baseUrl).toString();
557
+ const n = norm(u);
558
+ if (visited.has(n)) continue;
559
+ visited.add(n);
560
+ const r = await httpProbe(u, { timeout: 5000 });
561
+ if (r.status > 0 && r.status < 500) {
562
+ const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
563
+ routes.push(route);
564
+ if (onRoute) onRoute(route);
347
565
  }
348
- }
566
+ } catch {}
349
567
  }
350
568
 
351
- // ── Phase 3: Browser Interactions ─────────────────────────────────────
352
- async #phaseBrowserInteractions() {
353
- const pageRoutes = this.#session.routeMap.filter(r =>
354
- r.type === 'page' || r.type === 'unknown'
355
- );
569
+ return routes;
570
+ }
356
571
 
357
- for (const route of pageRoutes.slice(0, 25)) {
358
- if (this.#aborted) break;
359
- this.#terminal.setCurrentTest(`Browser: ${route.url}`);
572
+ // ═══════════════════════════════════════════════════════════════════════════
573
+ // Security Scanner
574
+ // ═══════════════════════════════════════════════════════════════════════════
575
+ async function runSecurityScan(url) {
576
+ const findings = [];
577
+ const r = await httpProbe(url);
578
+
579
+ if (!r.ok && r.status === 0) {
580
+ return [{
581
+ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
582
+ detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running',
583
+ }];
584
+ }
360
585
 
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
- });
586
+ const h = r.headers;
587
+
588
+ const headerChecks = [
589
+ { id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
590
+ validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
591
+ { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
592
+ validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
593
+ { id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
594
+ validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
595
+ { id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
596
+ validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
597
+ { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
598
+ validate: v => !!v, rec: 'Add Referrer-Policy header' },
599
+ { id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
600
+ validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
601
+ { id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
602
+ validate: v => !v, rec: 'Remove X-Powered-By header' },
603
+ ];
604
+
605
+ for (const c of headerChecks) {
606
+ const val = h[c.header] || '';
607
+ const pass = c.validate(val);
608
+ findings.push({
609
+ check: c.name, pass, severity: pass ? 'INFO' : c.sev,
610
+ category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
611
+ recommendation: c.rec, evidence: { header: c.header, value: val || null },
612
+ });
613
+ }
369
614
 
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
- }
615
+ const isHTTPS = url.startsWith('https://');
616
+ findings.push({
617
+ check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
618
+ category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted',
619
+ recommendation: 'Use HTTPS with valid SSL', evidence: { protocol: new URL(url).protocol },
620
+ });
384
621
 
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,
622
+ const corsOrigin = h['access-control-allow-origin'];
623
+ const corsCreds = h['access-control-allow-credentials'];
624
+ const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
625
+ findings.push({
626
+ check: 'CORS wildcard + credentials', pass: corsPass,
627
+ severity: corsPass ? 'INFO' : 'P0', category: 'cors',
628
+ detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
629
+ recommendation: 'Never combine CORS * with allow-credentials',
630
+ evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds },
631
+ });
632
+
633
+ const base = new URL(url).origin;
634
+ const sensitives = [
635
+ { path: '/.env', name: '.env exposed' },
636
+ { path: '/.git/config', name: 'Git config exposed' },
637
+ { path: '/phpinfo.php', name: 'phpinfo exposed' },
638
+ { path: '/server-status', name: 'Apache server-status' },
639
+ { path: '/actuator', name: 'Spring actuator exposed' },
640
+ { path: '/graphql', name: 'GraphQL introspection' },
641
+ ];
642
+ for (const s of sensitives) {
643
+ try {
644
+ const ctrl = new AbortController();
645
+ const timer = setTimeout(() => ctrl.abort(), 4000);
646
+ const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
647
+ clearTimeout(timer);
648
+ const exposed = res.status === 200;
649
+ findings.push({
650
+ check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
651
+ category: 'information-disclosure',
652
+ detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
653
+ recommendation: exposed ? `Block access to ${s.path} immediately` : null,
654
+ evidence: { url: `${base}${s.path}`, status: res.status },
407
655
  });
656
+ } catch {}
657
+ }
408
658
 
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
- }
659
+ return findings;
660
+ }
419
661
 
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
- }
662
+ // ═══════════════════════════════════════════════════════════════════════════
663
+ // SEO Scanner
664
+ // ═══════════════════════════════════════════════════════════════════════════
665
+ async function runSEOScan(url) {
666
+ const t0 = Date.now();
667
+ const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
668
+ const html = r.body || '';
669
+ const rt = Date.now() - t0;
670
+ const checks = [];
671
+
672
+ const has = (p) => p.test(html);
673
+ const get = (p) => (html.match(p) || [])[1]?.trim() || null;
674
+
675
+ const title = get(/<title[^>]*>([^<]+)<\/title>/i);
676
+ checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
677
+ detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>', data: { title, length: title?.length },
678
+ recommendation: 'Add unique title (50-60 chars)' });
679
+
680
+ if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
681
+ severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)`,
682
+ recommendation: 'Keep title 30-60 chars' });
683
+
684
+ const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
685
+ || get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
686
+ checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
687
+ detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description',
688
+ recommendation: 'Add meta description (120-160 chars)' });
689
+
690
+ const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
691
+ checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
692
+ detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : '1 H1 ✓',
693
+ recommendation: 'Use exactly one H1 per page' });
694
+
695
+ const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
696
+ checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
697
+ detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
698
+ recommendation: 'Add viewport meta tag' });
699
+
700
+ const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
701
+ checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
702
+ detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
703
+
704
+ const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
705
+ checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
706
+ detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
707
+ recommendation: 'Add <link rel="canonical">' });
708
+
709
+ const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
710
+ checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
711
+ detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
712
+ recommendation: 'Add og:title, og:description, og:image' });
713
+
714
+ const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
715
+ const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
716
+ checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
717
+ detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
718
+ recommendation: 'Add alt text to all images' });
719
+
720
+ checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
721
+ category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
722
+ recommendation: 'Optimize TTFB with CDN and caching' });
723
+
724
+ const base = new URL(url).origin;
725
+ for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
726
+ try {
727
+ const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
728
+ checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
729
+ detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}`,
730
+ recommendation: `Ensure ${name} exists` });
731
+ } catch {
732
+ checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
733
+ }
734
+ }
430
735
 
431
- if (result.forms?.length > 0) {
432
- await this.#testForms(route.url, result.forms, result.page);
433
- }
736
+ return { pass: checks.filter(c => !c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
737
+ }
434
738
 
435
- if (this.#isAuthPage(route.url)) {
436
- await this.#testAuthFlow(route.url, result.page);
437
- }
739
+ // ═══════════════════════════════════════════════════════════════════════════
740
+ // Accessibility Scanner — HTML analysis
741
+ // ═══════════════════════════════════════════════════════════════════════════
742
+ async function runA11yScan(url) {
743
+ const r = await httpProbe(url, { timeout: 12000 });
744
+ const html = r.body || '';
745
+ const violations = [], passes = [];
746
+
747
+ const checks = [
748
+ { id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
749
+ { id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
750
+ { id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
751
+ { id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
752
+ { id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
753
+ { id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
754
+ { id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
755
+ { 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' },
756
+ ];
757
+
758
+ for (const c of checks) {
759
+ if (c.test()) {
760
+ violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
761
+ tags: ['wcag2a'], category: 'wcag2a', nodes: 1, affectedNodes: [],
762
+ helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
763
+ } else {
764
+ passes.push({ id: c.id, description: c.pass, nodes: 1 });
438
765
  }
439
766
  }
440
767
 
441
- // ── Phase 4: Security ─────────────────────────────────────────────────
442
- async #phaseSecurityScan() {
443
- for (const [label, url] of Object.entries(this.#session.urls)) {
444
- if (!url) continue;
768
+ const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
769
+ return { pass: violations.length === 0, violations, passes, incomplete: [], score, url, mode: 'http-html-analysis' };
770
+ }
445
771
 
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
- });
772
+ // ═══════════════════════════════════════════════════════════════════════════
773
+ // AI Bug Classifier
774
+ // ═══════════════════════════════════════════════════════════════════════════
775
+ const SEV_PATTERNS = {
776
+ P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
777
+ P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
778
+ P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
779
+ P3: [/warning|minor|style|typo|cosmetic/i],
780
+ };
781
+ const CAT_PATTERNS = {
782
+ security : /security|csp|hsts|cors|xss|injection|auth|token/i,
783
+ performance : /lcp|fcp|cls|ttfb|slow|timeout|render/i,
784
+ accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
785
+ seo : /title|meta|description|canonical|sitemap|robots/i,
786
+ api : /api|endpoint|status.*code|response|rest/i,
787
+ javascript : /js.*error|console.*error|uncaught|undefined|null/i,
788
+ network : /network|fetch|connection|request.*fail/i,
789
+ };
790
+ function classifyBug(bug) {
791
+ const text = `${bug.title} ${bug.description || ''}`;
792
+ let severity = bug.severity || 'P3', confidence = 0.7;
793
+ for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
794
+ if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.85; break; }
795
+ }
796
+ let category = bug.type || 'general';
797
+ for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
798
+ if (pat.test(text)) { category = cat; break; }
799
+ }
800
+ const recs = {
801
+ security : 'Review security config and run penetration test',
802
+ performance : 'Run Lighthouse and optimize assets/server',
803
+ accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
804
+ seo : 'Fix meta tags and submit sitemap to Search Console',
805
+ api : 'Check API contract and add proper error handling',
806
+ javascript : 'Debug in browser DevTools, add error boundaries',
807
+ network : 'Check CDN, server logs, network config',
808
+ };
809
+ return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
810
+ }
460
811
 
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
- });
471
- }
472
- }
473
- }
812
+ // ═══════════════════════════════════════════════════════════════════════════
813
+ // Terminal Dashboard
814
+ // ═══════════════════════════════════════════════════════════════════════════
815
+ class TerminalDashboard {
816
+ #session; #lines = 0; #active = false; #timer = null;
817
+ #phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
818
+ #pwMode = false;
819
+
820
+ constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
821
+
822
+ start() {
823
+ this.#active = true; this.#startTime = Date.now();
824
+ process.stdout.write('\x1b[?25l');
825
+ this.#render();
826
+ this.#timer = setInterval(() => this.#render(), 600);
474
827
  }
475
828
 
476
- // ── Phase 5: Performance ──────────────────────────────────────────────
477
- async #phasePerformance() {
478
- for (const [label, url] of Object.entries(this.#session.urls)) {
479
- if (!url) continue;
829
+ stop() {
830
+ this.#active = false;
831
+ if (this.#timer) { clearInterval(this.#timer); this.#timer = null; }
832
+ this.#clear();
833
+ process.stdout.write('\x1b[?25h');
834
+ this.#printFinal();
835
+ }
480
836
 
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
- });
837
+ setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
838
+ setCurrentTest(t) { this.#currentTest = t; }
839
+ addResult() { this.#currentTest = ''; }
840
+ log(msg) {
841
+ this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
842
+ if (this.#log.length > 8) this.#log.shift();
843
+ }
509
844
 
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
- }
845
+ #render() {
846
+ if (!this.#active) return;
847
+ this.#clear();
848
+ const lines = this.#build();
849
+ this.#lines = lines.length;
850
+ process.stdout.write(lines.join('\n') + '\n');
851
+ }
522
852
 
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
- });
534
- }
853
+ #clear() {
854
+ if (this.#lines > 0) {
855
+ process.stdout.write(`\x1b[${this.#lines}A`);
856
+ for (let i = 0; i < this.#lines; i++) process.stdout.write('\x1b[2K\n');
857
+ process.stdout.write(`\x1b[${this.#lines}A`);
535
858
  }
859
+ this.#lines = 0;
536
860
  }
537
861
 
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
-
544
- 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
- }
581
- }
862
+ #build() {
863
+ const s = this.#session;
864
+ const elapsed = ((Date.now() - this.#startTime) / 1000).toFixed(1);
865
+ const passed = s.results.filter(r => r.status === 'PASS' || r.status === 'FLAKY').length;
866
+ const failed = s.results.filter(r => r.status === 'FAIL').length;
867
+ const total = s.results.length;
868
+ const rate = total > 0 ? Math.round(passed / total * 100) : 0;
869
+ const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
870
+ const w = Math.min(process.stdout.columns || 80, 88);
871
+ const bar = '─'.repeat(w - 2);
872
+ const c1 = chalk.hex('#00F5FF');
873
+ const c2 = chalk.hex('#BF40FF');
874
+ const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
875
+ const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 PLAYWRIGHT') : chalk.gray(' HTTP');
876
+
877
+ const pBar = (() => {
878
+ const f = Math.min(Math.round(rate / 100 * 26), 26);
879
+ const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
880
+ return col(''.repeat(f)) + chalk.gray('░'.repeat(26 - f));
881
+ })();
882
+
883
+ const out = [
884
+ c1(`┌${bar}┐`),
885
+ c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — REAL BROWSER TESTING${pwTag}`)) + c1('│'),
886
+ c1(`├${bar}┤`),
887
+ c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
888
+ c1(`├${bar}┤`),
889
+ 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('│'),
890
+ c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1(''),
891
+ c1(`├${bar}┤`),
892
+ c1('│') + pad(this.#currentTest ? ` ${chalk.yellow('⟳')} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Running...')}`) + c1('│'),
893
+ c1(`├${bar}┤`),
894
+ c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('Screenshots:')} ${chalk.white(s.screenshots.length)} ${chalk.cyan('Bugs:')} ${chalk.white(s.bugs.length)} ${chalk.cyan('Net Errors:')} ${chalk.white(s.networkLog.length)}`) + c1(''),
895
+ c1(`├${bar}┤`),
896
+ ];
897
+
898
+ const recent = s.results.slice(-5);
899
+ for (const r of recent) {
900
+ const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
901
+ out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(12) + ']')} ${chalk.white((r.name||'').slice(0, w - 30))}`) + c1('│'));
902
+ }
903
+ for (let i = recent.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
582
904
 
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
- });
593
- }
905
+ out.push(c1(`├${bar}┤`));
906
+ for (const entry of this.#log.slice(-4)) {
907
+ out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
594
908
  }
909
+ for (let i = this.#log.length; i < 4; i++) out.push(c1('│') + pad('') + c1('│'));
910
+ out.push(c1(`└${bar}┘`));
911
+ out.push(chalk.dim(` Real browser data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
912
+
913
+ return out;
595
914
  }
596
915
 
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);
916
+ #printFinal() {
917
+ const s = this.#session.getSummary();
918
+ const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
919
+ console.log('');
920
+ console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
921
+ console.log(` Tests: ${chalk.white.bold(s.total)}`);
922
+ console.log(` Passed: ${chalk.green.bold(s.passed)}`);
923
+ console.log(` Failed: ${chalk.red.bold(s.failed)}`);
924
+ console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
925
+ console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
926
+ console.log(` Screenshots: ${chalk.white(this.#session.screenshots.length)}`);
927
+ console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
928
+ console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Playwright (Real Browser)') : chalk.gray('HTTP-only')}`);
929
+ console.log('');
930
+ }
931
+ }
602
932
 
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
- });
933
+ // ═══════════════════════════════════════════════════════════════════════════
934
+ // HTML Report Builder — v13, Dark Theme, Screenshot Gallery + Vitals
935
+ // ═══════════════════════════════════════════════════════════════════════════
936
+ function buildHTMLReport(session) {
937
+ const summary = session.getSummary();
938
+ const passRate = Number(summary.passRate);
939
+ const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
940
+
941
+ const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
942
+ session.bugs.forEach(b => {
943
+ const key = b.aiSeverity || b.severity;
944
+ if (sevCounts[key] !== undefined) sevCounts[key]++;
945
+ });
621
946
 
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
- }
632
- }
633
- }
947
+ const coverage = {};
948
+ for (const r of session.results) {
949
+ if (!coverage[r.type]) coverage[r.type] = { pass: 0, fail: 0 };
950
+ if (r.status === 'PASS' || r.status === 'FLAKY') coverage[r.type].pass++;
951
+ else if (r.status === 'FAIL') coverage[r.type].fail++;
634
952
  }
635
953
 
636
- // ── Phase 8: AI Classification ────────────────────────────────────────
637
- async #phaseAIClassification() {
638
- this.#terminal.log(`AI classifying ${this.#session.bugs.length} bugs...`);
954
+ const esc = (s) => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
955
+
956
+ // ── Screenshot gallery ───────────────────────────────────────────────────
957
+ const screenshotCards = session.screenshots.length
958
+ ? session.screenshots.map(sc => {
959
+ // Embed screenshot as base64 if possible, else show path
960
+ let imgTag = '';
961
+ try {
962
+ const data = fs.readFileSync(sc.path);
963
+ const b64 = data.toString('base64');
964
+ imgTag = `<img src="data:image/png;base64,${b64}" alt="${esc(sc.type)} screenshot" loading="lazy">`;
965
+ } catch {
966
+ imgTag = `<div class="no-img">Screenshot: ${esc(sc.name)}</div>`;
967
+ }
968
+ return `
969
+ <div class="screenshot-card">
970
+ <div class="sc-header">
971
+ <span class="sc-type">${esc(sc.type)}</span>
972
+ <span class="sc-url">${esc(sc.url || '')}</span>
973
+ </div>
974
+ <div class="sc-img-wrap">${imgTag}</div>
975
+ <div class="sc-path">${esc(sc.path)}</div>
976
+ </div>`;
977
+ }).join('')
978
+ : '<p class="no-data">No screenshots (Playwright not available)</p>';
979
+
980
+ // ── Test rows ─────────────────────────────────────────────────────────────
981
+ const testRows = session.results.map(r => `
982
+ <tr class="result-row" data-type="${r.type}" data-status="${r.status}">
983
+ <td>${esc(r.name)}</td>
984
+ <td><span class="badge">${r.type}</span></td>
985
+ <td><span class="status status-${(r.status||'').toLowerCase()}">${r.status}</span></td>
986
+ <td>${r.severity ? `<span class="sev sev-${(r.severity||'').toLowerCase()}">${r.severity}</span>` : '–'}</td>
987
+ <td>${r.duration ? formatDuration(r.duration) : '–'}</td>
988
+ <td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
989
+ </tr>`).join('');
990
+
991
+ // ── Bug cards ─────────────────────────────────────────────────────────────
992
+ const bugCards = session.bugs.length
993
+ ? session.bugs.map(b => `
994
+ <div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}" data-severity="${b.aiSeverity||b.severity}">
995
+ <div class="bug-header">
996
+ <span class="bug-id">${esc(b.id)}</span>
997
+ <span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
998
+ <span class="badge">${b.type||'general'}</span>
999
+ ${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
1000
+ </div>
1001
+ <div class="bug-title">${esc(b.title)}</div>
1002
+ ${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
1003
+ ${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
1004
+ ${b.evidence ? `<details><summary>Evidence</summary><pre>${esc(JSON.stringify(b.evidence,null,2).slice(0,800))}</pre></details>` : ''}
1005
+ </div>`).join('')
1006
+ : '<p class="no-data">No bugs detected 🎉</p>';
1007
+
1008
+ // ── Route rows ────────────────────────────────────────────────────────────
1009
+ const routeRows = session.routeMap.map(r => `
1010
+ <tr>
1011
+ <td><code class="url">${esc(r.url)}</code></td>
1012
+ <td><span class="badge">${r.type}</span></td>
1013
+ <td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
1014
+ <td>${r.forms?.length || 0}</td>
1015
+ <td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
1016
+ </tr>`).join('');
1017
+
1018
+ // ── Security rows ─────────────────────────────────────────────────────────
1019
+ const secRows = session.secFindings.map(f => `
1020
+ <tr class="${f.pass ? '' : 'fail-row'}">
1021
+ <td>${esc(f.check)}</td>
1022
+ <td><span class="badge">${f.category}</span></td>
1023
+ <td><span class="status ${f.pass ? 'status-pass' : 'status-fail'}">${f.pass?'PASS':'FAIL'}</span></td>
1024
+ <td>${f.severity !== 'INFO' ? `<span class="sev sev-${(f.severity||'').toLowerCase()}">${f.severity}</span>` : '–'}</td>
1025
+ <td>${esc((f.detail||'').slice(0,120))}</td>
1026
+ <td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
1027
+ </tr>`).join('');
1028
+
1029
+ // ── SEO section ───────────────────────────────────────────────────────────
1030
+ const seoSection = session.seoResults.map(r => `
1031
+ <div class="seo-page">
1032
+ <div class="seo-header">
1033
+ <a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
1034
+ <span>${r.checks.filter(c=>c.pass).length}/${r.checks.length} passed</span>
1035
+ </div>
1036
+ <table>
1037
+ <thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr></thead>
1038
+ <tbody>${(r.checks||[]).map(c => `<tr>
1039
+ <td>${esc(c.name)}</td><td>${c.category||'–'}</td>
1040
+ <td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
1041
+ <td>${esc((c.detail||'').slice(0,100))}</td>
1042
+ </tr>`).join('')}</tbody>
1043
+ </table>
1044
+ </div>`).join('') || '<p class="no-data">No SEO scans</p>';
1045
+
1046
+ // ── A11y section ──────────────────────────────────────────────────────────
1047
+ const a11ySection = session.a11yResults.map(r => `
1048
+ <div class="a11y-page">
1049
+ <div class="a11y-header">
1050
+ <a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
1051
+ <span>Score: <strong>${r.score??'–'}%</strong></span>
1052
+ <span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
1053
+ </div>
1054
+ ${(r.violations||[]).map(v => `
1055
+ <div class="violation impact-${v.impact}">
1056
+ <div class="violation-header">
1057
+ <span class="impact-badge">${v.impact}</span>
1058
+ <strong>${esc(v.description)}</strong>
1059
+ </div>
1060
+ <p>${esc(v.help)}</p>
1061
+ </div>`).join('') || '<p class="no-data">No violations ✓</p>'}
1062
+ </div>`).join('') || '<p class="no-data">No accessibility scans</p>';
1063
+
1064
+ // ── Performance section ───────────────────────────────────────────────────
1065
+ const vitalCard = (name, value, threshold, unit) => {
1066
+ const na = value === null || value === undefined;
1067
+ const pass2 = !na && value <= threshold;
1068
+ const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
1069
+ const color = na ? '#64748b' : pass2 ? '#22c55e' : '#ef4444';
1070
+ const disp = na ? 'N/A' : `${Number(value).toFixed(name==='CLS'?3:0)}${unit}`;
1071
+ return `<div class="vital-card ${cls}">
1072
+ <div class="vital-label">${name}</div>
1073
+ <div class="vital-value" style="color:${color}">${disp}</div>
1074
+ <div class="vital-threshold">≤${threshold}${unit}</div>
1075
+ </div>`;
1076
+ };
639
1077
 
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
- }
1078
+ const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => {
1079
+ const slowResHtml = (m.slowResources||[]).length ? `
1080
+ <h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
1081
+ <table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
1082
+ <tbody>${m.slowResources.map(r => `<tr>
1083
+ <td class="url">${esc((r.url||'').split('/').pop())}</td>
1084
+ <td class="fail">${r.duration}ms</td>
1085
+ <td>${formatBytes(r.size)}</td>
1086
+ </tr>`).join('')}</tbody></table>` : '';
1087
+
1088
+ const resourceTableHtml = m.resourceStats?.byType ? `
1089
+ <h4 style="color:#94a3b8;margin-top:1.5rem">Resource Breakdown</h4>
1090
+ <table><thead><tr><th>Type</th><th>Count</th><th>Total Size</th><th>Total Time</th></tr></thead>
1091
+ <tbody>${Object.entries(m.resourceStats.byType).map(([t, d]) => `<tr>
1092
+ <td><span class="badge">${esc(t)}</span></td>
1093
+ <td>${d.count}</td>
1094
+ <td>${formatBytes(d.size)}</td>
1095
+ <td>${Math.round(d.time)}ms</td>
1096
+ </tr>`).join('')}</tbody></table>` : '';
1097
+
1098
+ const domChecksHtml = m.domChecks?.length ? `
1099
+ <h4 style="color:#94a3b8;margin-top:1.5rem">DOM Checks</h4>
1100
+ <table><thead><tr><th>Check</th><th>Status</th><th>Value</th></tr></thead>
1101
+ <tbody>${m.domChecks.map(c => `<tr>
1102
+ <td>${esc(c.name)}</td>
1103
+ <td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
1104
+ <td>${esc(c.value||'')}</td>
1105
+ </tr>`).join('')}</tbody></table>` : '';
1106
+
1107
+ const interactionsHtml = m.interactions?.length ? `
1108
+ <h4 style="color:#94a3b8;margin-top:1.5rem">Interaction Tests</h4>
1109
+ <table><thead><tr><th>Test</th><th>Status</th><th>Value</th></tr></thead>
1110
+ <tbody>${m.interactions.map(i => `<tr>
1111
+ <td>${esc(i.name)}</td>
1112
+ <td><span class="status ${i.pass?'status-pass':'status-fail'}">${i.pass?'PASS':'FAIL'}</span></td>
1113
+ <td>${esc(i.value||'')}</td>
1114
+ </tr>`).join('')}</tbody></table>` : '';
1115
+
1116
+ return `
1117
+ <div class="perf-card">
1118
+ <h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Playwright</span>' : ''}</h3>
1119
+ <div class="vitals-grid">
1120
+ ${vitalCard('TTFB', m.ttfb, 800, 'ms')}
1121
+ ${vitalCard('LCP', m.lcp, 2500, 'ms')}
1122
+ ${vitalCard('FCP', m.fcp, 1800, 'ms')}
1123
+ ${vitalCard('CLS', m.cls, 0.1, '')}
1124
+ ${vitalCard('TBT', m.tbt, 200, 'ms')}
1125
+ ${vitalCard('DOM Load', m.domLoad, 3000, 'ms')}
1126
+ ${vitalCard('DNS', m.dnsLookup, 100, 'ms')}
1127
+ </div>
1128
+ ${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
1129
+ ${slowResHtml}
1130
+ ${resourceTableHtml}
1131
+ ${domChecksHtml}
1132
+ ${interactionsHtml}
1133
+ </div>`;
1134
+ }).join('') || '<p class="no-data">No performance data</p>';
1135
+
1136
+ // ── Console errors table ──────────────────────────────────────────────────
1137
+ const consoleSection = session.consoleErrors.length
1138
+ ? `<table>
1139
+ <thead><tr><th>Type</th><th>Message</th><th>URL</th></tr></thead>
1140
+ <tbody>${session.consoleErrors.slice(0, 100).map(e => `<tr>
1141
+ <td><span class="badge">${esc(e.type)}</span></td>
1142
+ <td>${esc(e.text?.slice(0, 200) || '')}</td>
1143
+ <td class="url">${esc(e.url || '')}</td>
1144
+ </tr>`).join('')}</tbody>
1145
+ </table>`
1146
+ : '<p class="no-data">No console errors 🎉</p>';
1147
+
1148
+ // ── Network failures table ────────────────────────────────────────────────
1149
+ const networkSection = session.networkLog.length
1150
+ ? `<table>
1151
+ <thead><tr><th>URL</th><th>Method</th><th>Failure</th></tr></thead>
1152
+ <tbody>${session.networkLog.slice(0, 100).map(e => `<tr>
1153
+ <td class="url">${esc(e.url || '')}</td>
1154
+ <td>${esc(e.method || '')}</td>
1155
+ <td class="fail">${esc(e.failure || e.error || `HTTP ${e.status}`)}</td>
1156
+ </tr>`).join('')}</tbody>
1157
+ </table>`
1158
+ : '<p class="no-data">No network failures 🎉</p>';
1159
+
1160
+ const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
1161
+ .map(([k,v]) => `<div class="url-card"><span class="url-label">${k}</span><a href="${esc(v)}" target="_blank">${esc(v)}</a></div>`).join('');
1162
+
1163
+ const chartTypes = JSON.stringify(Object.keys(coverage));
1164
+ const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
1165
+ const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
1166
+ const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
1167
+ const pwBadge = session.playwrightMode
1168
+ ? '<span style="background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44;padding:3px 10px;border-radius:20px;font-size:.7rem">🎭 Playwright</span>'
1169
+ : '<span style="background:#1e293b;color:#64748b;padding:3px 10px;border-radius:20px;font-size:.7rem">HTTP-only</span>';
1170
+
1171
+ return `<!DOCTYPE html>
1172
+ <html lang="en">
1173
+ <head>
1174
+ <meta charset="UTF-8">
1175
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1176
+ <title>Backlist QA Report — ${esc(session.id)}</title>
1177
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
1178
+ <style>
1179
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
1180
+ :root{--bg:#060610;--surface:#0f0f1e;--border:#1e1e3a;--text:#e2e8f0;--dim:#4a5568;--cyan:#00f5ff;--purple:#bf40ff;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}
1181
+ *{box-sizing:border-box;margin:0;padding:0}
1182
+ body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
1183
+ a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
1184
+ 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)}
1185
+ .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}
1186
+ .header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
1187
+ nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
1188
+ .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}
1189
+ .nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
1190
+ .container{max-width:1400px;margin:0 auto;padding:2rem}
1191
+ .tab-panel{display:none}.tab-panel.active{display:block}
1192
+ .pw-banner{background:rgba(191,64,255,.08);border:1px solid #bf40ff44;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:#c084fc;display:flex;align-items:center;gap:.5rem}
1193
+ .real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.83rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
1194
+ .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
1195
+ .mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
1196
+ .mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
1197
+ .ml{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
1198
+ .mv{font-size:1.8rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
1199
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
1200
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
1201
+ .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}
1202
+ .chart-wrap{position:relative;height:240px}
1203
+ .search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
1204
+ .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}
1205
+ table{width:100%;border-collapse:collapse;font-size:.8rem}
1206
+ 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}
1207
+ td{padding:.45rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
1208
+ tr.fail-row td{background:rgba(239,68,68,.04)}
1209
+ .pass{color:var(--green)}.fail{color:var(--red)}
1210
+ .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace}
1211
+ .status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}.status-flaky{background:#422006;color:#fbbf24}.status-skip{background:#1e293b;color:#94a3b8}
1212
+ .sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
1213
+ .sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
1214
+ .badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1e293b;color:#94a3b8}
1215
+ .pw-badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
1216
+ .url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
1217
+ code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
1218
+ 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}
1219
+ details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:none}
1220
+ .bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
1221
+ .bug-card:hover{border-left-color:var(--cyan)}
1222
+ .sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
1223
+ .sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
1224
+ .sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
1225
+ .bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
1226
+ .bug-id{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim)}
1227
+ .bug-title{font-weight:700;margin-bottom:.3rem}
1228
+ .bug-url{font-size:.75rem;margin-bottom:.3rem}
1229
+ .bug-rec{font-size:.78rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
1230
+ .ai-badge{font-size:.68rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
1231
+ .rec{font-size:.75rem;color:#86efac}
1232
+ .no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
1233
+ .url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
1234
+ .url-label{font-size:.7rem;color:var(--dim);text-transform:uppercase;min-width:90px}
1235
+ /* Screenshot gallery */
1236
+ .screenshot-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:1.25rem;margin-top:1rem}
1237
+ .screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:.2s}
1238
+ .screenshot-card:hover{border-color:var(--purple);transform:translateY(-3px);box-shadow:0 8px 32px rgba(191,64,255,.15)}
1239
+ .sc-header{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--border)}
1240
+ .sc-type{font-size:.7rem;padding:2px 8px;border-radius:4px;background:#1a1a3b;color:#c084fc;text-transform:uppercase;font-weight:700}
1241
+ .sc-url{font-size:.72rem;color:var(--dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;max-width:240px}
1242
+ .sc-img-wrap{background:#000;min-height:200px;display:flex;align-items:center;justify-content:center;overflow:hidden}
1243
+ .sc-img-wrap img{width:100%;height:auto;display:block;max-height:400px;object-fit:cover}
1244
+ .no-img{color:var(--dim);font-style:italic;padding:2rem;text-align:center}
1245
+ .sc-path{font-family:'JetBrains Mono',monospace;font-size:.67rem;color:var(--dim);padding:.5rem 1rem;background:#080810}
1246
+ /* Vitals */
1247
+ .vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
1248
+ .vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
1249
+ .vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
1250
+ .vital-label{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
1251
+ .vital-threshold{font-size:.68rem;color:var(--dim);margin-top:2px}
1252
+ .vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
1253
+ .vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
1254
+ .vital-na{background:var(--surface)}
1255
+ .perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
1256
+ .perf-card h3{color:var(--cyan);margin-bottom:.5rem}
1257
+ .perf-note{font-size:.78rem;color:var(--dim);font-style:italic;margin-top:.75rem}
1258
+ .seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
1259
+ .seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.85rem}
1260
+ .violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
1261
+ .impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
1262
+ .impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
1263
+ .impact-moderate{border-left-color:#3b82f6;background:rgba(59,130,246,.05)}
1264
+ .violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
1265
+ .impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
1266
+ .err-cell details{font-size:.78rem}
1267
+ 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}
1268
+ @media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
1269
+ </style>
1270
+ </head>
1271
+ <body>
1272
+ <header>
1273
+ <div>
1274
+ <div class="logo">⚡ Backlist Enterprise QA</div>
1275
+ <div class="header-meta">
1276
+ Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
1277
+ </div>
1278
+ </div>
1279
+ ${pwBadge}
1280
+ </header>
1281
+
1282
+ <nav>
1283
+ <button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
1284
+ <button class="nav-tab" onclick="showTab('screenshots',this)">📸 Screenshots (${session.screenshots.length})</button>
1285
+ <button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
1286
+ <button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
1287
+ <button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
1288
+ <button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
1289
+ <button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
1290
+ <button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
1291
+ <button class="nav-tab" onclick="showTab('seo',this)">🔎 SEO</button>
1292
+ <button class="nav-tab" onclick="showTab('console',this)">🖥️ Console (${session.consoleErrors.length})</button>
1293
+ <button class="nav-tab" onclick="showTab('network',this)">📡 Network</button>
1294
+ </nav>
1295
+
1296
+ <div class="container">
1297
+
1298
+ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real Browser Mode</strong> — Screenshots, Web Vitals, DOM tests, Interaction tests captured from live Chromium browser</div>' : ''}
1299
+ <div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live application testing.</div>
1300
+
1301
+ <!-- OVERVIEW -->
1302
+ <div id="tab-overview" class="tab-panel active">
1303
+ ${urlsStr ? `<div class="card"><div class="card-title">Target URLs</div>${urlsStr}</div>` : ''}
1304
+ <div class="metrics">
1305
+ <div class="mc"><div class="ml">Pass Rate</div><div class="mv" style="color:${rateColor}">${summary.passRate}%</div></div>
1306
+ <div class="mc"><div class="ml">Total Tests</div><div class="mv">${summary.total}</div></div>
1307
+ <div class="mc"><div class="ml">Passed</div><div class="mv" style="color:var(--green)">${summary.passed}</div></div>
1308
+ <div class="mc"><div class="ml">Failed</div><div class="mv" style="color:var(--red)">${summary.failed}</div></div>
1309
+ <div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
1310
+ <div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
1311
+ <div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
1312
+ <div class="mc"><div class="ml">Screenshots</div><div class="mv" style="color:#c084fc">${session.screenshots.length}</div></div>
1313
+ <div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
1314
+ <div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
1315
+ <div class="mc"><div class="ml">Console Errors</div><div class="mv" style="color:${session.consoleErrors.length>0?'var(--yellow)':'var(--green)'}">${session.consoleErrors.length}</div></div>
1316
+ <div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
1317
+ </div>
1318
+ <div class="grid2">
1319
+ <div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
1320
+ <div class="card"><div class="card-title">Bug Severity</div><div class="chart-wrap"><canvas id="bugChart"></canvas></div></div>
1321
+ </div>
1322
+ </div>
1323
+
1324
+ <!-- SCREENSHOTS -->
1325
+ <div id="tab-screenshots" class="tab-panel">
1326
+ <div class="card">
1327
+ <div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured</span></div>
1328
+ ${session.playwrightMode ? '' : '<div class="perf-note" style="margin-bottom:1rem">⚠️ Screenshots require Playwright. Install: <code>npm install playwright && npx playwright install chromium</code></div>'}
1329
+ <div class="screenshot-gallery">${screenshotCards}</div>
1330
+ </div>
1331
+ </div>
1332
+
1333
+ <!-- TESTS -->
1334
+ <div id="tab-tests" class="tab-panel">
1335
+ <div class="search-bar">
1336
+ <input type="text" id="testSearch" placeholder="Search tests..." onkeyup="filterTests()">
1337
+ <select id="testStatus" onchange="filterTests()">
1338
+ <option value="">All statuses</option>
1339
+ <option value="FAIL">Failed only</option>
1340
+ <option value="PASS">Passed only</option>
1341
+ </select>
1342
+ <select id="testType" onchange="filterTests()">
1343
+ <option value="">All types</option>
1344
+ ${[...new Set(session.results.map(r=>r.type))].map(t=>`<option value="${esc(t)}">${t}</option>`).join('')}
1345
+ </select>
1346
+ </div>
1347
+ <div class="card">
1348
+ <div class="card-title">All Test Results <span>${summary.total} tests</span></div>
1349
+ <table id="testTable">
1350
+ <thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Severity</th><th>Duration</th><th>Details</th></tr></thead>
1351
+ <tbody>${testRows || '<tr><td colspan="6" class="no-data">No tests run yet</td></tr>'}</tbody>
1352
+ </table>
1353
+ </div>
1354
+ </div>
1355
+
1356
+ <!-- BUGS -->
1357
+ <div id="tab-bugs" class="tab-panel">
1358
+ <div class="search-bar">
1359
+ <input type="text" id="bugSearch" placeholder="Search bugs..." onkeyup="filterBugs()">
1360
+ <select id="bugSev" onchange="filterBugs()">
1361
+ <option value="">All severities</option>
1362
+ <option value="P0">P0 Critical</option><option value="P1">P1 High</option>
1363
+ <option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
1364
+ </select>
1365
+ </div>
1366
+ <div id="bugList">${bugCards}</div>
1367
+ </div>
1368
+
1369
+ <!-- ROUTES -->
1370
+ <div id="tab-routes" class="tab-panel">
1371
+ <div class="card">
1372
+ <div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
1373
+ <table>
1374
+ <thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
1375
+ <tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
1376
+ </table>
1377
+ </div>
1378
+ </div>
1379
+
1380
+ <!-- SECURITY -->
1381
+ <div id="tab-security" class="tab-panel">
1382
+ <div class="card">
1383
+ <div class="card-title">Security Scan Results <span>${session.secFindings.length} checks</span></div>
1384
+ <table>
1385
+ <thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
1386
+ <tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
1387
+ </table>
1388
+ </div>
1389
+ </div>
1390
+
1391
+ <!-- PERFORMANCE -->
1392
+ <div id="tab-performance" class="tab-panel">
1393
+ <div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals + Resource Analysis</div>
1394
+ ${perfSection}
1395
+ </div>
1396
+
1397
+ <!-- ACCESSIBILITY -->
1398
+ <div id="tab-a11y" class="tab-panel">
1399
+ <div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG HTML Analysis</div>
1400
+ ${a11ySection}
1401
+ </div>
1402
+
1403
+ <!-- SEO -->
1404
+ <div id="tab-seo" class="tab-panel">
1405
+ <div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent</div>
1406
+ ${seoSection}
1407
+ </div>
1408
+
1409
+ <!-- CONSOLE -->
1410
+ <div id="tab-console" class="tab-panel">
1411
+ <div class="card">
1412
+ <div class="card-title">Console Errors &amp; Warnings <span>${session.consoleErrors.length} entries</span></div>
1413
+ ${consoleSection}
1414
+ </div>
1415
+ </div>
1416
+
1417
+ <!-- NETWORK -->
1418
+ <div id="tab-network" class="tab-panel">
1419
+ <div class="card">
1420
+ <div class="card-title">Network Failures <span>${session.networkLog.length} failures</span></div>
1421
+ ${networkSection}
1422
+ </div>
1423
+ </div>
1424
+
1425
+ </div>
1426
+
1427
+ <footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${session.screenshots.length} screenshots · ${new Date().toLocaleString()}</footer>
1428
+
1429
+ <script>
1430
+ function showTab(name, el) {
1431
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
1432
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
1433
+ document.getElementById('tab-' + name)?.classList.add('active');
1434
+ el?.classList.add('active');
1435
+ }
1436
+ function filterTests() {
1437
+ const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
1438
+ const st = document.getElementById('testStatus')?.value||'';
1439
+ const ty = document.getElementById('testType')?.value||'';
1440
+ document.querySelectorAll('#testTable tbody .result-row').forEach(row => {
1441
+ row.style.display = (row.textContent.toLowerCase().includes(s) && (!st || row.dataset.status===st) && (!ty || row.dataset.type===ty)) ? '' : 'none';
1442
+ });
1443
+ }
1444
+ function filterBugs() {
1445
+ const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
1446
+ const sv = document.getElementById('bugSev')?.value||'';
1447
+ document.querySelectorAll('#bugList .bug-card').forEach(card => {
1448
+ card.style.display = (card.textContent.toLowerCase().includes(s) && (!sv || card.dataset.severity===sv)) ? '' : 'none';
1449
+ });
1450
+ }
1451
+ const chartCfg = {
1452
+ plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
1453
+ scales:{x:{ticks:{color:'#64748b'},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1},grid:{color:'#1e293b'},beginAtZero:true}}
1454
+ };
1455
+ new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
1456
+ {label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
1457
+ {label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
1458
+ ]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
1459
+ 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}}}}}});
1460
+ </script>
1461
+ </body>
1462
+ </html>`;
1463
+ }
647
1464
 
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
- }
1465
+ // ═══════════════════════════════════════════════════════════════════════════
1466
+ // Main QA Runner v13 with Playwright integration
1467
+ // ═══════════════════════════════════════════════════════════════════════════
1468
+ async function runQAEngine(session) {
1469
+ const dash = new TerminalDashboard(session);
1470
+ dash.start();
1471
+
1472
+ const addResult = (r) => {
1473
+ const result = { id: shortId(), timestamp: timestamp(), duration: 0, ...r };
1474
+ session.addResult(result);
1475
+ dash.addResult(result);
1476
+ return result;
1477
+ };
654
1478
 
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,
1479
+ try {
1480
+ // ── Phase 1: Discovery ───────────────────────────────────────────────
1481
+ dash.setPhase('🔍 Phase 1: Route Discovery & Crawling');
1482
+ for (const [label, url] of Object.entries(session.urls)) {
1483
+ if (!url) continue;
1484
+ dash.log(`Crawling ${label}: ${url}`);
1485
+ const t0 = Date.now();
1486
+ const routes = await crawlSite(url, {
1487
+ maxPages: 50,
1488
+ onRoute: (route) => {
1489
+ session.routeMap.push(route);
1490
+ dash.log(` Found: ${route.url} (${route.type})`);
675
1491
  },
676
- url,
677
- duration: result.duration,
678
1492
  });
1493
+ addResult({ name: `[${label}] Route Discovery`, type: 'discovery', category: 'crawl',
1494
+ status: routes.length > 0 ? 'PASS' : 'FAIL',
1495
+ message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
1496
+ }
679
1497
 
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
- });
1498
+ // ── Phase 2: Playwright Real Browser Tests ───────────────────────────
1499
+ dash.setPhase('🎭 Phase 2: Playwright Real Browser Tests');
1500
+ const chromium = await getPlaywright();
1501
+
1502
+ if (chromium) {
1503
+ session.playwrightMode = true;
1504
+ dash.log(chalk.hex('#BF40FF')(' 🎭 Playwright available! Running real browser tests...'));
1505
+
1506
+ for (const [label, url] of Object.entries(session.urls)) {
1507
+ if (!url) continue;
1508
+ dash.setCurrentTest(`🎭 Browser: ${url}`);
1509
+ dash.log(chalk.cyan(` Launching Chromium for ${label}...`));
1510
+
1511
+ const pwResult = await runPlaywrightScan(url, session, dash);
1512
+
1513
+ if (pwResult && !pwResult.error) {
1514
+ const { results: pw } = pwResult;
1515
+
1516
+ // Store playwright perf data merged with session
1517
+ session.perfMetrics[label] = {
1518
+ ...session.perfMetrics[label],
1519
+ ...pw.vitals,
1520
+ slowResources : pw.networkFails.filter(n => n.duration > 1000),
1521
+ resourceStats : pw.resourceStats,
1522
+ domChecks : pw.domChecks,
1523
+ interactions : pw.interactions,
1524
+ playwrightMode: true,
1525
+ };
1526
+
1527
+ // Add DOM check results
1528
+ for (const check of pw.domChecks || []) {
1529
+ addResult({ name: `DOM: ${check.name}`, type: 'browser-dom', category: 'playwright',
1530
+ status: check.pass ? 'PASS' : 'FAIL', message: check.value, url, label });
1531
+ }
1532
+
1533
+ // Add interaction results
1534
+ for (const interaction of pw.interactions || []) {
1535
+ addResult({ name: `Interaction: ${interaction.name}`, type: 'browser-interaction', category: 'playwright',
1536
+ status: interaction.pass ? 'PASS' : 'FAIL', message: interaction.value, url, label });
1537
+ if (!interaction.pass) {
1538
+ session.addBug({ title: `Interaction Failed: ${interaction.name}`,
1539
+ severity: 'P2', type: 'javascript', url, evidence: { value: interaction.value } });
1540
+ }
1541
+ }
1542
+
1543
+ // Add network failure results
1544
+ for (const fail of pw.networkFails || []) {
1545
+ addResult({ name: `Network Fail: ${fail.url?.split('/').pop()?.slice(0,40)}`, type: 'network', category: 'playwright',
1546
+ status: 'FAIL', message: fail.failure || `HTTP ${fail.status}`, url: fail.url, label });
1547
+ session.addBug({ title: `Network Failure: ${fail.url?.split('/').pop()}`,
1548
+ severity: fail.status >= 500 ? 'P1' : 'P2', type: 'network', url: fail.url,
1549
+ evidence: { status: fail.status, failure: fail.failure } });
1550
+ }
1551
+
1552
+ // Add console error results
1553
+ for (const err of pw.jsErrors || []) {
1554
+ addResult({ name: `JS Error: ${err.message?.slice(0,60)}`, type: 'javascript', category: 'playwright',
1555
+ status: 'FAIL', message: err.message, url, label, severity: 'P2' });
1556
+ session.addBug({ title: `JS Error: ${err.message?.slice(0,80)}`,
1557
+ severity: 'P2', type: 'javascript', url, evidence: { message: err.message, stack: err.stack?.slice(0,200) } });
1558
+ }
1559
+
1560
+ // Web vitals results
1561
+ const { lcp, fcp, cls, tbt, ttfb } = pw.vitals || {};
1562
+ if (ttfb !== undefined && ttfb !== null) {
1563
+ addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
1564
+ status: ttfb <= 800 ? 'PASS' : 'FAIL', message: `TTFB: ${ttfb}ms`, url, label, duration: ttfb });
1565
+ }
1566
+ if (lcp !== undefined && lcp !== null) {
1567
+ addResult({ name: `[${label}] LCP`, type: 'performance', category: 'web-vitals',
1568
+ status: lcp <= 2500 ? 'PASS' : 'FAIL', message: `LCP: ${lcp}ms (≤2500ms)`, url, label });
1569
+ if (lcp > 2500) session.addBug({ title: `Poor LCP: ${lcp}ms`, severity: lcp > 4000 ? 'P1' : 'P2',
1570
+ type: 'performance', url, evidence: { lcp }, recommendation: 'Optimize largest contentful paint' });
1571
+ }
1572
+ if (fcp !== undefined && fcp !== null) {
1573
+ addResult({ name: `[${label}] FCP`, type: 'performance', category: 'web-vitals',
1574
+ status: fcp <= 1800 ? 'PASS' : 'FAIL', message: `FCP: ${fcp}ms (≤1800ms)`, url, label });
1575
+ }
1576
+ if (cls !== undefined && cls !== null) {
1577
+ addResult({ name: `[${label}] CLS`, type: 'performance', category: 'web-vitals',
1578
+ status: cls <= 0.1 ? 'PASS' : 'FAIL', message: `CLS: ${cls} (≤0.1)`, url, label });
1579
+ if (cls > 0.1) session.addBug({ title: `High CLS: ${cls}`, severity: 'P2', type: 'performance',
1580
+ url, evidence: { cls }, recommendation: 'Fix layout shifts — set image dimensions, avoid dynamic content insertion' });
1581
+ }
1582
+ if (tbt !== undefined && tbt !== null) {
1583
+ addResult({ name: `[${label}] TBT`, type: 'performance', category: 'web-vitals',
1584
+ status: tbt <= 200 ? 'PASS' : 'FAIL', message: `TBT: ${tbt}ms (≤200ms)`, url, label });
1585
+ }
1586
+
1587
+ addResult({ name: `[${label}] Playwright Scan`, type: 'browser', category: 'playwright',
1588
+ status: 'PASS', message: `${pw.screenshots?.length || 0} screenshots, ${pw.domChecks?.length || 0} DOM checks`, url, label });
1589
+
1590
+ dash.log(chalk.green(` ✅ Playwright scan complete for ${label}`));
1591
+ } else {
1592
+ dash.log(chalk.yellow(` ⚠ Playwright scan failed: ${pwResult?.error || 'unknown error'}`));
1593
+ addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'FAIL',
1594
+ message: pwResult?.error || 'Playwright scan failed', url, label });
1595
+ }
1596
+ }
1597
+ } else {
1598
+ dash.log(chalk.yellow(' ⚠ Playwright not installed. HTTP-only mode.'));
1599
+ dash.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
1600
+ // Fallback: HTTP TTFB
1601
+ for (const [label, url] of Object.entries(session.urls)) {
1602
+ if (!url) continue;
1603
+ const t0 = Date.now();
1604
+ const r = await httpProbe(url, { timeout: 15000 });
1605
+ const ttfb = Date.now() - t0;
1606
+ session.perfMetrics[label] = { ttfb, bodySize: r.bodySize, statusCode: r.status,
1607
+ slowResources: [], note: 'Install Playwright for real Web Vitals (LCP, FCP, CLS)' };
1608
+ addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
1609
+ status: ttfb <= 800 ? 'PASS' : 'FAIL',
1610
+ message: `TTFB: ${ttfb}ms (threshold: ≤800ms)`, url, label, duration: ttfb });
1611
+ if (ttfb > 800) session.addBug({ title: `Slow TTFB: ${ttfb}ms`, severity: ttfb > 2000 ? 'P1' : 'P2',
1612
+ type: 'performance', url, evidence: { ttfb }, recommendation: 'Optimize server response time' });
689
1613
  }
690
1614
  }
691
- }
692
1615
 
693
- // ── Auth Flow Testing ─────────────────────────────────────────────────
694
- async #testAuthFlow(url, page) {
695
- this.#terminal.setCurrentTest(`Auth flow: ${url}`);
1616
+ // ── Phase 3: API Validation ──────────────────────────────────────────
1617
+ dash.setPhase('📡 Phase 3: API Validation');
1618
+ const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
1619
+ dash.log(`Validating ${apiRoutes.length} API endpoints...`);
1620
+ for (const route of apiRoutes) {
1621
+ dash.setCurrentTest(`API: ${route.url}`);
1622
+ const r = await httpProbe(route.url);
1623
+ session.apiLog.push({ ...r, id: shortId() });
1624
+ addResult({ name: `API: ${route.url}`, type: 'api', category: 'api',
1625
+ status: r.ok ? 'PASS' : 'FAIL',
1626
+ message: `${r.status} ${r.ok ? 'OK' : 'FAIL'} (${r.responseTime}ms)`,
1627
+ url: route.url, duration: r.responseTime });
1628
+ if (!r.ok) session.addBug({ title: `API Failure: ${route.url}`,
1629
+ severity: r.status >= 500 ? 'P0' : 'P1', type: 'api',
1630
+ description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
1631
+ }
1632
+
1633
+ // ── Phase 4: Security ────────────────────────────────────────────────
1634
+ dash.setPhase('🛡️ Phase 4: Security Scan');
1635
+ for (const [label, url] of Object.entries(session.urls)) {
1636
+ if (!url) continue;
1637
+ dash.setCurrentTest(`Security: ${url}`);
1638
+ const findings = await runSecurityScan(url);
1639
+ session.secFindings.push(...findings);
1640
+ for (const f of findings) {
1641
+ addResult({ name: `Security: ${f.check}`, type: 'security', category: f.category,
1642
+ status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
1643
+ if (!f.pass && ['P0','P1'].includes(f.severity)) {
1644
+ session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
1645
+ description: f.detail, url, evidence: f.evidence, recommendation: f.recommendation });
1646
+ }
1647
+ }
1648
+ }
696
1649
 
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
- ],
703
- });
1650
+ // ── Phase 5: Accessibility ───────────────────────────────────────────
1651
+ dash.setPhase('♿ Phase 5: Accessibility Check');
1652
+ const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
1653
+ for (const route of pageRoutes) {
1654
+ dash.setCurrentTest(`A11y: ${route.url}`);
1655
+ const result = await runA11yScan(route.url);
1656
+ session.a11yResults.push({ url: route.url, ...result });
1657
+ for (const v of result.violations) {
1658
+ addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility', category: 'wcag',
1659
+ status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : v.impact === 'serious' ? 'P1' : 'P2',
1660
+ url: route.url });
1661
+ if (['critical','serious'].includes(v.impact)) session.addBug({
1662
+ title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
1663
+ type: 'accessibility', description: v.help, url: route.url, recommendation: v.helpUrl });
1664
+ }
1665
+ for (const pass of result.passes.slice(0, 3)) {
1666
+ addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
1667
+ }
1668
+ }
704
1669
 
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
- });
1670
+ // ── Phase 6: SEO ─────────────────────────────────────────────────────
1671
+ dash.setPhase('🔎 Phase 6: SEO Validation');
1672
+ const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
1673
+ for (const route of seoRoutes) {
1674
+ dash.setCurrentTest(`SEO: ${route.url}`);
1675
+ const result = await runSEOScan(route.url);
1676
+ session.seoResults.push({ url: route.url, ...result });
1677
+ for (const c of result.checks) {
1678
+ addResult({ name: `SEO: ${c.name}`, type: 'seo', category: c.category,
1679
+ status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
1680
+ if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
1681
+ title: `SEO: ${c.name}`, severity: c.severity, type: 'seo',
1682
+ description: c.detail, url: route.url, recommendation: c.recommendation });
1683
+ }
1684
+ }
715
1685
 
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
- });
1686
+ // ── Phase 7: AI Classification ───────────────────────────────────────
1687
+ dash.setPhase('🤖 Phase 7: AI Bug Classification');
1688
+ dash.log(`Classifying ${session.bugs.length} bugs...`);
1689
+ for (const bug of session.bugs) {
1690
+ const cls = classifyBug(bug);
1691
+ bug.aiSeverity = cls.severity;
1692
+ bug.aiCategory = cls.category;
1693
+ bug.aiRecommendation = cls.recommendation;
1694
+ bug.aiConfidence = cls.confidence;
725
1695
  }
726
- }
1696
+ session.bugs.sort((a, b) => {
1697
+ const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
1698
+ return (o[a.aiSeverity||a.severity]||3) - (o[b.aiSeverity||b.severity]||3);
1699
+ });
727
1700
 
728
- #isAuthPage(url) {
729
- return /\/(login|signin|auth|register|signup)/i.test(url);
1701
+ } finally {
1702
+ dash.stop();
730
1703
  }
731
1704
 
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
- }
1705
+ return session;
744
1706
  }
745
1707
 
746
1708
  // ═══════════════════════════════════════════════════════════════════════════
747
- // Public API — exported functions
1709
+ // Report Generation
748
1710
  // ═══════════════════════════════════════════════════════════════════════════
1711
+ async function generateReports(session) {
1712
+ await fs.ensureDir(REPORT_DIR);
1713
+ const base = session.id.toLowerCase();
1714
+ const htmlPath = path.join(REPORT_DIR, `${base}.html`);
1715
+ const jsonPath = path.join(REPORT_DIR, `${base}.json`);
1716
+ const summary = session.getSummary();
1717
+
1718
+ await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
1719
+ await fs.writeJson(jsonPath, {
1720
+ meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
1721
+ dataSource: session.playwrightMode ? 'playwright-real-browser' : 'http-only' },
1722
+ urls: session.urls, summary, results: session.results, bugs: session.bugs,
1723
+ routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
1724
+ perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
1725
+ screenshots: session.screenshots.map(s => ({ ...s, path: undefined })), // strip paths from JSON
1726
+ playwrightMode: session.playwrightMode,
1727
+ ci: {
1728
+ exitCode: summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
1729
+ p0Bugs : session.bugs.filter(b => b.severity === 'P0').length,
1730
+ p1Bugs : session.bugs.filter(b => b.severity === 'P1').length,
1731
+ passRate: summary.passRate,
1732
+ },
1733
+ }, { spaces: 2 });
1734
+
1735
+ return { htmlPath, jsonPath };
1736
+ }
749
1737
 
1738
+ // ═══════════════════════════════════════════════════════════════════════════
1739
+ // History
1740
+ // ═══════════════════════════════════════════════════════════════════════════
750
1741
  export async function initQASystem() {
751
1742
  await fs.ensureDir(QA_DIR);
752
1743
  await fs.ensureDir(REPORT_DIR);
@@ -756,189 +1747,193 @@ export async function initQASystem() {
756
1747
  }
757
1748
  }
758
1749
 
759
- export async function saveSession(session) {
760
- const history = await loadHistory();
1750
+ async function saveToHistory(session, htmlPath, jsonPath) {
1751
+ let history = { runs: [] };
1752
+ try { history = await fs.readJson(HISTORY_FILE); } catch {}
761
1753
  const summary = session.getSummary();
762
1754
  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,
1755
+ id: session.id, startedAt: session.startedAt, urls: session.urls,
1756
+ summary, version: VERSION, bugCount: session.bugs.length,
769
1757
  screenshotCount: session.screenshots.length,
1758
+ playwrightMode: session.playwrightMode,
1759
+ htmlPath, jsonPath,
770
1760
  });
771
1761
  if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
772
1762
  await fs.writeJson(HISTORY_FILE, history, { spaces: 2 });
773
1763
  }
774
1764
 
775
- export async function loadHistory() {
776
- try { return await fs.readJson(HISTORY_FILE); }
777
- catch { return { runs: [], version: VERSION }; }
778
- }
779
-
780
- // ── URL QA entry point ────────────────────────────────────────────────────
781
- export async function runUrlQA({ localUrl, stagingUrl, prodUrl, options = {} } = {}) {
1765
+ // ═══════════════════════════════════════════════════════════════════════════
1766
+ // Public API runUrlQA (main entry point)
1767
+ // ═══════════════════════════════════════════════════════════════════════════
1768
+ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
782
1769
  const urls = {};
783
1770
  if (localUrl) urls.localhost = localUrl;
784
1771
  if (stagingUrl) urls.staging = stagingUrl;
785
1772
  if (prodUrl) urls.production = prodUrl;
786
1773
 
787
- if (Object.keys(urls).length === 0) {
788
- console.log(chalk.red(' No URLs provided.'));
789
- return null;
1774
+ if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
1775
+
1776
+ // Check Playwright availability and warn
1777
+ const chromium = await getPlaywright();
1778
+ if (chromium) {
1779
+ console.log(chalk.hex('#BF40FF')(' 🎭 Playwright detected — Real browser mode ENABLED'));
1780
+ console.log(chalk.gray(' Screenshots, Web Vitals, DOM tests, Interactions will be captured'));
1781
+ } else {
1782
+ console.log(chalk.yellow(' ⚠ Playwright not found — HTTP-only mode'));
1783
+ console.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
1784
+ console.log(chalk.gray(' For real Web Vitals, screenshots, and DOM tests'));
790
1785
  }
1786
+ console.log('');
791
1787
 
792
1788
  const session = new QASession(urls);
793
- const engine = new QAEngine(session, options);
1789
+ await runQAEngine(session);
1790
+ const { htmlPath, jsonPath } = await generateReports(session);
1791
+ await saveToHistory(session, htmlPath, jsonPath);
794
1792
 
795
- await engine.init();
796
- await engine.run();
797
- await saveSession(session);
1793
+ const summary = session.getSummary();
1794
+ console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
1795
+ console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
1796
+ console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
1797
+ if (session.screenshots.length > 0) {
1798
+ console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
1799
+ }
798
1800
 
799
- const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
800
- const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
1801
+ try {
1802
+ const { exec } = await import('node:child_process');
1803
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1804
+ exec(`${cmd} "${htmlPath}"`);
1805
+ console.log(chalk.green(' 🌐 Report opened in browser!'));
1806
+ } catch {}
801
1807
 
802
1808
  return { session, htmlPath, jsonPath };
803
1809
  }
804
1810
 
805
- // ── Automated QA entry point ──────────────────────────────────────────────
806
- export async function runAutomatedQA({ continuous = false, localUrl, prodUrl, stagingUrl } = {}) {
807
- const runOnce = async () => {
1811
+ export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
1812
+ const run = async () => {
808
1813
  const urls = {};
809
1814
  if (localUrl) urls.localhost = localUrl;
810
1815
  if (stagingUrl) urls.staging = stagingUrl;
811
1816
  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
1817
  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}`));
1818
+ await runQAEngine(session);
1819
+ const { htmlPath, jsonPath } = await generateReports(session);
1820
+ await saveToHistory(session, htmlPath, jsonPath);
1821
+ console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
833
1822
  return session;
834
1823
  };
835
1824
 
836
- if (!continuous) return runOnce();
837
-
838
- console.log(chalk.cyan(' ⚡ Continuous mode — re-runs every 60s. Ctrl+C to stop.\n'));
1825
+ if (!continuous) return run();
1826
+ console.log(chalk.cyan(' ⚡ Continuous mode — every 60s. Ctrl+C to stop.\n'));
839
1827
  let i = 0;
840
1828
  while (true) {
841
- console.log(chalk.gray(`\n ── Run #${++i} ── ${new Date().toLocaleTimeString()}`));
842
- await runOnce();
1829
+ console.log(chalk.gray(`\n ── Run #${++i} @ ${new Date().toLocaleTimeString()} ──`));
1830
+ await run();
843
1831
  await sleep(60_000);
844
1832
  }
845
1833
  }
846
1834
 
847
- // ── Manual QA ─────────────────────────────────────────────────────────────
848
1835
  export async function runManualQA() {
849
- console.log('');
850
-
851
1836
  const action = await p.select({
852
- message: 'Manual QA — what to run?',
1837
+ message: 'Manual QA mode:',
853
1838
  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' },
1839
+ { value: 'full', label: '🌐 Full Scan (All phases + Playwright)' },
1840
+ { value: 'browser', label: '🎭 Browser-only (Playwright: screenshots + vitals)' },
1841
+ { value: 'security', label: '🛡️ Security only' },
1842
+ { value: 'seo', label: '🔎 SEO only' },
1843
+ { value: 'a11y', label: ' Accessibility only' },
1844
+ { value: 'perf', label: ' Performance only' },
860
1845
  ],
861
1846
  });
862
1847
  if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
863
1848
 
864
- const localUrl = await p.text({
865
- message : 'Localhost URL:',
866
- placeholder: 'http://localhost:3000',
867
- });
1849
+ const localUrl = await p.text({ message: 'URL to test:', placeholder: 'http://localhost:3000' });
868
1850
  if (p.isCancel(localUrl)) { p.cancel('Cancelled.'); return; }
869
1851
 
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
- };
1852
+ const url = String(localUrl).trim();
1853
+ const sess = new QASession({ localhost: url });
879
1854
 
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}`));
1855
+ if (action === 'full') {
1856
+ await runQAEngine(sess);
1857
+ } else {
1858
+ const dash = new TerminalDashboard(sess);
1859
+ dash.start();
1860
+ try {
1861
+ if (action === 'browser') {
1862
+ const chromium = await getPlaywright();
1863
+ if (!chromium) { dash.log(chalk.red('Playwright not installed! Run: npm install playwright && npx playwright install chromium')); }
1864
+ else {
1865
+ sess.playwrightMode = true;
1866
+ await runPlaywrightScan(url, sess, dash);
1867
+ sess.perfMetrics.localhost = { ...sess.perfMetrics.localhost, playwrightMode: true };
1868
+ }
1869
+ } else if (action === 'security') {
1870
+ const f = await runSecurityScan(url);
1871
+ sess.secFindings.push(...f);
1872
+ f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
1873
+ status: finding.pass ? 'PASS' : 'FAIL', message: finding.detail, timestamp: timestamp() }));
1874
+ } else if (action === 'seo') {
1875
+ const r = await runSEOScan(url);
1876
+ sess.seoResults.push({ url, ...r });
1877
+ r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
1878
+ status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
1879
+ } else if (action === 'a11y') {
1880
+ const r = await runA11yScan(url);
1881
+ sess.a11yResults.push({ url, ...r });
1882
+ r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
1883
+ status: 'FAIL', message: v.help, timestamp: timestamp() }));
1884
+ } else if (action === 'perf') {
1885
+ const chromium2 = await getPlaywright();
1886
+ if (chromium2) {
1887
+ sess.playwrightMode = true;
1888
+ await runPlaywrightScan(url, sess, dash);
1889
+ } else {
1890
+ const m = await (async () => {
1891
+ const t0 = Date.now(); const r = await httpProbe(url, { timeout: 15000 });
1892
+ return { ttfb: Date.now()-t0, bodySize: r.bodySize, statusCode: r.status, slowResources: [],
1893
+ note: 'Install Playwright for real LCP/FCP/CLS metrics' };
1894
+ })();
1895
+ sess.perfMetrics.localhost = m;
1896
+ sess.addResult({ id: shortId(), name: `TTFB: ${m.ttfb}ms`, type: 'performance',
1897
+ status: m.ttfb <= 800 ? 'PASS' : 'FAIL', message: `${m.ttfb}ms`, timestamp: timestamp() });
1898
+ }
1899
+ }
1900
+ } finally { dash.stop(); }
890
1901
  }
1902
+
1903
+ const { htmlPath } = await generateReports(sess);
1904
+ await saveToHistory(sess, htmlPath, '');
1905
+ p.outro(chalk.hex('#00F5FF').bold('✓ QA complete'));
1906
+ console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
1907
+ try {
1908
+ const { exec } = await import('node:child_process');
1909
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1910
+ exec(`${cmd} "${htmlPath}"`);
1911
+ } catch {}
891
1912
  }
892
1913
 
893
- // ── Post-generation validation ────────────────────────────────────────────
894
- export async function autoRunPostGeneration(options = {}) {
1914
+ export async function autoRunPostGeneration() {
895
1915
  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'));
898
- 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
- });
1916
+ console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation QA v${VERSION} ──`));
1917
+ const url = await p.text({ message: 'Server URL:', placeholder: 'http://localhost:3000', defaultValue: 'http://localhost:3000' });
905
1918
  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
- }
1919
+ await runUrlQA({ localUrl: String(url).trim() });
911
1920
  }
912
1921
 
913
- // ── View History ──────────────────────────────────────────────────────────
914
1922
  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
- }
1923
+ let history = { runs: [] };
1924
+ try { history = await fs.readJson(HISTORY_FILE); } catch {}
920
1925
 
921
- console.log('');
922
- console.log(chalk.hex('#00F5FF').bold(' QA History (real runs only)'));
923
- console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
1926
+ if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
924
1927
 
1928
+ console.log('');
1929
+ console.log(chalk.hex('#00F5FF').bold(' QA History'));
1930
+ console.log(chalk.gray(' ──────────────────────────────────────────────────'));
925
1931
  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
- );
1932
+ const rate = run.summary?.passRate ?? '–';
1933
+ const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
1934
+ const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
1935
+ const pwIcon = run.playwrightMode ? chalk.hex('#BF40FF')('🎭') : chalk.gray('⚡');
1936
+ 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')} ${pwIcon} ${chalk.dim(urls.slice(0,40))}`);
942
1937
  }
943
1938
  console.log('');
944
1939
 
@@ -946,25 +1941,23 @@ export async function viewQAHistory() {
946
1941
  message: 'Open a report?',
947
1942
  options: [
948
1943
  ...history.runs.slice(0, 8).map(r => ({
949
- value: r.id,
950
- label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
1944
+ value: r.htmlPath || r.id,
1945
+ label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs${r.playwrightMode ? ' 🎭' : ''}`,
951
1946
  })),
952
1947
  { value: '__back', label: '↩ Back' },
953
1948
  ],
954
1949
  });
955
1950
  if (p.isCancel(chosen) || chosen === '__back') return;
956
1951
 
957
- const reportPath = path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
1952
+ const reportPath = chosen.endsWith('.html') ? chosen : path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
958
1953
  if (await fs.pathExists(reportPath)) {
959
1954
  console.log(chalk.green(` 📄 Report: ${reportPath}`));
960
1955
  try {
961
1956
  const { exec } = await import('node:child_process');
962
- const cmd = process.platform === 'darwin' ? 'open'
963
- : process.platform === 'win32' ? 'start'
964
- : 'xdg-open';
1957
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
965
1958
  exec(`${cmd} "${reportPath}"`);
966
1959
  } catch {}
967
1960
  } else {
968
- console.log(chalk.yellow(' Report file not found — may have been deleted.'));
1961
+ console.log(chalk.yellow(' Report file not found.'));
969
1962
  }
970
- }
1963
+ }