create-backlist 10.0.9 → 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,6 +1,6 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
- // Backlist Enterprise QA Engine v12.0 — FIXED COMPLETE EDITION
3
- // 100% Real Runtime Testing · No Fake Data · Live Demo Support
2
+ // Backlist Enterprise QA Engine v13.0 — PLAYWRIGHT REAL BROWSER EDITION
3
+ // 100% Real Runtime Testing · Live Playwright Tests · Rich HTML Reports
4
4
  // ═══════════════════════════════════════════════════════════════════════════
5
5
 
6
6
  import * as p from '@clack/prompts';
@@ -13,32 +13,32 @@ import { performance } from 'node:perf_hooks';
13
13
  import { EventEmitter } from 'node:events';
14
14
 
15
15
  // ── Constants ─────────────────────────────────────────────────────────────
16
- export const VERSION = '12.0.0';
16
+ export const VERSION = '13.0.0';
17
17
  export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
18
18
  export const REPORT_DIR = path.join(QA_DIR, 'reports');
19
19
  export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
20
20
  export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
21
21
 
22
22
  // ── Utilities ─────────────────────────────────────────────────────────────
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));
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
26
  export const formatDuration = (ms) => {
27
27
  if (!ms || ms < 0) return '0ms';
28
28
  if (ms < 1000) return `${Math.round(ms)}ms`;
29
29
  return `${(ms / 1000).toFixed(2)}s`;
30
30
  };
31
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`;
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`;
35
35
  return `${(b / 1024 / 1024).toFixed(1)}MB`;
36
36
  };
37
37
 
38
- // ── Safe readline prompt (fixes: await inside non-async Promise) ──────────
38
+ // ── readline helper ───────────────────────────────────────────────────────
39
39
  function askYesNo(question) {
40
40
  return new Promise((resolve) => {
41
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
41
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
42
42
  const timer = setTimeout(() => { rl.close(); resolve(false); }, 10_000);
43
43
  rl.question(question, (ans) => {
44
44
  clearTimeout(timer);
@@ -48,8 +48,18 @@ function askYesNo(question) {
48
48
  });
49
49
  }
50
50
 
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
+
51
61
  // ═══════════════════════════════════════════════════════════════════════════
52
- // QA Session — stores ALL real runtime data
62
+ // QA Session
53
63
  // ═══════════════════════════════════════════════════════════════════════════
54
64
  export class QASession {
55
65
  id;
@@ -66,6 +76,7 @@ export class QASession {
66
76
  secFindings = [];
67
77
  a11yResults = [];
68
78
  seoResults = [];
79
+ playwrightMode = false;
69
80
 
70
81
  constructor(urls = {}) {
71
82
  this.id = `QA-${shortId().toUpperCase()}`;
@@ -74,7 +85,13 @@ export class QASession {
74
85
  }
75
86
 
76
87
  addResult(r) { this.results.push(r); }
77
- addBug(bug) { this.bugs.push({ ...bug, id: `BUG-${shortId().toUpperCase()}`, createdAt: timestamp() }); }
88
+ addBug(bug) {
89
+ this.bugs.push({
90
+ ...bug,
91
+ id: `BUG-${shortId().toUpperCase()}`,
92
+ createdAt: timestamp(),
93
+ });
94
+ }
78
95
 
79
96
  getSummary() {
80
97
  const passed = this.results.filter(r => r.status === 'PASS').length;
@@ -91,7 +108,7 @@ export class QASession {
91
108
  }
92
109
 
93
110
  // ═══════════════════════════════════════════════════════════════════════════
94
- // HTTP Probe — real HTTP requests, no mocking
111
+ // HTTP Probe — real HTTP requests
95
112
  // ═══════════════════════════════════════════════════════════════════════════
96
113
  async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
97
114
  const t0 = Date.now();
@@ -101,7 +118,7 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
101
118
  const res = await fetch(url, {
102
119
  method,
103
120
  signal : ctrl.signal,
104
- headers : { 'User-Agent': 'Backlist-QA/12.0', Accept: '*/*', ...headers },
121
+ headers : { 'User-Agent': 'Backlist-QA/13.0', Accept: '*/*', ...headers },
105
122
  redirect: 'follow',
106
123
  });
107
124
  clearTimeout(timer);
@@ -121,8 +138,7 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
121
138
  ok: res.status >= 200 && res.status < 400,
122
139
  status: res.status, contentType, headers: hdrs,
123
140
  body: body.slice(0, 3000), parsed, bodySize,
124
- responseTime: rt, url, method,
125
- error: null,
141
+ responseTime: rt, url, method, error: null,
126
142
  };
127
143
  } catch (err) {
128
144
  return {
@@ -135,14 +151,351 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
135
151
  }
136
152
 
137
153
  // ═══════════════════════════════════════════════════════════════════════════
138
- // Route Crawler real HTTP crawl, discovers all pages & APIs
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
+ }
168
+
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
+ });
188
+
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
+ });
195
+
196
+ page = await context.newPage();
197
+
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
+ });
208
+
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
+ });
215
+
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
+ });
246
+
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
+ }
259
+
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
+ });
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' });
426
+ } catch (err) {
427
+ interactions.push({ name: 'Page scroll', pass: false, value: err.message });
428
+ }
429
+
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
+ }
439
+
440
+ // Hover test on first link
441
+ try {
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' });
446
+ }
447
+ } catch {
448
+ interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' });
449
+ }
450
+
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(() => ({}));
475
+
476
+ results.resourceStats = resourceStats;
477
+
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 {}
487
+ }
488
+ }
489
+
490
+ // ═══════════════════════════════════════════════════════════════════════════
491
+ // Route Crawler — real HTTP crawl
139
492
  // ═══════════════════════════════════════════════════════════════════════════
140
493
  async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
141
494
  const visited = new Set();
142
495
  const queue = [{ url: baseUrl, depth: 0 }];
143
496
  const routes = [];
144
497
 
145
- const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
498
+ const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
146
499
  const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
147
500
 
148
501
  while (queue.length > 0 && routes.length < maxPages) {
@@ -151,17 +504,16 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
151
504
  if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
152
505
  visited.add(n);
153
506
 
154
- const r = await httpProbe(n, { timeout: 10000 });
507
+ const r = await httpProbe(n, { timeout: 10000 });
155
508
  const type = (() => {
156
- if (r.status >= 400) return 'error-page';
157
- if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
158
- if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
159
- if (/\/(login|signin|auth)/i.test(n)) return 'auth';
160
- if (/\/(admin)/i.test(n)) return 'admin';
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';
161
514
  return 'page';
162
515
  })();
163
516
 
164
- // Extract links from HTML
165
517
  const links = [];
166
518
  if (r.contentType.includes('text/html')) {
167
519
  const re = /href=["']([^"'#?][^"']*?)["']/gi;
@@ -171,17 +523,16 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
171
523
  }
172
524
  }
173
525
 
174
- // Extract forms
175
- const forms = [];
526
+ const forms = [];
176
527
  const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
177
528
  let fm;
178
529
  while ((fm = formRe.exec(r.body)) !== null) {
179
530
  const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
180
531
  const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
181
532
  const fields = [];
182
- const ir = /<input([^>]*)>/gi; let inp;
533
+ const ir = /<input([^>]*)>/gi; let inp;
183
534
  while ((inp = ir.exec(fm[2])) !== null) {
184
- const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
535
+ const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
185
536
  const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
186
537
  if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
187
538
  }
@@ -198,7 +549,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
198
549
  }
199
550
  }
200
551
 
201
- // Probe common API endpoints
552
+ // Common paths probe
202
553
  const commonPaths = ['/api/health','/health','/api/status','/api/v1/health','/api/docs','/robots.txt','/sitemap.xml'];
203
554
  for (const p2 of commonPaths) {
204
555
  try {
@@ -219,62 +570,67 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
219
570
  }
220
571
 
221
572
  // ═══════════════════════════════════════════════════════════════════════════
222
- // Security Scanner — real HTTP header analysis
573
+ // Security Scanner
223
574
  // ═══════════════════════════════════════════════════════════════════════════
224
575
  async function runSecurityScan(url) {
225
576
  const findings = [];
226
- const r = await httpProbe(url);
577
+ const r = await httpProbe(url);
227
578
 
228
579
  if (!r.ok && r.status === 0) {
229
- return [{ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
230
- detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running' }];
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
+ }];
231
584
  }
232
585
 
233
586
  const h = r.headers;
234
587
 
235
588
  const headerChecks = [
236
- { id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
589
+ { id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
237
590
  validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
238
- { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
591
+ { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
239
592
  validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
240
- { id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
593
+ { id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
241
594
  validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
242
- { id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
595
+ { id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
243
596
  validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
244
- { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
597
+ { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
245
598
  validate: v => !!v, rec: 'Add Referrer-Policy header' },
246
- { id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
599
+ { id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
247
600
  validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
248
- { id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
249
- validate: v => !v, rec: 'Remove X-Powered-By (app.disable("x-powered-by"))' },
601
+ { id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
602
+ validate: v => !v, rec: 'Remove X-Powered-By header' },
250
603
  ];
251
604
 
252
605
  for (const c of headerChecks) {
253
606
  const val = h[c.header] || '';
254
607
  const pass = c.validate(val);
255
- findings.push({ check: c.name, pass, severity: pass ? 'INFO' : c.sev,
608
+ findings.push({
609
+ check: c.name, pass, severity: pass ? 'INFO' : c.sev,
256
610
  category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
257
- recommendation: c.rec, evidence: { header: c.header, value: val || null } });
611
+ recommendation: c.rec, evidence: { header: c.header, value: val || null },
612
+ });
258
613
  }
259
614
 
260
- // HTTPS check
261
615
  const isHTTPS = url.startsWith('https://');
262
- findings.push({ check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
263
- category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted traffic',
264
- recommendation: 'Use HTTPS with valid SSL', evidence: { protocol: new URL(url).protocol } });
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
+ });
265
621
 
266
- // CORS wildcard check
267
622
  const corsOrigin = h['access-control-allow-origin'];
268
623
  const corsCreds = h['access-control-allow-credentials'];
269
624
  const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
270
- findings.push({ check: 'CORS wildcard + credentials', pass: corsPass,
625
+ findings.push({
626
+ check: 'CORS wildcard + credentials', pass: corsPass,
271
627
  severity: corsPass ? 'INFO' : 'P0', category: 'cors',
272
628
  detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
273
629
  recommendation: 'Never combine CORS * with allow-credentials',
274
- evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds } });
630
+ evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds },
631
+ });
275
632
 
276
- // Probe sensitive paths
277
- const base = new URL(url).origin;
633
+ const base = new URL(url).origin;
278
634
  const sensitives = [
279
635
  { path: '/.env', name: '.env exposed' },
280
636
  { path: '/.git/config', name: 'Git config exposed' },
@@ -290,11 +646,13 @@ async function runSecurityScan(url) {
290
646
  const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
291
647
  clearTimeout(timer);
292
648
  const exposed = res.status === 200;
293
- findings.push({ check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
649
+ findings.push({
650
+ check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
294
651
  category: 'information-disclosure',
295
652
  detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
296
653
  recommendation: exposed ? `Block access to ${s.path} immediately` : null,
297
- evidence: { url: `${base}${s.path}`, status: res.status } });
654
+ evidence: { url: `${base}${s.path}`, status: res.status },
655
+ });
298
656
  } catch {}
299
657
  }
300
658
 
@@ -302,13 +660,13 @@ async function runSecurityScan(url) {
302
660
  }
303
661
 
304
662
  // ═══════════════════════════════════════════════════════════════════════════
305
- // SEO Scanner — real HTML parsing with Googlebot UA
663
+ // SEO Scanner
306
664
  // ═══════════════════════════════════════════════════════════════════════════
307
665
  async function runSEOScan(url) {
308
- const t0 = Date.now();
309
- const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
666
+ const t0 = Date.now();
667
+ const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
310
668
  const html = r.body || '';
311
- const rt = Date.now() - t0;
669
+ const rt = Date.now() - t0;
312
670
  const checks = [];
313
671
 
314
672
  const has = (p) => p.test(html);
@@ -337,7 +695,7 @@ async function runSEOScan(url) {
337
695
  const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
338
696
  checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
339
697
  detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
340
- recommendation: 'Add <meta name="viewport" content="width=device-width,initial-scale=1">' });
698
+ recommendation: 'Add viewport meta tag' });
341
699
 
342
700
  const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
343
701
  checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
@@ -353,8 +711,8 @@ async function runSEOScan(url) {
353
711
  detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
354
712
  recommendation: 'Add og:title, og:description, og:image' });
355
713
 
356
- const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
357
- const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
714
+ const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
715
+ const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
358
716
  checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
359
717
  detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
360
718
  recommendation: 'Add alt text to all images' });
@@ -363,7 +721,6 @@ async function runSEOScan(url) {
363
721
  category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
364
722
  recommendation: 'Optimize TTFB with CDN and caching' });
365
723
 
366
- // robots.txt & sitemap
367
724
  const base = new URL(url).origin;
368
725
  for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
369
726
  try {
@@ -376,48 +733,11 @@ async function runSEOScan(url) {
376
733
  }
377
734
  }
378
735
 
379
- return { pass: checks.filter(c=>!c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
380
- }
381
-
382
- // ═══════════════════════════════════════════════════════════════════════════
383
- // Performance Profiler — real HTTP TTFB + resource timing
384
- // ═══════════════════════════════════════════════════════════════════════════
385
- async function runPerfProfile(url) {
386
- const t0 = Date.now();
387
- const r = await httpProbe(url, { timeout: 15000 });
388
- const ttfb = Date.now() - t0;
389
-
390
- const slowResources = [];
391
- if (ttfb > 3000) slowResources.push({ url, duration: ttfb, size: r.bodySize, type: 'document' });
392
-
393
- // Parse resource hints from HTML
394
- const resourceUrls = [];
395
- if (r.contentType.includes('text/html')) {
396
- const scriptRe = /src=["']([^"']+\.(?:js|css))["']/gi;
397
- let m;
398
- while ((m = scriptRe.exec(r.body)) !== null) {
399
- try { resourceUrls.push(new URL(m[1], url).toString()); } catch {}
400
- }
401
- for (const ru of resourceUrls.slice(0, 5)) {
402
- const t1 = Date.now();
403
- const rr = await httpProbe(ru, { timeout: 8000 });
404
- const dur = Date.now() - t1;
405
- if (dur > 1000) slowResources.push({ url: ru, duration: dur, size: rr.bodySize, type: ru.endsWith('.css') ? 'stylesheet' : 'script' });
406
- }
407
- }
408
-
409
- return {
410
- ttfb, totalTime: ttfb, bodySize: r.bodySize,
411
- statusCode: r.status, slowResources,
412
- lcp: null, fcp: null, cls: null, fid: null, tbt: null,
413
- resourceTimings: [],
414
- url, mode: 'http',
415
- note: 'LCP/FCP/CLS require Playwright — run: npx playwright install chromium',
416
- };
736
+ return { pass: checks.filter(c => !c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
417
737
  }
418
738
 
419
739
  // ═══════════════════════════════════════════════════════════════════════════
420
- // Accessibility Scanner — real HTML analysis + axe-core hint
740
+ // Accessibility Scanner — HTML analysis
421
741
  // ═══════════════════════════════════════════════════════════════════════════
422
742
  async function runA11yScan(url) {
423
743
  const r = await httpProbe(url, { timeout: 12000 });
@@ -425,13 +745,13 @@ async function runA11yScan(url) {
425
745
  const violations = [], passes = [];
426
746
 
427
747
  const checks = [
428
- { id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
429
- { id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
430
- { id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
431
- { id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
432
- { id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
433
- { id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
434
- { id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
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' },
435
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' },
436
756
  ];
437
757
 
@@ -450,7 +770,7 @@ async function runA11yScan(url) {
450
770
  }
451
771
 
452
772
  // ═══════════════════════════════════════════════════════════════════════════
453
- // AI Bug Classifier — local pattern matching (no external API needed)
773
+ // AI Bug Classifier
454
774
  // ═══════════════════════════════════════════════════════════════════════════
455
775
  const SEV_PATTERNS = {
456
776
  P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
@@ -459,13 +779,13 @@ const SEV_PATTERNS = {
459
779
  P3: [/warning|minor|style|typo|cosmetic/i],
460
780
  };
461
781
  const CAT_PATTERNS = {
462
- security: /security|csp|hsts|cors|xss|injection|auth|token/i,
463
- performance: /lcp|fcp|cls|ttfb|slow|timeout|render/i,
782
+ security : /security|csp|hsts|cors|xss|injection|auth|token/i,
783
+ performance : /lcp|fcp|cls|ttfb|slow|timeout|render/i,
464
784
  accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
465
- seo: /title|meta|description|canonical|sitemap|robots/i,
466
- api: /api|endpoint|status.*code|response|rest/i,
467
- javascript: /js.*error|console.*error|uncaught|undefined|null/i,
468
- network: /network|fetch|connection|request.*fail/i,
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,
469
789
  };
470
790
  function classifyBug(bug) {
471
791
  const text = `${bug.title} ${bug.description || ''}`;
@@ -478,25 +798,26 @@ function classifyBug(bug) {
478
798
  if (pat.test(text)) { category = cat; break; }
479
799
  }
480
800
  const recs = {
481
- security: 'Review security config and run penetration test',
482
- performance: 'Run Lighthouse and optimize assets/server',
801
+ security : 'Review security config and run penetration test',
802
+ performance : 'Run Lighthouse and optimize assets/server',
483
803
  accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
484
- seo: 'Fix meta tags and submit sitemap to Search Console',
485
- api: 'Check API contract and add proper error handling',
486
- javascript: 'Debug in browser DevTools, add error boundaries',
487
- network: 'Check CDN, server logs, network config',
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',
488
808
  };
489
809
  return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
490
810
  }
491
811
 
492
812
  // ═══════════════════════════════════════════════════════════════════════════
493
- // Terminal Dashboard — live real-time display
813
+ // Terminal Dashboard
494
814
  // ═══════════════════════════════════════════════════════════════════════════
495
815
  class TerminalDashboard {
496
816
  #session; #lines = 0; #active = false; #timer = null;
497
817
  #phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
818
+ #pwMode = false;
498
819
 
499
- constructor(s) { this.#session = s; }
820
+ constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
500
821
 
501
822
  start() {
502
823
  this.#active = true; this.#startTime = Date.now();
@@ -513,9 +834,9 @@ class TerminalDashboard {
513
834
  this.#printFinal();
514
835
  }
515
836
 
516
- setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
837
+ setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
517
838
  setCurrentTest(t) { this.#currentTest = t; }
518
- addResult() { this.#currentTest = ''; }
839
+ addResult() { this.#currentTest = ''; }
519
840
  log(msg) {
520
841
  this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
521
842
  if (this.#log.length > 8) this.#log.shift();
@@ -525,7 +846,7 @@ class TerminalDashboard {
525
846
  if (!this.#active) return;
526
847
  this.#clear();
527
848
  const lines = this.#build();
528
- this.#lines = lines.length;
849
+ this.#lines = lines.length;
529
850
  process.stdout.write(lines.join('\n') + '\n');
530
851
  }
531
852
 
@@ -551,16 +872,17 @@ class TerminalDashboard {
551
872
  const c1 = chalk.hex('#00F5FF');
552
873
  const c2 = chalk.hex('#BF40FF');
553
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');
554
876
 
555
877
  const pBar = (() => {
556
- const f = Math.min(Math.round(rate / 100 * 26), 26);
878
+ const f = Math.min(Math.round(rate / 100 * 26), 26);
557
879
  const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
558
880
  return col('█'.repeat(f)) + chalk.gray('░'.repeat(26 - f));
559
881
  })();
560
882
 
561
883
  const out = [
562
884
  c1(`┌${bar}┐`),
563
- c1('│') + c2.bold(pad(` ⚡ BACKLIST ENTERPRISE QA v${VERSION} — REAL RUNTIME TESTING`)) + c1('│'),
885
+ c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — REAL BROWSER TESTING${pwTag}`)) + c1('│'),
564
886
  c1(`├${bar}┤`),
565
887
  c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
566
888
  c1(`├${bar}┤`),
@@ -569,7 +891,7 @@ class TerminalDashboard {
569
891
  c1(`├${bar}┤`),
570
892
  c1('│') + pad(this.#currentTest ? ` ${chalk.yellow('⟳')} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Running...')}`) + c1('│'),
571
893
  c1(`├${bar}┤`),
572
- c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('APIs:')} ${chalk.white(s.apiLog.length)} ${chalk.cyan('Bugs:')} ${chalk.white(s.bugs.length)} ${chalk.cyan('Screenshots:')} ${chalk.white(s.screenshots.length)}`) + c1('│'),
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('│'),
573
895
  c1(`├${bar}┤`),
574
896
  ];
575
897
 
@@ -586,37 +908,41 @@ class TerminalDashboard {
586
908
  }
587
909
  for (let i = this.#log.length; i < 4; i++) out.push(c1('│') + pad('') + c1('│'));
588
910
  out.push(c1(`└${bar}┘`));
589
- out.push(chalk.dim(` Real runtime data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
911
+ out.push(chalk.dim(` Real browser data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
590
912
 
591
913
  return out;
592
914
  }
593
915
 
594
916
  #printFinal() {
595
- const s = this.#session.getSummary();
917
+ const s = this.#session.getSummary();
596
918
  const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
597
919
  console.log('');
598
920
  console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
599
- console.log(` Tests: ${chalk.white.bold(s.total)}`);
600
- console.log(` Passed: ${chalk.green.bold(s.passed)}`);
601
- console.log(` Failed: ${chalk.red.bold(s.failed)}`);
602
- console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
603
- console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
604
- console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
605
- console.log(` Routes: ${chalk.white(this.#session.routeMap.length)} discovered`);
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')}`);
606
929
  console.log('');
607
930
  }
608
931
  }
609
932
 
610
933
  // ═══════════════════════════════════════════════════════════════════════════
611
- // HTML Report Builder — stunning dark theme, 100% real data
934
+ // HTML Report Builder — v13, Dark Theme, Screenshot Gallery + Vitals
612
935
  // ═══════════════════════════════════════════════════════════════════════════
613
936
  function buildHTMLReport(session) {
614
- const summary = session.getSummary();
615
- const passRate = Number(summary.passRate);
616
- const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
937
+ const summary = session.getSummary();
938
+ const passRate = Number(summary.passRate);
939
+ const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
617
940
 
618
- const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
619
- session.bugs.forEach(b => { if (sevCounts[(b.aiSeverity||b.severity)] !== undefined) sevCounts[b.aiSeverity||b.severity]++; });
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
+ });
620
946
 
621
947
  const coverage = {};
622
948
  for (const r of session.results) {
@@ -627,6 +953,31 @@ function buildHTMLReport(session) {
627
953
 
628
954
  const esc = (s) => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
629
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 ─────────────────────────────────────────────────────────────
630
981
  const testRows = session.results.map(r => `
631
982
  <tr class="result-row" data-type="${r.type}" data-status="${r.status}">
632
983
  <td>${esc(r.name)}</td>
@@ -637,8 +988,10 @@ function buildHTMLReport(session) {
637
988
  <td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
638
989
  </tr>`).join('');
639
990
 
640
- const bugCards = session.bugs.length ? session.bugs.map(b => `
641
- <div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">
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}">
642
995
  <div class="bug-header">
643
996
  <span class="bug-id">${esc(b.id)}</span>
644
997
  <span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
@@ -649,8 +1002,10 @@ function buildHTMLReport(session) {
649
1002
  ${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
650
1003
  ${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
651
1004
  ${b.evidence ? `<details><summary>Evidence</summary><pre>${esc(JSON.stringify(b.evidence,null,2).slice(0,800))}</pre></details>` : ''}
652
- </div>`).join('') : '<p class="no-data">No bugs detected 🎉</p>';
1005
+ </div>`).join('')
1006
+ : '<p class="no-data">No bugs detected 🎉</p>';
653
1007
 
1008
+ // ── Route rows ────────────────────────────────────────────────────────────
654
1009
  const routeRows = session.routeMap.map(r => `
655
1010
  <tr>
656
1011
  <td><code class="url">${esc(r.url)}</code></td>
@@ -660,6 +1015,7 @@ function buildHTMLReport(session) {
660
1015
  <td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
661
1016
  </tr>`).join('');
662
1017
 
1018
+ // ── Security rows ─────────────────────────────────────────────────────────
663
1019
  const secRows = session.secFindings.map(f => `
664
1020
  <tr class="${f.pass ? '' : 'fail-row'}">
665
1021
  <td>${esc(f.check)}</td>
@@ -670,52 +1026,43 @@ function buildHTMLReport(session) {
670
1026
  <td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
671
1027
  </tr>`).join('');
672
1028
 
1029
+ // ── SEO section ───────────────────────────────────────────────────────────
673
1030
  const seoSection = session.seoResults.map(r => `
674
1031
  <div class="seo-page">
675
- <div class="seo-header"><a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
676
- <span>${r.checks.filter(c=>c.pass).length}/${r.checks.length} passed</span></div>
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>
677
1036
  <table>
678
1037
  <thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr></thead>
679
- <tbody>${(r.checks||[]).map(c => `<tr><td>${esc(c.name)}</td><td>${c.category||'–'}</td>
1038
+ <tbody>${(r.checks||[]).map(c => `<tr>
1039
+ <td>${esc(c.name)}</td><td>${c.category||'–'}</td>
680
1040
  <td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
681
- <td>${esc((c.detail||'').slice(0,100))}</td></tr>`).join('')}</tbody>
1041
+ <td>${esc((c.detail||'').slice(0,100))}</td>
1042
+ </tr>`).join('')}</tbody>
682
1043
  </table>
683
1044
  </div>`).join('') || '<p class="no-data">No SEO scans</p>';
684
1045
 
1046
+ // ── A11y section ──────────────────────────────────────────────────────────
685
1047
  const a11ySection = session.a11yResults.map(r => `
686
1048
  <div class="a11y-page">
687
- <div class="a11y-header"><a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
1049
+ <div class="a11y-header">
1050
+ <a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
688
1051
  <span>Score: <strong>${r.score??'–'}%</strong></span>
689
- <span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span></div>
1052
+ <span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
1053
+ </div>
690
1054
  ${(r.violations||[]).map(v => `
691
1055
  <div class="violation impact-${v.impact}">
692
- <div class="violation-header"><span class="impact-badge">${v.impact}</span>
693
- <strong>${esc(v.description)}</strong></div>
1056
+ <div class="violation-header">
1057
+ <span class="impact-badge">${v.impact}</span>
1058
+ <strong>${esc(v.description)}</strong>
1059
+ </div>
694
1060
  <p>${esc(v.help)}</p>
695
1061
  </div>`).join('') || '<p class="no-data">No violations ✓</p>'}
696
1062
  </div>`).join('') || '<p class="no-data">No accessibility scans</p>';
697
1063
 
698
- const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => `
699
- <div class="perf-card">
700
- <h3>${esc(label)}</h3>
701
- <div class="vitals-grid">
702
- ${vitalCard('TTFB', m.ttfb, 800, 'ms')}
703
- ${vitalCard('LCP', m.lcp, 2500, 'ms')}
704
- ${vitalCard('FCP', m.fcp, 1800, 'ms')}
705
- ${vitalCard('CLS', m.cls, 0.1, '')}
706
- ${vitalCard('TBT', m.tbt, 200, 'ms')}
707
- </div>
708
- ${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
709
- ${(m.slowResources||[]).length ? `<h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
710
- <table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
711
- <tbody>${m.slowResources.map(r => `<tr>
712
- <td class="url">${esc((r.url||'').split('/').pop())}</td>
713
- <td class="fail">${r.duration}ms</td>
714
- <td>${formatBytes(r.size)}</td>
715
- </tr>`).join('')}</tbody></table>` : ''}
716
- </div>`).join('') || '<p class="no-data">No performance data</p>';
717
-
718
- function vitalCard(name, value, threshold, unit) {
1064
+ // ── Performance section ───────────────────────────────────────────────────
1065
+ const vitalCard = (name, value, threshold, unit) => {
719
1066
  const na = value === null || value === undefined;
720
1067
  const pass2 = !na && value <= threshold;
721
1068
  const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
@@ -726,15 +1073,100 @@ function buildHTMLReport(session) {
726
1073
  <div class="vital-value" style="color:${color}">${disp}</div>
727
1074
  <div class="vital-threshold">≤${threshold}${unit}</div>
728
1075
  </div>`;
729
- }
1076
+ };
730
1077
 
731
- const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
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)
732
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('');
733
1162
 
734
- const chartTypes = JSON.stringify(Object.keys(coverage));
735
- const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
736
- const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
737
- const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
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>';
738
1170
 
739
1171
  return `<!DOCTYPE html>
740
1172
  <html lang="en">
@@ -752,13 +1184,13 @@ a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
752
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)}
753
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}
754
1186
  .header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
755
- .version-badge{font-size:.7rem;padding:3px 10px;border-radius:20px;border:1px solid var(--purple);color:var(--purple)}
756
1187
  nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
757
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}
758
1189
  .nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
759
1190
  .container{max-width:1400px;margin:0 auto;padding:2rem}
760
1191
  .tab-panel{display:none}.tab-panel.active{display:block}
761
- .real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
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}
762
1194
  .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
763
1195
  .mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
764
1196
  .mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
@@ -780,6 +1212,7 @@ tr.fail-row td{background:rgba(239,68,68,.04)}
780
1212
  .sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
781
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}
782
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}
783
1216
  .url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
784
1217
  code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
785
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}
@@ -799,6 +1232,18 @@ details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:no
799
1232
  .no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
800
1233
  .url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
801
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 */
802
1247
  .vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
803
1248
  .vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
804
1249
  .vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
@@ -820,7 +1265,7 @@ details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:no
820
1265
  .impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
821
1266
  .err-cell details{font-size:.78rem}
822
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}
823
- @media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
1268
+ @media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
824
1269
  </style>
825
1270
  </head>
826
1271
  <body>
@@ -831,11 +1276,12 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
831
1276
  Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
832
1277
  </div>
833
1278
  </div>
834
- <span class="version-badge">v${VERSION}</span>
1279
+ ${pwBadge}
835
1280
  </header>
836
1281
 
837
1282
  <nav>
838
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>
839
1285
  <button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
840
1286
  <button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
841
1287
  <button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
@@ -843,10 +1289,14 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
843
1289
  <button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
844
1290
  <button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
845
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>
846
1294
  </nav>
847
1295
 
848
1296
  <div class="container">
849
- <div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live application testing. No mocked or simulated values.</div>
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>
850
1300
 
851
1301
  <!-- OVERVIEW -->
852
1302
  <div id="tab-overview" class="tab-panel active">
@@ -859,10 +1309,10 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
859
1309
  <div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
860
1310
  <div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
861
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>
862
1313
  <div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
863
- <div class="mc"><div class="ml">APIs Tested</div><div class="mv">${session.apiLog.length}</div></div>
864
1314
  <div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
865
- <div class="mc"><div class="ml">SEO Checks</div><div class="mv">${session.seoResults.reduce((a,r)=>a+(r.checks?.length||0),0)}</div></div>
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>
866
1316
  <div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
867
1317
  </div>
868
1318
  <div class="grid2">
@@ -871,6 +1321,15 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
871
1321
  </div>
872
1322
  </div>
873
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
+
874
1333
  <!-- TESTS -->
875
1334
  <div id="tab-tests" class="tab-panel">
876
1335
  <div class="search-bar">
@@ -910,7 +1369,7 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
910
1369
  <!-- ROUTES -->
911
1370
  <div id="tab-routes" class="tab-panel">
912
1371
  <div class="card">
913
- <div class="card-title">Discovered Routes <span>${session.routeMap.length} real pages/APIs</span></div>
1372
+ <div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
914
1373
  <table>
915
1374
  <thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
916
1375
  <tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
@@ -931,25 +1390,41 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
931
1390
 
932
1391
  <!-- PERFORMANCE -->
933
1392
  <div id="tab-performance" class="tab-panel">
934
- <div class="card-title" style="padding:.5rem 0 1rem">Real Performance Metrics HTTP TTFB + Resource Analysis</div>
1393
+ <div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals + Resource Analysis</div>
935
1394
  ${perfSection}
936
1395
  </div>
937
1396
 
938
1397
  <!-- ACCESSIBILITY -->
939
1398
  <div id="tab-a11y" class="tab-panel">
940
- <div class="card-title" style="padding:.5rem 0 1rem">Accessibility Analysis Real HTML WCAG Checks</div>
1399
+ <div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG HTML Analysis</div>
941
1400
  ${a11ySection}
942
1401
  </div>
943
1402
 
944
1403
  <!-- SEO -->
945
1404
  <div id="tab-seo" class="tab-panel">
946
- <div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Fetched with Googlebot User-Agent</div>
1405
+ <div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent</div>
947
1406
  ${seoSection}
948
1407
  </div>
949
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
+
950
1425
  </div>
951
1426
 
952
- <footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${new Date().toLocaleString()}</footer>
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>
953
1428
 
954
1429
  <script>
955
1430
  function showTab(name, el) {
@@ -959,23 +1434,18 @@ function showTab(name, el) {
959
1434
  el?.classList.add('active');
960
1435
  }
961
1436
  function filterTests() {
962
- const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
1437
+ const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
963
1438
  const st = document.getElementById('testStatus')?.value||'';
964
1439
  const ty = document.getElementById('testType')?.value||'';
965
1440
  document.querySelectorAll('#testTable tbody .result-row').forEach(row => {
966
- const show = row.textContent.toLowerCase().includes(s)
967
- && (!st || row.dataset.status === st)
968
- && (!ty || row.dataset.type === ty);
969
- row.style.display = show ? '' : 'none';
1441
+ row.style.display = (row.textContent.toLowerCase().includes(s) && (!st || row.dataset.status===st) && (!ty || row.dataset.type===ty)) ? '' : 'none';
970
1442
  });
971
1443
  }
972
1444
  function filterBugs() {
973
1445
  const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
974
1446
  const sv = document.getElementById('bugSev')?.value||'';
975
1447
  document.querySelectorAll('#bugList .bug-card').forEach(card => {
976
- const show = card.textContent.toLowerCase().includes(s)
977
- && (!sv || card.dataset.severity === sv);
978
- card.style.display = show ? '' : 'none';
1448
+ card.style.display = (card.textContent.toLowerCase().includes(s) && (!sv || card.dataset.severity===sv)) ? '' : 'none';
979
1449
  });
980
1450
  }
981
1451
  const chartCfg = {
@@ -993,7 +1463,7 @@ new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0
993
1463
  }
994
1464
 
995
1465
  // ═══════════════════════════════════════════════════════════════════════════
996
- // Main QA Runner
1466
+ // Main QA Runner — v13 with Playwright integration
997
1467
  // ═══════════════════════════════════════════════════════════════════════════
998
1468
  async function runQAEngine(session) {
999
1469
  const dash = new TerminalDashboard(session);
@@ -1012,7 +1482,7 @@ async function runQAEngine(session) {
1012
1482
  for (const [label, url] of Object.entries(session.urls)) {
1013
1483
  if (!url) continue;
1014
1484
  dash.log(`Crawling ${label}: ${url}`);
1015
- const t0 = Date.now();
1485
+ const t0 = Date.now();
1016
1486
  const routes = await crawlSite(url, {
1017
1487
  maxPages: 50,
1018
1488
  onRoute: (route) => {
@@ -1025,8 +1495,126 @@ async function runQAEngine(session) {
1025
1495
  message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
1026
1496
  }
1027
1497
 
1028
- // ── Phase 2: API Validation ──────────────────────────────────────────
1029
- dash.setPhase('📡 Phase 2: API Validation');
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' });
1613
+ }
1614
+ }
1615
+
1616
+ // ── Phase 3: API Validation ──────────────────────────────────────────
1617
+ dash.setPhase('📡 Phase 3: API Validation');
1030
1618
  const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
1031
1619
  dash.log(`Validating ${apiRoutes.length} API endpoints...`);
1032
1620
  for (const route of apiRoutes) {
@@ -1042,8 +1630,8 @@ async function runQAEngine(session) {
1042
1630
  description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
1043
1631
  }
1044
1632
 
1045
- // ── Phase 3: Security ────────────────────────────────────────────────
1046
- dash.setPhase('🛡️ Phase 3: Security Scan');
1633
+ // ── Phase 4: Security ────────────────────────────────────────────────
1634
+ dash.setPhase('🛡️ Phase 4: Security Scan');
1047
1635
  for (const [label, url] of Object.entries(session.urls)) {
1048
1636
  if (!url) continue;
1049
1637
  dash.setCurrentTest(`Security: ${url}`);
@@ -1059,24 +1647,6 @@ async function runQAEngine(session) {
1059
1647
  }
1060
1648
  }
1061
1649
 
1062
- // ── Phase 4: Performance ─────────────────────────────────────────────
1063
- dash.setPhase('⚡ Phase 4: Performance Profiling');
1064
- for (const [label, url] of Object.entries(session.urls)) {
1065
- if (!url) continue;
1066
- dash.setCurrentTest(`Perf: ${url}`);
1067
- const m = await runPerfProfile(url);
1068
- session.perfMetrics[label] = m;
1069
- addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
1070
- status: m.ttfb <= 800 ? 'PASS' : 'FAIL',
1071
- message: `TTFB: ${m.ttfb}ms (threshold: ≤800ms)`, url, label, duration: m.ttfb });
1072
- if (m.ttfb > 800) session.addBug({ title: `Slow TTFB: ${m.ttfb}ms`, severity: m.ttfb > 2000 ? 'P1' : 'P2',
1073
- type: 'performance', url, evidence: { ttfb: m.ttfb }, recommendation: 'Optimize server response time' });
1074
- for (const res of (m.slowResources || [])) {
1075
- addResult({ name: `Slow resource: ${res.url?.split('/').pop()}`, type: 'performance',
1076
- category: 'resource', status: 'FAIL', message: `${res.duration}ms (${formatBytes(res.size)})`, url, label });
1077
- }
1078
- }
1079
-
1080
1650
  // ── Phase 5: Accessibility ───────────────────────────────────────────
1081
1651
  dash.setPhase('♿ Phase 5: Accessibility Check');
1082
1652
  const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
@@ -1090,8 +1660,7 @@ async function runQAEngine(session) {
1090
1660
  url: route.url });
1091
1661
  if (['critical','serious'].includes(v.impact)) session.addBug({
1092
1662
  title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
1093
- type: 'accessibility', description: v.help, url: route.url,
1094
- recommendation: v.helpUrl });
1663
+ type: 'accessibility', description: v.help, url: route.url, recommendation: v.helpUrl });
1095
1664
  }
1096
1665
  for (const pass of result.passes.slice(0, 3)) {
1097
1666
  addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
@@ -1118,11 +1687,11 @@ async function runQAEngine(session) {
1118
1687
  dash.setPhase('🤖 Phase 7: AI Bug Classification');
1119
1688
  dash.log(`Classifying ${session.bugs.length} bugs...`);
1120
1689
  for (const bug of session.bugs) {
1121
- const cls = classifyBug(bug);
1122
- bug.aiSeverity = cls.severity;
1123
- bug.aiCategory = cls.category;
1690
+ const cls = classifyBug(bug);
1691
+ bug.aiSeverity = cls.severity;
1692
+ bug.aiCategory = cls.category;
1124
1693
  bug.aiRecommendation = cls.recommendation;
1125
- bug.aiConfidence = cls.confidence;
1694
+ bug.aiConfidence = cls.confidence;
1126
1695
  }
1127
1696
  session.bugs.sort((a, b) => {
1128
1697
  const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -1141,22 +1710,25 @@ async function runQAEngine(session) {
1141
1710
  // ═══════════════════════════════════════════════════════════════════════════
1142
1711
  async function generateReports(session) {
1143
1712
  await fs.ensureDir(REPORT_DIR);
1144
- const base = session.id.toLowerCase();
1145
- const htmlPath = path.join(REPORT_DIR, `${base}.html`);
1146
- const jsonPath = path.join(REPORT_DIR, `${base}.json`);
1147
- const summary = session.getSummary();
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();
1148
1717
 
1149
1718
  await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
1150
1719
  await fs.writeJson(jsonPath, {
1151
- meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(), dataSource: 'real-runtime' },
1720
+ meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
1721
+ dataSource: session.playwrightMode ? 'playwright-real-browser' : 'http-only' },
1152
1722
  urls: session.urls, summary, results: session.results, bugs: session.bugs,
1153
1723
  routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
1154
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,
1155
1727
  ci: {
1156
- exitCode : summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
1157
- p0Bugs : session.bugs.filter(b => b.severity === 'P0').length,
1158
- p1Bugs : session.bugs.filter(b => b.severity === 'P1').length,
1159
- passRate : summary.passRate,
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,
1160
1732
  },
1161
1733
  }, { spaces: 2 });
1162
1734
 
@@ -1182,6 +1754,8 @@ async function saveToHistory(session, htmlPath, jsonPath) {
1182
1754
  history.runs.unshift({
1183
1755
  id: session.id, startedAt: session.startedAt, urls: session.urls,
1184
1756
  summary, version: VERSION, bugCount: session.bugs.length,
1757
+ screenshotCount: session.screenshots.length,
1758
+ playwrightMode: session.playwrightMode,
1185
1759
  htmlPath, jsonPath,
1186
1760
  });
1187
1761
  if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
@@ -1189,9 +1763,8 @@ async function saveToHistory(session, htmlPath, jsonPath) {
1189
1763
  }
1190
1764
 
1191
1765
  // ═══════════════════════════════════════════════════════════════════════════
1192
- // Public API
1766
+ // Public API — runUrlQA (main entry point)
1193
1767
  // ═══════════════════════════════════════════════════════════════════════════
1194
-
1195
1768
  export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1196
1769
  const urls = {};
1197
1770
  if (localUrl) urls.localhost = localUrl;
@@ -1200,17 +1773,31 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1200
1773
 
1201
1774
  if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
1202
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'));
1785
+ }
1786
+ console.log('');
1787
+
1203
1788
  const session = new QASession(urls);
1204
1789
  await runQAEngine(session);
1205
1790
  const { htmlPath, jsonPath } = await generateReports(session);
1206
1791
  await saveToHistory(session, htmlPath, jsonPath);
1207
1792
 
1208
1793
  const summary = session.getSummary();
1209
- console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs`));
1794
+ console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
1210
1795
  console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
1211
1796
  console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
1797
+ if (session.screenshots.length > 0) {
1798
+ console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
1799
+ }
1212
1800
 
1213
- // Auto-open report
1214
1801
  try {
1215
1802
  const { exec } = await import('node:child_process');
1216
1803
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -1249,7 +1836,8 @@ export async function runManualQA() {
1249
1836
  const action = await p.select({
1250
1837
  message: 'Manual QA mode:',
1251
1838
  options: [
1252
- { value: 'full', label: '🌐 Full Scan (All phases)' },
1839
+ { value: 'full', label: '🌐 Full Scan (All phases + Playwright)' },
1840
+ { value: 'browser', label: '🎭 Browser-only (Playwright: screenshots + vitals)' },
1253
1841
  { value: 'security', label: '🛡️ Security only' },
1254
1842
  { value: 'seo', label: '🔎 SEO only' },
1255
1843
  { value: 'a11y', label: '♿ Accessibility only' },
@@ -1270,7 +1858,15 @@ export async function runManualQA() {
1270
1858
  const dash = new TerminalDashboard(sess);
1271
1859
  dash.start();
1272
1860
  try {
1273
- if (action === 'security') {
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') {
1274
1870
  const f = await runSecurityScan(url);
1275
1871
  sess.secFindings.push(...f);
1276
1872
  f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
@@ -1286,10 +1882,20 @@ export async function runManualQA() {
1286
1882
  r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
1287
1883
  status: 'FAIL', message: v.help, timestamp: timestamp() }));
1288
1884
  } else if (action === 'perf') {
1289
- const m = await runPerfProfile(url);
1290
- sess.perfMetrics.localhost = m;
1291
- sess.addResult({ id: shortId(), name: `TTFB: ${m.ttfb}ms`, type: 'performance',
1292
- status: m.ttfb <= 800 ? 'PASS' : 'FAIL', message: `${m.ttfb}ms`, timestamp: timestamp() });
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
+ }
1293
1899
  }
1294
1900
  } finally { dash.stop(); }
1295
1901
  }
@@ -1326,7 +1932,8 @@ export async function viewQAHistory() {
1326
1932
  const rate = run.summary?.passRate ?? '–';
1327
1933
  const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
1328
1934
  const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
1329
- console.log(` ${chalk.gray(run.id.padEnd(16))} ${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(22))} ${col((rate+'%').padStart(7))} ${chalk.cyan((run.bugCount||0)+' bugs')} ${chalk.dim(urls.slice(0,40))}`);
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))}`);
1330
1937
  }
1331
1938
  console.log('');
1332
1939
 
@@ -1335,7 +1942,7 @@ export async function viewQAHistory() {
1335
1942
  options: [
1336
1943
  ...history.runs.slice(0, 8).map(r => ({
1337
1944
  value: r.htmlPath || r.id,
1338
- label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
1945
+ label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs${r.playwrightMode ? ' 🎭' : ''}`,
1339
1946
  })),
1340
1947
  { value: '__back', label: '↩ Back' },
1341
1948
  ],
@@ -1353,4 +1960,4 @@ export async function viewQAHistory() {
1353
1960
  } else {
1354
1961
  console.log(chalk.yellow(' Report file not found.'));
1355
1962
  }
1356
- }
1963
+ }