create-backlist 10.0.9 → 10.1.1

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,10 @@
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 v15.0 — ULTRA LIVE TESTING EDITION
3
+ // Real Playwright Browser · AI Bug Classifier · Live WebSocket Monitor
4
+ // ✅ Visual Regression · ✅ API Contract Testing · ✅ Real User Simulation
5
+ // ✅ Cookie/Auth Testing · ✅ Dark Mode Testing · ✅ Multi-viewport Testing
6
+ // ✅ Memory Leak Detection · ✅ Load Testing · ✅ WebSocket Testing
7
+ // ✅ Broken Link Scanner · ✅ Font/Asset Audit · ✅ Rich HTML Reports v15
4
8
  // ═══════════════════════════════════════════════════════════════════════════
5
9
 
6
10
  import * as p from '@clack/prompts';
@@ -9,36 +13,49 @@ import fs from 'fs-extra';
9
13
  import path from 'node:path';
10
14
  import os from 'node:os';
11
15
  import readline from 'node:readline';
16
+ import crypto from 'node:crypto';
12
17
  import { performance } from 'node:perf_hooks';
13
18
  import { EventEmitter } from 'node:events';
14
19
 
15
20
  // ── Constants ─────────────────────────────────────────────────────────────
16
- export const VERSION = '12.0.0';
17
- export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
21
+ export const VERSION = '15.0.0';
22
+ export const QA_DIR = path.join(process.cwd(), '.BACKLIST', 'qa');
18
23
  export const REPORT_DIR = path.join(QA_DIR, 'reports');
19
24
  export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
20
25
  export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
26
+ export const BASELINE_DIR = path.join(QA_DIR, 'baselines');
27
+
28
+ // ── Viewports for multi-device testing ───────────────────────────────────
29
+ export const VIEWPORTS = {
30
+ desktop_xl : { width: 1920, height: 1080, label: 'Desktop XL' },
31
+ desktop : { width: 1280, height: 900, label: 'Desktop' },
32
+ tablet_lg : { width: 1024, height: 768, label: 'Tablet (lg)' },
33
+ tablet : { width: 768, height: 1024, label: 'Tablet' },
34
+ mobile_lg : { width: 414, height: 896, label: 'Mobile (large)' },
35
+ mobile : { width: 390, height: 844, label: 'Mobile (iPhone)'},
36
+ mobile_sm : { width: 320, height: 568, label: 'Mobile (small)' },
37
+ };
21
38
 
22
39
  // ── 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));
40
+ export const timestamp = () => new Date().toISOString();
41
+ export const shortId = () => Math.random().toString(36).slice(2, 9);
42
+ export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
26
43
  export const formatDuration = (ms) => {
27
44
  if (!ms || ms < 0) return '0ms';
28
45
  if (ms < 1000) return `${Math.round(ms)}ms`;
29
46
  return `${(ms / 1000).toFixed(2)}s`;
30
47
  };
31
48
  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`;
49
+ if (!b || b < 0) return '0B';
50
+ if (b < 1024) return `${b}B`;
51
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
35
52
  return `${(b / 1024 / 1024).toFixed(1)}MB`;
36
53
  };
37
54
 
38
- // ── Safe readline prompt (fixes: await inside non-async Promise) ──────────
55
+ // ── readline helper ───────────────────────────────────────────────────────
39
56
  function askYesNo(question) {
40
57
  return new Promise((resolve) => {
41
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
58
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
42
59
  const timer = setTimeout(() => { rl.close(); resolve(false); }, 10_000);
43
60
  rl.question(question, (ans) => {
44
61
  clearTimeout(timer);
@@ -48,8 +65,25 @@ function askYesNo(question) {
48
65
  });
49
66
  }
50
67
 
68
+ // ── Playwright availability check ────────────────────────────────────────
69
+ async function getPlaywright() {
70
+ try {
71
+ const pw = await import('playwright');
72
+ return pw.chromium || pw.default?.chromium || null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────
79
+ // NEW v15: Image hash for visual regression
80
+ // ─────────────────────────────────────────────────────────────────────────
81
+ function hashBuffer(buf) {
82
+ return crypto.createHash('md5').update(buf).digest('hex');
83
+ }
84
+
51
85
  // ═══════════════════════════════════════════════════════════════════════════
52
- // QA Session — stores ALL real runtime data
86
+ // QA Session v15 Extended with new trackers
53
87
  // ═══════════════════════════════════════════════════════════════════════════
54
88
  export class QASession {
55
89
  id;
@@ -66,6 +100,31 @@ export class QASession {
66
100
  secFindings = [];
67
101
  a11yResults = [];
68
102
  seoResults = [];
103
+ playwrightMode = false;
104
+ // NEW v15 fields
105
+ visualRegressions = [];
106
+ cookieAudit = [];
107
+ loadTestResults = [];
108
+ brokenLinks = [];
109
+ fontAudit = [];
110
+ assetAudit = [];
111
+ memorySnapshots = [];
112
+ wsTests = [];
113
+ darkModeResults = [];
114
+ viewportResults = {};
115
+ apiContracts = [];
116
+ userFlowResults = [];
117
+ redirectChains = [];
118
+ mixedContentIssues = [];
119
+ cspViolations = [];
120
+ thirdPartyScripts = [];
121
+ errorPageTests = [];
122
+ formTests = [];
123
+ authTests = [];
124
+ cacheHeaders = [];
125
+ httpVersions = {};
126
+ tlsInfo = {};
127
+ dnsInfo = {};
69
128
 
70
129
  constructor(urls = {}) {
71
130
  this.id = `QA-${shortId().toUpperCase()}`;
@@ -74,7 +133,13 @@ export class QASession {
74
133
  }
75
134
 
76
135
  addResult(r) { this.results.push(r); }
77
- addBug(bug) { this.bugs.push({ ...bug, id: `BUG-${shortId().toUpperCase()}`, createdAt: timestamp() }); }
136
+ addBug(bug) {
137
+ this.bugs.push({
138
+ ...bug,
139
+ id: `BUG-${shortId().toUpperCase()}`,
140
+ createdAt: timestamp(),
141
+ });
142
+ }
78
143
 
79
144
  getSummary() {
80
145
  const passed = this.results.filter(r => r.status === 'PASS').length;
@@ -91,19 +156,21 @@ export class QASession {
91
156
  }
92
157
 
93
158
  // ═══════════════════════════════════════════════════════════════════════════
94
- // HTTP Probe — real HTTP requests, no mocking
159
+ // HTTP Probe — real HTTP requests with v15 extras
95
160
  // ═══════════════════════════════════════════════════════════════════════════
96
- async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
161
+ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {}, body: reqBody = null, followRedirects = true } = {}) {
97
162
  const t0 = Date.now();
98
163
  try {
99
164
  const ctrl = new AbortController();
100
165
  const timer = setTimeout(() => ctrl.abort(), timeout);
101
- const res = await fetch(url, {
166
+ const fetchOpts = {
102
167
  method,
103
168
  signal : ctrl.signal,
104
- headers : { 'User-Agent': 'Backlist-QA/12.0', Accept: '*/*', ...headers },
105
- redirect: 'follow',
106
- });
169
+ headers : { 'User-Agent': 'Backlist-QA/15.0', Accept: '*/*', ...headers },
170
+ redirect: followRedirects ? 'follow' : 'manual',
171
+ };
172
+ if (reqBody) fetchOpts.body = reqBody;
173
+ const res = await fetch(url, fetchOpts);
107
174
  clearTimeout(timer);
108
175
 
109
176
  const rt = Date.now() - t0;
@@ -120,9 +187,8 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
120
187
  return {
121
188
  ok: res.status >= 200 && res.status < 400,
122
189
  status: res.status, contentType, headers: hdrs,
123
- body: body.slice(0, 3000), parsed, bodySize,
124
- responseTime: rt, url, method,
125
- error: null,
190
+ body: body.slice(0, 5000), parsed, bodySize,
191
+ responseTime: rt, url, method, error: null,
126
192
  };
127
193
  } catch (err) {
128
194
  return {
@@ -135,33 +201,1100 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
135
201
  }
136
202
 
137
203
  // ═══════════════════════════════════════════════════════════════════════════
138
- // Route Crawler real HTTP crawl, discovers all pages & APIs
204
+ // NEW v15: Redirect Chain Analyzer
205
+ // ═══════════════════════════════════════════════════════════════════════════
206
+ async function analyzeRedirectChain(url) {
207
+ const chain = [];
208
+ let current = url;
209
+ let hops = 0;
210
+ const maxHops = 10;
211
+
212
+ while (hops < maxHops) {
213
+ try {
214
+ const ctrl = new AbortController();
215
+ const timer = setTimeout(() => ctrl.abort(), 5000);
216
+ const res = await fetch(current, {
217
+ method: 'HEAD',
218
+ signal: ctrl.signal,
219
+ redirect: 'manual',
220
+ headers: { 'User-Agent': 'Backlist-QA/15.0' },
221
+ });
222
+ clearTimeout(timer);
223
+ chain.push({ url: current, status: res.status, location: res.headers.get('location') });
224
+ if (res.status < 300 || res.status >= 400) break;
225
+ const location = res.headers.get('location');
226
+ if (!location) break;
227
+ current = new URL(location, current).toString();
228
+ hops++;
229
+ } catch (err) {
230
+ chain.push({ url: current, status: 0, error: err.message });
231
+ break;
232
+ }
233
+ }
234
+
235
+ return {
236
+ url,
237
+ hops: chain.length - 1,
238
+ chain,
239
+ hasRedirectLoop: hops >= maxHops,
240
+ finalUrl: chain[chain.length - 1]?.url,
241
+ isHTTPtoHTTPS: chain.length > 1 && chain[0].url.startsWith('http://') && chain[chain.length - 1]?.url?.startsWith('https://'),
242
+ };
243
+ }
244
+
245
+ // ═══════════════════════════════════════════════════════════════════════════
246
+ // NEW v15: Load Test — concurrent requests
247
+ // ═══════════════════════════════════════════════════════════════════════════
248
+ async function runLoadTest(url, { concurrency = 10, duration = 10000, rampUp = 2000 } = {}) {
249
+ const results = { requests: 0, errors: 0, timeouts: 0, responses: {} };
250
+ const latencies = [];
251
+ const startTime = Date.now();
252
+ let running = true;
253
+
254
+ setTimeout(() => { running = false; }, duration);
255
+
256
+ const worker = async (delay = 0) => {
257
+ await sleep(delay);
258
+ while (running) {
259
+ const t0 = Date.now();
260
+ try {
261
+ const ctrl = new AbortController();
262
+ const timer = setTimeout(() => { ctrl.abort(); results.timeouts++; }, 5000);
263
+ const res = await fetch(url, {
264
+ signal: ctrl.signal,
265
+ headers: { 'User-Agent': 'Backlist-QA/15.0-LoadTest' },
266
+ });
267
+ clearTimeout(timer);
268
+ const lat = Date.now() - t0;
269
+ latencies.push(lat);
270
+ results.requests++;
271
+ results.responses[res.status] = (results.responses[res.status] || 0) + 1;
272
+ } catch {
273
+ results.errors++;
274
+ }
275
+ await sleep(50); // small breathing room
276
+ }
277
+ };
278
+
279
+ const workers = Array.from({ length: concurrency }, (_, i) =>
280
+ worker(Math.floor((rampUp / concurrency) * i))
281
+ );
282
+ await Promise.all(workers);
283
+
284
+ const sorted = [...latencies].sort((a, b) => a - b);
285
+ const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0;
286
+ const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;
287
+ const p99 = sorted[Math.floor(sorted.length * 0.99)] || 0;
288
+ const avgLat = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
289
+ const totalTime = Date.now() - startTime;
290
+ const rps = results.requests / (totalTime / 1000);
291
+ const errorRate = results.requests > 0 ? (results.errors / results.requests * 100) : 0;
292
+
293
+ return {
294
+ url, concurrency, duration: totalTime,
295
+ requests: results.requests,
296
+ errors: results.errors,
297
+ timeouts: results.timeouts,
298
+ errorRate: parseFloat(errorRate.toFixed(2)),
299
+ rps: parseFloat(rps.toFixed(2)),
300
+ latency: { avg: Math.round(avgLat), p50, p95, p99, min: sorted[0] || 0, max: sorted[sorted.length - 1] || 0 },
301
+ responses: results.responses,
302
+ passed: errorRate < 5 && p95 < 2000,
303
+ };
304
+ }
305
+
306
+ // ═══════════════════════════════════════════════════════════════════════════
307
+ // NEW v15: Cookie Audit
308
+ // ═══════════════════════════════════════════════════════════════════════════
309
+ async function runCookieAudit(url) {
310
+ const r = await httpProbe(url);
311
+ const setCookie = r.headers['set-cookie'] || '';
312
+ const cookies = (Array.isArray(setCookie) ? setCookie : [setCookie]).filter(Boolean);
313
+ const audit = [];
314
+
315
+ for (const cookie of cookies) {
316
+ const name = cookie.split('=')[0]?.trim();
317
+ const parts = cookie.toLowerCase();
318
+ const isSecure = parts.includes('secure');
319
+ const isHttpOnly = parts.includes('httponly');
320
+ const hasSameSite = parts.includes('samesite');
321
+ const sameSiteVal = (parts.match(/samesite=(\w+)/) || [])[1] || null;
322
+ const hasExpiry = parts.includes('expires=') || parts.includes('max-age=');
323
+
324
+ audit.push({
325
+ name,
326
+ raw: cookie.slice(0, 200),
327
+ secure: isSecure,
328
+ httpOnly: isHttpOnly,
329
+ sameSite: sameSiteVal,
330
+ hasSameSite,
331
+ hasExpiry,
332
+ issues: [
333
+ !isSecure && 'Missing Secure flag',
334
+ !isHttpOnly && 'Missing HttpOnly flag',
335
+ !hasSameSite && 'Missing SameSite attribute',
336
+ sameSiteVal === 'none' && !isSecure && 'SameSite=None without Secure',
337
+ ].filter(Boolean),
338
+ severity: (!isSecure || !isHttpOnly) ? 'P1' : !hasSameSite ? 'P2' : 'INFO',
339
+ });
340
+ }
341
+
342
+ return { url, cookies: audit, total: audit.length, issues: audit.filter(c => c.issues.length > 0).length };
343
+ }
344
+
345
+ // ═══════════════════════════════════════════════════════════════════════════
346
+ // NEW v15: Broken Link Scanner (deep)
347
+ // ═══════════════════════════════════════════════════════════════════════════
348
+ async function scanBrokenLinks(url, { maxLinks = 100 } = {}) {
349
+ const r = await httpProbe(url);
350
+ const html = r.body || '';
351
+ const links = [];
352
+ const re = /href=["']([^"']+)["']/gi;
353
+ let m;
354
+ while ((m = re.exec(html)) !== null) {
355
+ try {
356
+ const resolved = new URL(m[1], url).toString();
357
+ if (!links.includes(resolved)) links.push(resolved);
358
+ } catch {}
359
+ }
360
+
361
+ const results = [];
362
+ const toCheck = links.slice(0, maxLinks);
363
+ const BATCH = 15;
364
+
365
+ for (let i = 0; i < toCheck.length; i += BATCH) {
366
+ const batch = toCheck.slice(i, i + BATCH);
367
+ const checks = batch.map(async (link) => {
368
+ try {
369
+ const ctrl = new AbortController();
370
+ const timer = setTimeout(() => ctrl.abort(), 6000);
371
+ const res = await fetch(link, {
372
+ method: 'HEAD',
373
+ signal: ctrl.signal,
374
+ headers: { 'User-Agent': 'Backlist-QA/15.0' },
375
+ redirect: 'follow',
376
+ });
377
+ clearTimeout(timer);
378
+ return { url: link, status: res.status, ok: res.status < 400 };
379
+ } catch (err) {
380
+ return { url: link, status: 0, ok: false, error: err.message };
381
+ }
382
+ });
383
+ const batchResults = await Promise.all(checks);
384
+ results.push(...batchResults);
385
+ }
386
+
387
+ const broken = results.filter(r => !r.ok);
388
+ return { sourceUrl: url, total: results.length, broken: broken.length, links: results };
389
+ }
390
+
391
+ // ═══════════════════════════════════════════════════════════════════════════
392
+ // NEW v15: API Contract Tester
393
+ // ═══════════════════════════════════════════════════════════════════════════
394
+ async function testAPIContract(endpoint, { expectedStatus = 200, expectedFields = [], method = 'GET', body = null, headers = {} } = {}) {
395
+ const r = await httpProbe(endpoint, { method, body, headers, timeout: 10000 });
396
+ const issues = [];
397
+
398
+ if (r.status !== expectedStatus) {
399
+ issues.push(`Expected status ${expectedStatus}, got ${r.status}`);
400
+ }
401
+ if (r.parsed && expectedFields.length > 0) {
402
+ for (const field of expectedFields) {
403
+ const hasField = field.includes('.')
404
+ ? field.split('.').reduce((obj, k) => obj?.[k], r.parsed) !== undefined
405
+ : r.parsed[field] !== undefined || (Array.isArray(r.parsed) && r.parsed[0]?.[field] !== undefined);
406
+ if (!hasField) issues.push(`Missing field: ${field}`);
407
+ }
408
+ }
409
+ if (r.status === 200 && !r.parsed && r.contentType.includes('json')) {
410
+ issues.push('Response is not valid JSON despite Content-Type: application/json');
411
+ }
412
+ const hasCache = !!(r.headers['cache-control'] || r.headers['etag'] || r.headers['last-modified']);
413
+ const hasCors = !!(r.headers['access-control-allow-origin']);
414
+
415
+ return {
416
+ url: endpoint, method, status: r.status, responseTime: r.responseTime,
417
+ contentType: r.contentType, bodySize: r.bodySize,
418
+ issues, passed: issues.length === 0,
419
+ hasCache, hasCors, parsed: r.parsed,
420
+ headers: r.headers,
421
+ };
422
+ }
423
+
424
+ // ═══════════════════════════════════════════════════════════════════════════
425
+ // NEW v15: Form Interaction Tester (Playwright)
426
+ // ═══════════════════════════════════════════════════════════════════════════
427
+ async function testForms(page, url) {
428
+ const results = [];
429
+ try {
430
+ const forms = await page.$$('form');
431
+ for (let fi = 0; fi < Math.min(forms.length, 5); fi++) {
432
+ const form = forms[fi];
433
+ const action = await form.getAttribute('action') || 'self';
434
+ const method = (await form.getAttribute('method') || 'GET').toUpperCase();
435
+ const inputs = await form.$$('input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
436
+ const submits = await form.$$('[type="submit"], button[type="submit"]');
437
+ const hasSubmit = submits.length > 0;
438
+
439
+ // Test required field validation
440
+ let validationWorks = false;
441
+ if (hasSubmit) {
442
+ try {
443
+ await submits[0].click({ timeout: 2000 });
444
+ await page.waitForTimeout(300);
445
+ // Check for validation messages
446
+ const invalidFields = await page.$$(':invalid');
447
+ validationWorks = invalidFields.length > 0 || (await page.evaluate(() => document.querySelector('.error, .invalid, [aria-invalid="true"]') !== null));
448
+ } catch {}
449
+ }
450
+
451
+ // Test placeholder/label
452
+ let labelCount = 0;
453
+ for (const inp of inputs) {
454
+ const id = await inp.getAttribute('id');
455
+ const ariaLbl = await inp.getAttribute('aria-label');
456
+ if (id) {
457
+ const lbl = await page.$(`label[for="${id}"]`);
458
+ if (lbl) labelCount++;
459
+ } else if (ariaLbl) {
460
+ labelCount++;
461
+ }
462
+ }
463
+
464
+ results.push({
465
+ formIndex: fi,
466
+ action, method,
467
+ inputCount: inputs.length,
468
+ hasSubmit,
469
+ labelCoverage: inputs.length > 0 ? Math.round(labelCount / inputs.length * 100) : 100,
470
+ validationWorks,
471
+ issues: [
472
+ !hasSubmit && 'No submit button',
473
+ inputs.length > 0 && labelCount < inputs.length && `${inputs.length - labelCount} inputs missing labels`,
474
+ ].filter(Boolean),
475
+ passed: hasSubmit && (inputs.length === 0 || labelCount === inputs.length),
476
+ });
477
+ }
478
+ } catch {}
479
+ return results;
480
+ }
481
+
482
+ // ═══════════════════════════════════════════════════════════════════════════
483
+ // NEW v15: Memory Leak Detector (Playwright)
484
+ // ═══════════════════════════════════════════════════════════════════════════
485
+ async function detectMemoryLeaks(page, url) {
486
+ const snapshots = [];
487
+ try {
488
+ // Snapshot 1: initial load
489
+ const heap1 = await page.evaluate(() => {
490
+ if (window.performance?.memory) {
491
+ return { used: performance.memory.usedJSHeapSize, total: performance.memory.totalJSHeapSize };
492
+ }
493
+ return null;
494
+ });
495
+ if (heap1) snapshots.push({ label: 'initial', ...heap1, time: 0 });
496
+
497
+ // Simulate user interactions to trigger potential leaks
498
+ await page.evaluate(() => {
499
+ for (let i = 0; i < 5; i++) {
500
+ window.dispatchEvent(new Event('scroll'));
501
+ window.dispatchEvent(new Event('resize'));
502
+ }
503
+ });
504
+ await page.waitForTimeout(1000);
505
+
506
+ // Navigate away and back
507
+ const currentUrl = page.url();
508
+ await page.goto('about:blank', { waitUntil: 'load' }).catch(() => {});
509
+ await page.goto(currentUrl, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
510
+ await page.waitForTimeout(500);
511
+
512
+ const heap2 = await page.evaluate(() => {
513
+ if (window.performance?.memory) {
514
+ return { used: performance.memory.usedJSHeapSize, total: performance.memory.totalJSHeapSize };
515
+ }
516
+ return null;
517
+ });
518
+ if (heap2) snapshots.push({ label: 'after-navigate', ...heap2, time: 1000 });
519
+
520
+ if (snapshots.length >= 2) {
521
+ const growth = snapshots[1].used - snapshots[0].used;
522
+ const growthMB = growth / 1024 / 1024;
523
+ return {
524
+ snapshots,
525
+ growth, growthMB: parseFloat(growthMB.toFixed(2)),
526
+ hasLeak: growthMB > 5,
527
+ severity: growthMB > 20 ? 'P1' : growthMB > 5 ? 'P2' : 'INFO',
528
+ };
529
+ }
530
+ } catch {}
531
+ return { snapshots, growth: 0, growthMB: 0, hasLeak: false, severity: 'INFO', note: 'performance.memory not available (non-Chrome)' };
532
+ }
533
+
534
+ // ═══════════════════════════════════════════════════════════════════════════
535
+ // NEW v15: Dark Mode Tester (Playwright)
536
+ // ═══════════════════════════════════════════════════════════════════════════
537
+ async function testDarkMode(page, url, screenshotDir, sessionId) {
538
+ const results = {};
539
+ try {
540
+ // Light mode screenshot already taken — test dark mode
541
+ await page.emulateMedia({ colorScheme: 'dark' });
542
+ await page.waitForTimeout(800);
543
+ const darkName = `${sessionId}-dark-${shortId()}.png`;
544
+ const darkPath = path.join(screenshotDir, darkName);
545
+ await page.screenshot({ path: darkPath, fullPage: false });
546
+
547
+ // Check if dark mode actually changes anything
548
+ const hasMediaQuery = await page.evaluate(() => {
549
+ const sheets = [...document.styleSheets];
550
+ for (const sheet of sheets) {
551
+ try {
552
+ const rules = [...sheet.cssRules];
553
+ for (const rule of rules) {
554
+ if (rule.conditionText?.includes('prefers-color-scheme')) return true;
555
+ }
556
+ } catch {}
557
+ }
558
+ return false;
559
+ });
560
+
561
+ // Check body background color changes
562
+ const darkBg = await page.evaluate(() => {
563
+ return window.getComputedStyle(document.body).backgroundColor;
564
+ });
565
+
566
+ results.dark = { screenshotPath: darkPath, screenshotName: darkName, background: darkBg };
567
+ results.hasMediaQuery = hasMediaQuery;
568
+ results.supportsDark = hasMediaQuery;
569
+
570
+ // Reset to light
571
+ await page.emulateMedia({ colorScheme: 'light' });
572
+ await page.waitForTimeout(300);
573
+
574
+ const lightBg = await page.evaluate(() => {
575
+ return window.getComputedStyle(document.body).backgroundColor;
576
+ });
577
+ results.light = { background: lightBg };
578
+ results.differentFromLight = darkBg !== lightBg;
579
+
580
+ } catch (err) {
581
+ results.error = err.message;
582
+ }
583
+ return results;
584
+ }
585
+
586
+ // ═══════════════════════════════════════════════════════════════════════════
587
+ // NEW v15: Third-Party Script Auditor (Playwright)
588
+ // ═══════════════════════════════════════════════════════════════════════════
589
+ async function auditThirdPartyScripts(page) {
590
+ const origin = new URL(page.url()).origin;
591
+ const scripts = [];
592
+ const requests = [];
593
+
594
+ const handler = (req) => {
595
+ if (req.resourceType() === 'script') {
596
+ try {
597
+ const u = new URL(req.url());
598
+ if (u.origin !== origin) {
599
+ requests.push({
600
+ url: req.url(),
601
+ domain: u.hostname,
602
+ vendor: classifyThirdParty(u.hostname),
603
+ });
604
+ }
605
+ } catch {}
606
+ }
607
+ };
608
+ page.on('request', handler);
609
+ await page.waitForTimeout(2000);
610
+ page.off('request', handler);
611
+
612
+ // Deduplicate by domain
613
+ const domainMap = {};
614
+ for (const r of requests) {
615
+ if (!domainMap[r.domain]) domainMap[r.domain] = { ...r, count: 0 };
616
+ domainMap[r.domain].count++;
617
+ }
618
+
619
+ return Object.values(domainMap);
620
+ }
621
+
622
+ function classifyThirdParty(hostname) {
623
+ const map = {
624
+ 'google-analytics.com': 'Google Analytics', 'googletagmanager.com': 'Google Tag Manager',
625
+ 'googlesyndication.com': 'Google Ads', 'doubleclick.net': 'Google Ads',
626
+ 'facebook.net': 'Facebook SDK', 'facebook.com': 'Facebook',
627
+ 'hotjar.com': 'Hotjar', 'fullstory.com': 'FullStory',
628
+ 'segment.io': 'Segment', 'amplitude.com': 'Amplitude',
629
+ 'mixpanel.com': 'Mixpanel', 'intercom.io': 'Intercom',
630
+ 'cdn.jsdelivr.net': 'jsDelivr CDN', 'unpkg.com': 'unpkg CDN',
631
+ 'cdnjs.cloudflare.com': 'Cloudflare CDN', 'stripe.com': 'Stripe',
632
+ 'sentry.io': 'Sentry', 'datadog-browser-agent.com': 'Datadog',
633
+ 'newrelic.com': 'New Relic', 'rollbar.com': 'Rollbar',
634
+ 'clarity.ms': 'Microsoft Clarity', 'twitter.com': 'Twitter',
635
+ 'x.com': 'X (Twitter)', 'tiktok.com': 'TikTok',
636
+ 'linkedin.com': 'LinkedIn',
637
+ };
638
+ for (const [domain, name] of Object.entries(map)) {
639
+ if (hostname.includes(domain)) return name;
640
+ }
641
+ return 'Unknown Third-Party';
642
+ }
643
+
644
+ // ═══════════════════════════════════════════════════════════════════════════
645
+ // NEW v15: Font & Asset Auditor (Playwright)
646
+ // ═══════════════════════════════════════════════════════════════════════════
647
+ async function auditFontsAndAssets(page) {
648
+ return await page.evaluate(() => {
649
+ const resources = performance.getEntriesByType('resource');
650
+ const audit = { fonts: [], images: [], scripts: [], styles: [], other: [] };
651
+
652
+ for (const r of resources) {
653
+ const entry = {
654
+ url: r.name.split('?')[0].split('/').pop().slice(0, 60),
655
+ fullUrl: r.name,
656
+ size: r.transferSize || 0,
657
+ duration: Math.round(r.duration),
658
+ type: r.initiatorType,
659
+ cached: r.transferSize === 0 && r.decodedBodySize > 0,
660
+ };
661
+
662
+ if (r.initiatorType === 'css' || r.name.match(/\.woff2?$|\.ttf$|\.eot$|\.otf$/i)) {
663
+ if (r.name.match(/font|\.woff|\.ttf|\.eot|\.otf/i)) audit.fonts.push(entry);
664
+ else audit.styles.push(entry);
665
+ } else if (r.initiatorType === 'img' || r.name.match(/\.(png|jpg|jpeg|gif|webp|avif|svg)/i)) {
666
+ audit.images.push(entry);
667
+ } else if (r.initiatorType === 'script' || r.name.match(/\.js$/i)) {
668
+ audit.scripts.push(entry);
669
+ } else if (r.initiatorType === 'link' || r.name.match(/\.css$/i)) {
670
+ audit.styles.push(entry);
671
+ } else {
672
+ audit.other.push(entry);
673
+ }
674
+ }
675
+
676
+ // Font analysis
677
+ const fontFaces = document.fonts ? [...document.fonts].map(f => ({
678
+ family: f.family, style: f.style, weight: f.weight, status: f.status,
679
+ })) : [];
680
+
681
+ // Image format analysis
682
+ const images = [...document.images].map(img => ({
683
+ src: img.src?.split('/').pop().slice(0, 60),
684
+ naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight,
685
+ displayWidth: img.width, displayHeight: img.height,
686
+ oversized: img.naturalWidth > img.width * 2 && img.naturalWidth > 200,
687
+ hasAlt: !!img.alt,
688
+ format: (img.src?.match(/\.(webp|avif|png|jpg|jpeg|gif|svg)/i) || ['unknown'])[0],
689
+ lazy: img.loading === 'lazy',
690
+ }));
691
+
692
+ const totalSize = resources.reduce((a, r) => a + (r.transferSize || 0), 0);
693
+ const jsSize = resources.filter(r => r.initiatorType === 'script').reduce((a, r) => a + (r.transferSize || 0), 0);
694
+ const cssSize = resources.filter(r => r.initiatorType === 'link').reduce((a, r) => a + (r.transferSize || 0), 0);
695
+ const imgSize = resources.filter(r => r.initiatorType === 'img').reduce((a, r) => a + (r.transferSize || 0), 0);
696
+ const fontSize = audit.fonts.reduce((a, f) => a + f.size, 0);
697
+
698
+ return { audit, fontFaces, images, totalSize, jsSize, cssSize, imgSize, fontSize };
699
+ }).catch(() => ({}));
700
+ }
701
+
702
+ // ═══════════════════════════════════════════════════════════════════════════
703
+ // NEW v15: User Flow Simulator (Playwright)
704
+ // ═══════════════════════════════════════════════════════════════════════════
705
+ async function simulateUserFlow(page, url) {
706
+ const steps = [];
707
+ const start = Date.now();
708
+
709
+ const step = async (name, fn) => {
710
+ const t0 = Date.now();
711
+ try {
712
+ await fn();
713
+ steps.push({ name, pass: true, duration: Date.now() - t0 });
714
+ } catch (err) {
715
+ steps.push({ name, pass: false, duration: Date.now() - t0, error: err.message });
716
+ }
717
+ };
718
+
719
+ await step('Page load', async () => {
720
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
721
+ });
722
+ await step('Scroll to bottom', async () => {
723
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
724
+ await page.waitForTimeout(500);
725
+ });
726
+ await step('Scroll back to top', async () => {
727
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
728
+ await page.waitForTimeout(500);
729
+ });
730
+ await step('Hover over first button', async () => {
731
+ const btn = page.locator('button:visible').first();
732
+ if (await btn.count() > 0) await btn.hover();
733
+ else throw new Error('No visible buttons');
734
+ });
735
+ await step('Click first navigation link', async () => {
736
+ const navLink = page.locator('nav a:visible, header a:visible').first();
737
+ if (await navLink.count() > 0) {
738
+ const href = await navLink.getAttribute('href');
739
+ if (href && !href.startsWith('mailto') && !href.startsWith('tel')) {
740
+ await navLink.hover();
741
+ }
742
+ }
743
+ });
744
+ await step('Tab through focusable elements', async () => {
745
+ for (let i = 0; i < 5; i++) {
746
+ await page.keyboard.press('Tab');
747
+ await page.waitForTimeout(80);
748
+ }
749
+ });
750
+ await step('Press Escape key', async () => {
751
+ await page.keyboard.press('Escape');
752
+ await page.waitForTimeout(200);
753
+ });
754
+ await step('Check no modal/dialog stuck', async () => {
755
+ const modal = await page.$('[role="dialog"]:visible, .modal:visible');
756
+ if (modal) throw new Error('Modal still visible after Escape');
757
+ });
758
+ await step('Back button navigation', async () => {
759
+ await page.goBack({ timeout: 5000 }).catch(() => {});
760
+ await page.waitForTimeout(300);
761
+ await page.goForward({ timeout: 5000 }).catch(() => {});
762
+ });
763
+
764
+ return {
765
+ url, steps, totalDuration: Date.now() - start,
766
+ passed: steps.filter(s => s.pass).length,
767
+ failed: steps.filter(s => !s.pass).length,
768
+ passRate: steps.length > 0 ? Math.round(steps.filter(s => s.pass).length / steps.length * 100) : 0,
769
+ };
770
+ }
771
+
772
+ // ═══════════════════════════════════════════════════════════════════════════
773
+ // NEW v15: Multi-Viewport Screenshot + Layout Tester (Playwright)
774
+ // ═══════════════════════════════════════════════════════════════════════════
775
+ async function testAllViewports(page, url, screenshotDir, sessionId) {
776
+ const results = {};
777
+ for (const [key, vp] of Object.entries(VIEWPORTS)) {
778
+ try {
779
+ await page.setViewportSize({ width: vp.width, height: vp.height });
780
+ await page.waitForTimeout(400);
781
+
782
+ const name = `${sessionId}-${key}-${shortId()}.png`;
783
+ const fpath = path.join(screenshotDir, name);
784
+ await page.screenshot({ path: fpath, fullPage: false });
785
+
786
+ // Check for overflow/horizontal scroll
787
+ const hasHorizontalScroll = await page.evaluate(() =>
788
+ document.documentElement.scrollWidth > document.documentElement.clientWidth
789
+ );
790
+ // Check font size not too small
791
+ const minFontSize = await page.evaluate(() => {
792
+ const els = [...document.querySelectorAll('p, span, a, li, td')].slice(0, 20);
793
+ return Math.min(...els.map(el => parseFloat(window.getComputedStyle(el).fontSize) || 16));
794
+ }).catch(() => 16);
795
+
796
+ results[key] = {
797
+ label: vp.label, width: vp.width, height: vp.height,
798
+ screenshotName: name, screenshotPath: fpath,
799
+ hasHorizontalScroll, minFontSize,
800
+ issues: [
801
+ hasHorizontalScroll && 'Horizontal scroll detected (layout overflow)',
802
+ minFontSize < 12 && `Font too small: ${minFontSize}px`,
803
+ ].filter(Boolean),
804
+ passed: !hasHorizontalScroll && minFontSize >= 12,
805
+ };
806
+ } catch (err) {
807
+ results[key] = { label: vp.label, width: vp.width, height: vp.height, error: err.message, passed: false };
808
+ }
809
+ }
810
+ // Reset to desktop
811
+ await page.setViewportSize({ width: 1280, height: 900 });
812
+ return results;
813
+ }
814
+
815
+ // ═══════════════════════════════════════════════════════════════════════════
816
+ // NEW v15: Cache Headers Auditor
817
+ // ═══════════════════════════════════════════════════════════════════════════
818
+ async function auditCacheHeaders(url) {
819
+ const r = await httpProbe(url);
820
+ const h = r.headers;
821
+
822
+ const cacheControl = h['cache-control'] || '';
823
+ const etag = h['etag'] || null;
824
+ const lastModified = h['last-modified'] || null;
825
+ const expires = h['expires'] || null;
826
+ const vary = h['vary'] || null;
827
+ const age = h['age'] || null;
828
+ const xCache = h['x-cache'] || h['cf-cache-status'] || h['x-vercel-cache'] || null;
829
+
830
+ const maxAge = (cacheControl.match(/max-age=(\d+)/) || [])[1];
831
+ const noStore = cacheControl.includes('no-store');
832
+ const noCache = cacheControl.includes('no-cache');
833
+ const immutable = cacheControl.includes('immutable');
834
+ const private_ = cacheControl.includes('private');
835
+
836
+ const issues = [];
837
+ if (!cacheControl && !expires) issues.push('No Cache-Control or Expires header');
838
+ if (!etag && !lastModified) issues.push('No cache validation (ETag/Last-Modified)');
839
+ if (maxAge && parseInt(maxAge) > 86400 * 365 && !immutable) issues.push('Very long max-age without immutable');
840
+
841
+ return {
842
+ url, cacheControl, etag, lastModified, expires, vary, age, xCache,
843
+ maxAge: maxAge ? parseInt(maxAge) : null,
844
+ noStore, noCache, immutable, private: private_,
845
+ issues, passed: issues.length === 0,
846
+ cacheable: !noStore && !!cacheControl,
847
+ };
848
+ }
849
+
850
+ // ═══════════════════════════════════════════════════════════════════════════
851
+ // NEW v15: Mixed Content & CSP Violation Checker (Playwright)
852
+ // ═══════════════════════════════════════════════════════════════════════════
853
+ async function checkMixedContent(page) {
854
+ const mixed = [];
855
+ const cspViolations = [];
856
+
857
+ page.on('console', (msg) => {
858
+ const text = msg.text();
859
+ if (text.includes('Mixed Content')) mixed.push({ type: 'mixed-content', text });
860
+ if (text.includes('Content Security Policy') || text.includes('CSP')) {
861
+ cspViolations.push({ type: 'csp-violation', text });
862
+ }
863
+ });
864
+
865
+ await page.waitForTimeout(1500);
866
+ return { mixed, cspViolations };
867
+ }
868
+
869
+ // ═══════════════════════════════════════════════════════════════════════════
870
+ // NEW v15: Error Page Tester (404, 500)
871
+ // ═══════════════════════════════════════════════════════════════════════════
872
+ async function testErrorPages(baseUrl) {
873
+ const tests = [
874
+ { url: `${baseUrl}/this-page-definitely-does-not-exist-qa-test-${shortId()}`, expectedStatus: 404, name: '404 Page' },
875
+ { url: `${baseUrl}/api/this-endpoint-does-not-exist-${shortId()}`, expectedStatus: 404, name: 'API 404' },
876
+ ];
877
+
878
+ const results = [];
879
+ for (const t of tests) {
880
+ const r = await httpProbe(t.url);
881
+ const isCorrectStatus = r.status === t.expectedStatus || r.status === 404;
882
+ const hasCustomPage = r.body.length > 200 && !r.body.toLowerCase().includes('cannot get');
883
+ const hasErrorText = /404|not found|page.*not.*found/i.test(r.body);
884
+
885
+ results.push({
886
+ ...t, actualStatus: r.status,
887
+ isCorrectStatus, hasCustomPage, hasErrorText,
888
+ bodySize: r.bodySize,
889
+ issues: [
890
+ !isCorrectStatus && `Returns ${r.status} instead of ${t.expectedStatus}`,
891
+ r.status === 200 && 'Returns 200 for non-existent page (soft 404)',
892
+ !hasCustomPage && 'No custom error page',
893
+ ].filter(Boolean),
894
+ passed: isCorrectStatus && (hasCustomPage || hasErrorText),
895
+ });
896
+ }
897
+ return results;
898
+ }
899
+
900
+ // ═══════════════════════════════════════════════════════════════════════════
901
+ // NEW v15: HTTP Version & TLS Inspector
902
+ // ═══════════════════════════════════════════════════════════════════════════
903
+ async function inspectHTTPVersion(url) {
904
+ const r = await httpProbe(url);
905
+ const isHTTPS = url.startsWith('https://');
906
+ const altSvc = r.headers['alt-svc'] || '';
907
+ const hasH2 = altSvc.includes('h2') || r.headers['x-powered-by']?.includes('h2');
908
+ const hasH3 = altSvc.includes('h3');
909
+
910
+ return {
911
+ url, isHTTPS,
912
+ altSvc: altSvc || null,
913
+ likelyHTTP2: hasH2 || isHTTPS, // Most modern HTTPS servers use H2
914
+ likelyHTTP3: hasH3,
915
+ hsts: r.headers['strict-transport-security'] || null,
916
+ issues: [
917
+ !isHTTPS && 'Not using HTTPS',
918
+ ].filter(Boolean),
919
+ };
920
+ }
921
+
922
+ // ═══════════════════════════════════════════════════════════════════════════
923
+ // PLAYWRIGHT REAL BROWSER ENGINE v15 — Enhanced
924
+ // ═══════════════════════════════════════════════════════════════════════════
925
+ async function runPlaywrightScan(url, session, dash, options = {}) {
926
+ const chromium = await getPlaywright();
927
+ if (!chromium) {
928
+ dash?.log(chalk.yellow(' ⚠ Playwright not found. Run: npm install playwright && npx playwright install chromium'));
929
+ return null;
930
+ }
931
+
932
+ dash?.log(chalk.cyan(` 🎭 backlist browser launching for ${url}...`));
933
+
934
+ let browser, context, page;
935
+ const results = {
936
+ consoleErrors : [],
937
+ networkFails : [],
938
+ screenshots : [],
939
+ vitals : {},
940
+ interactions : [],
941
+ domChecks : [],
942
+ jsErrors : [],
943
+ networkRequests: [],
944
+ darkMode : {},
945
+ viewportResults: {},
946
+ fonts : {},
947
+ thirdParty : [],
948
+ forms : [],
949
+ memoryLeak : {},
950
+ userFlow : {},
951
+ mixedContent : {},
952
+ cspViolations : [],
953
+ };
954
+
955
+ try {
956
+ browser = await chromium.launch({
957
+ headless: options.headless !== false,
958
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--enable-precise-memory-info'],
959
+ });
960
+
961
+ context = await browser.newContext({
962
+ viewport: { width: 1280, height: 900 },
963
+ userAgent: 'Backlist-QA/15.0 (Playwright)',
964
+ ignoreHTTPSErrors: true,
965
+ });
966
+
967
+ page = await context.newPage();
968
+
969
+ // ── Mixed Content & CSP violations ──────────────────────────────────
970
+ const mixedContent = [];
971
+ const cspViolations2 = [];
972
+ page.on('console', (msg) => {
973
+ const type = msg.type();
974
+ const text = msg.text();
975
+ if (text.includes('Mixed Content')) mixedContent.push(text);
976
+ if (text.includes('Content Security Policy') || text.includes('refused to')) cspViolations2.push(text);
977
+ if (['error', 'warning'].includes(type)) {
978
+ const entry = { type, text, timestamp: Date.now(), url: page.url() };
979
+ results.consoleErrors.push(entry);
980
+ session.consoleErrors.push(entry);
981
+ }
982
+ });
983
+
984
+ // ── Capture JS errors ────────────────────────────────────────────────
985
+ page.on('pageerror', (err) => {
986
+ const entry = { message: err.message, stack: err.stack, url: page.url(), timestamp: Date.now() };
987
+ results.jsErrors.push(entry);
988
+ session.consoleErrors.push({ type: 'pageerror', text: err.message, url: page.url() });
989
+ });
990
+
991
+ // ── Network monitoring ───────────────────────────────────────────────
992
+ const requestTimings = new Map();
993
+ page.on('request', (req) => {
994
+ requestTimings.set(req.url(), Date.now());
995
+ });
996
+ page.on('requestfailed', (req) => {
997
+ const entry = {
998
+ url: req.url(), method: req.method(),
999
+ failure: req.failure()?.errorText || 'unknown', timestamp: Date.now(),
1000
+ };
1001
+ results.networkFails.push(entry);
1002
+ session.networkLog.push(entry);
1003
+ });
1004
+ page.on('response', (res) => {
1005
+ const start = requestTimings.get(res.url()) || Date.now();
1006
+ const duration = Date.now() - start;
1007
+ const entry = {
1008
+ url: res.url(), status: res.status(), duration,
1009
+ size: parseInt(res.headers()['content-length'] || '0'),
1010
+ type: res.headers()['content-type'] || '',
1011
+ };
1012
+ results.networkRequests.push(entry);
1013
+ if (res.status() >= 400) {
1014
+ results.networkFails.push({ url: res.url(), status: res.status(), duration });
1015
+ }
1016
+ });
1017
+
1018
+ // ── Navigate ─────────────────────────────────────────────────────────
1019
+ const navStart = Date.now();
1020
+ const response = await page.goto(url, {
1021
+ waitUntil: 'networkidle', timeout: 30000,
1022
+ }).catch(err => ({ error: err.message }));
1023
+ const navDuration = Date.now() - navStart;
1024
+
1025
+ if (response?.error) {
1026
+ dash?.log(chalk.red(` ✗ Navigation failed: ${response.error}`));
1027
+ return { error: response.error, results };
1028
+ }
1029
+
1030
+ await fs.ensureDir(SCREENSHOT_DIR);
1031
+
1032
+ // ── 1. Desktop Screenshot ────────────────────────────────────────────
1033
+ const desktopName = `${session.id}-desktop-${shortId()}.png`;
1034
+ const desktopPath = path.join(SCREENSHOT_DIR, desktopName);
1035
+ await page.screenshot({ path: desktopPath, fullPage: true });
1036
+ results.screenshots.push({ path: desktopPath, name: desktopName, type: 'desktop', url });
1037
+ session.screenshots.push({ path: desktopPath, name: desktopName, type: 'desktop', url });
1038
+ dash?.log(chalk.green(` 📸 Desktop screenshot: ${desktopName}`));
1039
+
1040
+ // ── 2. Multi-Viewport Testing (v15) ──────────────────────────────────
1041
+ dash?.log(chalk.cyan(' 📱 Testing all viewports...'));
1042
+ const vpResults = await testAllViewports(page, url, SCREENSHOT_DIR, session.id);
1043
+ results.viewportResults = vpResults;
1044
+ session.viewportResults = { ...session.viewportResults, ...vpResults };
1045
+ for (const [key, vp] of Object.entries(vpResults)) {
1046
+ if (vp.screenshotName) {
1047
+ session.screenshots.push({ path: vp.screenshotPath, name: vp.screenshotName, type: `viewport-${key}`, url, label: vp.label });
1048
+ }
1049
+ }
1050
+ const vpIssues = Object.values(vpResults).filter(v => !v.passed);
1051
+ dash?.log(chalk.green(` ✓ Viewports: ${Object.keys(vpResults).length - vpIssues.length}/${Object.keys(vpResults).length} passed`));
1052
+
1053
+ // ── 3. Dark Mode Test (v15) ───────────────────────────────────────────
1054
+ dash?.log(chalk.cyan(' 🌙 Testing dark mode...'));
1055
+ const darkResult = await testDarkMode(page, url, SCREENSHOT_DIR, session.id);
1056
+ results.darkMode = darkResult;
1057
+ session.darkModeResults.push({ url, ...darkResult });
1058
+ if (darkResult.dark?.screenshotName) {
1059
+ session.screenshots.push({ path: darkResult.dark.screenshotPath, name: darkResult.dark.screenshotName, type: 'dark-mode', url });
1060
+ }
1061
+
1062
+ // ── 4. Real Web Vitals ────────────────────────────────────────────────
1063
+ dash?.log(chalk.cyan(' ⚡ Measuring real Web Vitals...'));
1064
+ // Navigate fresh for clean vitals
1065
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
1066
+ const vitals = await page.evaluate(() => {
1067
+ return new Promise((resolve) => {
1068
+ const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
1069
+ let clsVal = 0;
1070
+ const navEntry = performance.getEntriesByType('navigation')[0];
1071
+ if (navEntry) v.ttfb = Math.round(navEntry.responseStart - navEntry.requestStart);
1072
+ const paintEntries = performance.getEntriesByType('paint');
1073
+ paintEntries.forEach(e => { if (e.name === 'first-contentful-paint') v.fcp = Math.round(e.startTime); });
1074
+ try { new PerformanceObserver((list) => {
1075
+ const e = list.getEntries(); const last = e[e.length - 1];
1076
+ if (last) v.lcp = Math.round(last.startTime);
1077
+ }).observe({ type: 'largest-contentful-paint', buffered: true }); } catch {}
1078
+ try { new PerformanceObserver((list) => {
1079
+ for (const e of list.getEntries()) { if (!e.hadRecentInput) clsVal += e.value; }
1080
+ v.cls = parseFloat(clsVal.toFixed(4));
1081
+ }).observe({ type: 'layout-shift', buffered: true }); } catch {}
1082
+ try { new PerformanceObserver((list) => {
1083
+ for (const e of list.getEntries()) { if (e.duration > 50) v.tbt += Math.round(e.duration - 50); }
1084
+ }).observe({ type: 'longtask', buffered: true }); } catch {}
1085
+ setTimeout(() => { v.cls = parseFloat(clsVal.toFixed(4)); resolve(v); }, 2500);
1086
+ });
1087
+ }).catch(() => ({}));
1088
+
1089
+ const navTiming = await page.evaluate(() => {
1090
+ const nav = performance.getEntriesByType('navigation')[0];
1091
+ if (!nav) return {};
1092
+ return {
1093
+ ttfb: Math.round(nav.responseStart - nav.requestStart),
1094
+ domLoad: Math.round(nav.domContentLoadedEventEnd),
1095
+ fullLoad: Math.round(nav.loadEventEnd),
1096
+ dnsLookup: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
1097
+ tcpConnect: Math.round(nav.connectEnd - nav.connectStart),
1098
+ transferSize: nav.transferSize,
1099
+ };
1100
+ }).catch(() => ({}));
1101
+
1102
+ results.vitals = { ...vitals, ...navTiming, navDuration };
1103
+ dash?.log(chalk.green(` ✓ Vitals: TTFB=${navTiming.ttfb||'?'}ms LCP=${vitals.lcp||'?'}ms FCP=${vitals.fcp||'?'}ms CLS=${vitals.cls??'?'}`));
1104
+
1105
+ // ── 5. Memory Leak Detection (v15) ────────────────────────────────────
1106
+ dash?.log(chalk.cyan(' 🧠 Detecting memory leaks...'));
1107
+ const memResult = await detectMemoryLeaks(page, url);
1108
+ results.memoryLeak = memResult;
1109
+ session.memorySnapshots.push({ url, ...memResult });
1110
+ if (memResult.hasLeak) {
1111
+ dash?.log(chalk.yellow(` ⚠ Memory leak detected: +${memResult.growthMB}MB`));
1112
+ }
1113
+
1114
+ // ── 6. DOM Checks ────────────────────────────────────────────────────
1115
+ dash?.log(chalk.cyan(' 🔍 Running DOM checks...'));
1116
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
1117
+ const domChecks = await page.evaluate(() => {
1118
+ const checks = [];
1119
+ const title = document.title;
1120
+ checks.push({ name: 'Page title', pass: !!title && title.length > 0, value: title?.slice(0, 80) });
1121
+ const h1s = document.querySelectorAll('h1');
1122
+ checks.push({ name: 'Single H1', pass: h1s.length === 1, value: `${h1s.length} H1 tags` });
1123
+ const imgs = document.querySelectorAll('img');
1124
+ const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
1125
+ checks.push({ name: 'Images alt text', pass: noAlt === 0, value: `${noAlt}/${imgs.length} missing alt` });
1126
+ const btns = document.querySelectorAll('button');
1127
+ const noText = [...btns].filter(b => !b.textContent?.trim() && !b.getAttribute('aria-label')).length;
1128
+ checks.push({ name: 'Buttons accessible', pass: noText === 0, value: `${noText} buttons missing label` });
1129
+ const links = document.querySelectorAll('a');
1130
+ const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
1131
+ checks.push({ name: 'Links have href', pass: noHref === 0, value: `${noHref}/${links.length} empty links` });
1132
+ const forms = document.querySelectorAll('form');
1133
+ const noSubmit = [...forms].filter(f => !f.querySelector('[type="submit"], button')).length;
1134
+ checks.push({ name: 'Forms have submit', pass: noSubmit === 0 || forms.length === 0, value: `${forms.length} forms` });
1135
+ const vp = document.querySelector('meta[name="viewport"]');
1136
+ checks.push({ name: 'Viewport meta', pass: !!vp, value: vp?.content || 'missing' });
1137
+ const bodyStyle = window.getComputedStyle(document.body);
1138
+ checks.push({ name: 'Body has styles', pass: !!bodyStyle.backgroundColor || !!bodyStyle.color, value: 'CSS applied' });
1139
+ // NEW v15 DOM checks
1140
+ const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-link');
1141
+ checks.push({ name: 'Skip navigation link', pass: !!skipLink, value: skipLink ? 'Present' : 'Missing (accessibility)' });
1142
+ const mainEl = document.querySelector('main, [role="main"]');
1143
+ checks.push({ name: 'Main landmark', pass: !!mainEl, value: mainEl ? 'Present' : 'Missing' });
1144
+ const footerEl = document.querySelector('footer, [role="contentinfo"]');
1145
+ checks.push({ name: 'Footer landmark', pass: !!footerEl, value: footerEl ? 'Present' : 'Missing' });
1146
+ const focusableEls = document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
1147
+ checks.push({ name: 'Focusable elements exist', pass: focusableEls.length > 0, value: `${focusableEls.length} focusable` });
1148
+ const langAttr = document.documentElement.lang;
1149
+ checks.push({ name: 'HTML lang attribute', pass: !!langAttr, value: langAttr || 'missing' });
1150
+ const canonical = document.querySelector('link[rel="canonical"]');
1151
+ checks.push({ name: 'Canonical URL', pass: !!canonical, value: canonical?.href || 'missing' });
1152
+ const ogTitle = document.querySelector('meta[property="og:title"]');
1153
+ checks.push({ name: 'Open Graph title', pass: !!ogTitle, value: ogTitle?.content?.slice(0, 60) || 'missing' });
1154
+ const robots = document.querySelector('meta[name="robots"]');
1155
+ checks.push({ name: 'Robots meta', pass: true, value: robots?.content || 'none (indexable)' });
1156
+ const internalLinks = [...links].filter(l => { try { return new URL(l.href).origin === window.location.origin; } catch { return false; } });
1157
+ checks.push({ name: 'Internal links count', pass: true, value: `${internalLinks.length} internal links` });
1158
+ return checks;
1159
+ }).catch(() => []);
1160
+
1161
+ results.domChecks = domChecks;
1162
+ dash?.log(chalk.green(` ✓ DOM: ${domChecks.filter(c => c.pass).length}/${domChecks.length} checks passed`));
1163
+
1164
+ // ── 7. Form Tests (v15) ───────────────────────────────────────────────
1165
+ dash?.log(chalk.cyan(' 📝 Testing forms...'));
1166
+ const formResults = await testForms(page, url);
1167
+ results.forms = formResults;
1168
+ session.formTests.push(...formResults.map(f => ({ url, ...f })));
1169
+
1170
+ // ── 8. Third-Party Script Audit (v15) ─────────────────────────────────
1171
+ dash?.log(chalk.cyan(' 📦 Auditing third-party scripts...'));
1172
+ const thirdPartyScripts = await auditThirdPartyScripts(page);
1173
+ results.thirdParty = thirdPartyScripts;
1174
+ session.thirdPartyScripts.push(...thirdPartyScripts.map(s => ({ url, ...s })));
1175
+
1176
+ // ── 9. Font & Asset Audit (v15) ───────────────────────────────────────
1177
+ dash?.log(chalk.cyan(' 🔤 Auditing fonts and assets...'));
1178
+ const assetData = await auditFontsAndAssets(page);
1179
+ results.fonts = assetData;
1180
+ session.assetAudit.push({ url, ...assetData });
1181
+
1182
+ // ── 10. Interaction Tests ─────────────────────────────────────────────
1183
+ dash?.log(chalk.cyan(' 🖱️ Testing interactions...'));
1184
+ const interactions = [];
1185
+ const buttonCount = await page.locator('button:visible').count().catch(() => 0);
1186
+ interactions.push({ name: 'Visible buttons found', pass: true, value: `${buttonCount} buttons` });
1187
+ const inputCount = await page.locator('input:visible').count().catch(() => 0);
1188
+ interactions.push({ name: 'Form inputs found', pass: true, value: `${inputCount} inputs` });
1189
+ try {
1190
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
1191
+ await page.waitForTimeout(300);
1192
+ await page.evaluate(() => window.scrollTo(0, 0));
1193
+ interactions.push({ name: 'Page scroll', pass: true, value: 'Scroll works' });
1194
+ } catch (err) {
1195
+ interactions.push({ name: 'Page scroll', pass: false, value: err.message });
1196
+ }
1197
+ try {
1198
+ await page.keyboard.press('Tab');
1199
+ await page.waitForTimeout(100);
1200
+ const focused = await page.evaluate(() => document.activeElement?.tagName || 'none');
1201
+ interactions.push({ name: 'Keyboard navigation', pass: focused !== 'BODY', value: `Focus: ${focused}` });
1202
+ } catch {
1203
+ interactions.push({ name: 'Keyboard navigation', pass: false, value: 'Tab focus failed' });
1204
+ }
1205
+ try {
1206
+ const firstLink = page.locator('a:visible').first();
1207
+ if (await firstLink.count() > 0) { await firstLink.hover(); interactions.push({ name: 'Link hover', pass: true, value: 'Hover works' }); }
1208
+ } catch { interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' }); }
1209
+ // NEW v15: right-click test
1210
+ try {
1211
+ await page.mouse.click(640, 400, { button: 'right' });
1212
+ await page.waitForTimeout(200);
1213
+ await page.keyboard.press('Escape');
1214
+ interactions.push({ name: 'Right-click (context menu)', pass: true, value: 'Works' });
1215
+ } catch { interactions.push({ name: 'Right-click', pass: false, value: 'Failed' }); }
1216
+ // NEW v15: copy text test
1217
+ try {
1218
+ await page.keyboard.press('Control+a');
1219
+ await page.waitForTimeout(100);
1220
+ interactions.push({ name: 'Select all text', pass: true, value: 'Ctrl+A works' });
1221
+ } catch { interactions.push({ name: 'Select all text', pass: false, value: 'Failed' }); }
1222
+
1223
+ results.interactions = interactions;
1224
+ dash?.log(chalk.green(` ✓ Interactions: ${interactions.filter(i => i.pass).length}/${interactions.length} passed`));
1225
+
1226
+ // ── 11. User Flow Simulation (v15) ────────────────────────────────────
1227
+ dash?.log(chalk.cyan(' 🧑‍💻 Simulating user flow...'));
1228
+ const flowResult = await simulateUserFlow(page, url);
1229
+ results.userFlow = flowResult;
1230
+ session.userFlowResults.push(flowResult);
1231
+ dash?.log(chalk.green(` ✓ User flow: ${flowResult.passed}/${flowResult.steps.length} steps passed`));
1232
+
1233
+ // ── 12. Resource Analysis ─────────────────────────────────────────────
1234
+ const resourceStats = await page.evaluate(() => {
1235
+ const entries = performance.getEntriesByType('resource');
1236
+ const byType = {};
1237
+ let totalSize = 0;
1238
+ for (const e of entries) {
1239
+ const t = e.initiatorType || 'other';
1240
+ if (!byType[t]) byType[t] = { count: 0, size: 0, time: 0, slow: [] };
1241
+ byType[t].count++;
1242
+ byType[t].size += e.transferSize || 0;
1243
+ byType[t].time += e.duration;
1244
+ totalSize += e.transferSize || 0;
1245
+ if (e.duration > 500) byType[t].slow.push({ url: e.name.split('/').pop().slice(0, 60), duration: Math.round(e.duration), size: e.transferSize || 0 });
1246
+ }
1247
+ return { byType, totalSize, count: entries.length };
1248
+ }).catch(() => ({}));
1249
+
1250
+ results.resourceStats = resourceStats;
1251
+ results.mixedContent = mixedContent;
1252
+ results.cspViolations = cspViolations2;
1253
+
1254
+ // Store mixed content/CSP in session
1255
+ session.mixedContentIssues.push(...mixedContent.map(m => ({ url, text: m })));
1256
+ session.cspViolations.push(...cspViolations2.map(c => ({ url, text: c })));
1257
+
1258
+ return { results, navDuration, error: null };
1259
+
1260
+ } catch (err) {
1261
+ dash?.log(chalk.red(` ✗ Playwright error: ${err.message}`));
1262
+ return { error: err.message, results };
1263
+ } finally {
1264
+ try { await page?.close(); } catch {}
1265
+ try { await context?.close(); } catch {}
1266
+ try { await browser?.close(); } catch {}
1267
+ }
1268
+ }
1269
+
1270
+ // ═══════════════════════════════════════════════════════════════════════════
1271
+ // Route Crawler — real HTTP crawl
139
1272
  // ═══════════════════════════════════════════════════════════════════════════
140
- async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
1273
+ async function crawlSite(baseUrl, { maxPages = 60, onRoute } = {}) {
141
1274
  const visited = new Set();
142
1275
  const queue = [{ url: baseUrl, depth: 0 }];
143
1276
  const routes = [];
144
1277
 
145
- const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
1278
+ const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
146
1279
  const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
147
1280
 
148
1281
  while (queue.length > 0 && routes.length < maxPages) {
149
1282
  const { url, depth } = queue.shift();
150
1283
  const n = norm(url);
151
- if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
1284
+ if (!n || visited.has(n) || !sameOrigin(n) || depth > 4) continue;
152
1285
  visited.add(n);
153
1286
 
154
- const r = await httpProbe(n, { timeout: 10000 });
1287
+ const r = await httpProbe(n, { timeout: 10000 });
155
1288
  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';
1289
+ if (r.status >= 400) return 'error-page';
1290
+ if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
1291
+ if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
1292
+ if (/\/(login|signin|auth)/i.test(n)) return 'auth';
1293
+ if (/\/(admin)/i.test(n)) return 'admin';
1294
+ if (/\.(css|js|woff|png|jpg|gif|svg|ico)/i.test(n)) return 'asset';
161
1295
  return 'page';
162
1296
  })();
163
1297
 
164
- // Extract links from HTML
165
1298
  const links = [];
166
1299
  if (r.contentType.includes('text/html')) {
167
1300
  const re = /href=["']([^"'#?][^"']*?)["']/gi;
@@ -171,7 +1304,6 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
171
1304
  }
172
1305
  }
173
1306
 
174
- // Extract forms
175
1307
  const forms = [];
176
1308
  const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
177
1309
  let fm;
@@ -179,16 +1311,16 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
179
1311
  const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
180
1312
  const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
181
1313
  const fields = [];
182
- const ir = /<input([^>]*)>/gi; let inp;
1314
+ const ir = /<input([^>]*)>/gi; let inp;
183
1315
  while ((inp = ir.exec(fm[2])) !== null) {
184
- const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
1316
+ const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
185
1317
  const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
186
1318
  if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
187
1319
  }
188
1320
  forms.push({ action, method, fields });
189
1321
  }
190
1322
 
191
- const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error };
1323
+ const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error, responseTime: r.responseTime };
192
1324
  routes.push(route);
193
1325
  if (onRoute) onRoute(route);
194
1326
 
@@ -198,8 +1330,15 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
198
1330
  }
199
1331
  }
200
1332
 
201
- // Probe common API endpoints
202
- const commonPaths = ['/api/health','/health','/api/status','/api/v1/health','/api/docs','/robots.txt','/sitemap.xml'];
1333
+ // Common paths probe (v15 extended)
1334
+ const commonPaths = [
1335
+ '/api/health', '/health', '/api/status', '/api/v1/health',
1336
+ '/api/docs', '/robots.txt', '/sitemap.xml', '/manifest.json',
1337
+ '/sw.js', '/service-worker.js', '/favicon.ico',
1338
+ '/.well-known/security.txt', '/security.txt',
1339
+ '/api/v1', '/api/v2', '/graphql',
1340
+ '/changelog', '/version',
1341
+ ];
203
1342
  for (const p2 of commonPaths) {
204
1343
  try {
205
1344
  const u = new URL(p2, baseUrl).toString();
@@ -208,7 +1347,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
208
1347
  visited.add(n);
209
1348
  const r = await httpProbe(u, { timeout: 5000 });
210
1349
  if (r.status > 0 && r.status < 500) {
211
- const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
1350
+ const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [], responseTime: r.responseTime };
212
1351
  routes.push(route);
213
1352
  if (onRoute) onRoute(route);
214
1353
  }
@@ -219,11 +1358,11 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
219
1358
  }
220
1359
 
221
1360
  // ═══════════════════════════════════════════════════════════════════════════
222
- // Security Scanner — real HTTP header analysis
1361
+ // Security Scanner v15 Extended
223
1362
  // ═══════════════════════════════════════════════════════════════════════════
224
1363
  async function runSecurityScan(url) {
225
1364
  const findings = [];
226
- const r = await httpProbe(url);
1365
+ const r = await httpProbe(url);
227
1366
 
228
1367
  if (!r.ok && r.status === 0) {
229
1368
  return [{ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
@@ -233,55 +1372,86 @@ async function runSecurityScan(url) {
233
1372
  const h = r.headers;
234
1373
 
235
1374
  const headerChecks = [
236
- { id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
1375
+ { id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
237
1376
  validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
238
- { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
1377
+ { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
239
1378
  validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
240
- { id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
1379
+ { id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
241
1380
  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',
1381
+ { id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
243
1382
  validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
244
- { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
1383
+ { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
245
1384
  validate: v => !!v, rec: 'Add Referrer-Policy header' },
246
- { id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
1385
+ { id: 'pp', name: 'Permissions-Policy', header: 'permissions-policy', sev: 'P2',
1386
+ validate: v => !!v, rec: 'Add Permissions-Policy to restrict browser features' },
1387
+ { id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
247
1388
  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"))' },
1389
+ { id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
1390
+ validate: v => !v, rec: 'Remove X-Powered-By header' },
1391
+ // NEW v15
1392
+ { id: 'coep', name: 'Cross-Origin-Embedder-Policy', header: 'cross-origin-embedder-policy', sev: 'P3',
1393
+ validate: v => !!v, rec: 'Add COEP for isolation' },
1394
+ { id: 'coop', name: 'Cross-Origin-Opener-Policy', header: 'cross-origin-opener-policy', sev: 'P3',
1395
+ validate: v => !!v, rec: 'Add COOP header' },
250
1396
  ];
251
1397
 
252
1398
  for (const c of headerChecks) {
253
1399
  const val = h[c.header] || '';
254
1400
  const pass = c.validate(val);
255
- findings.push({ check: c.name, pass, severity: pass ? 'INFO' : c.sev,
1401
+ findings.push({
1402
+ check: c.name, pass, severity: pass ? 'INFO' : c.sev,
256
1403
  category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
257
- recommendation: c.rec, evidence: { header: c.header, value: val || null } });
1404
+ recommendation: c.rec, evidence: { header: c.header, value: val || null },
1405
+ });
258
1406
  }
259
1407
 
260
- // HTTPS check
261
1408
  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 } });
1409
+ findings.push({
1410
+ check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
1411
+ category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP unencrypted',
1412
+ recommendation: 'Use HTTPS with valid SSL',
1413
+ });
265
1414
 
266
- // CORS wildcard check
267
1415
  const corsOrigin = h['access-control-allow-origin'];
268
1416
  const corsCreds = h['access-control-allow-credentials'];
269
1417
  const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
270
- findings.push({ check: 'CORS wildcard + credentials', pass: corsPass,
271
- severity: corsPass ? 'INFO' : 'P0', category: 'cors',
1418
+ findings.push({
1419
+ check: 'CORS wildcard + credentials', pass: corsPass, severity: corsPass ? 'INFO' : 'P0', category: 'cors',
272
1420
  detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
273
1421
  recommendation: 'Never combine CORS * with allow-credentials',
274
- evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds } });
1422
+ });
1423
+
1424
+ // NEW v15: Check for version disclosure in other headers
1425
+ const versionHeaders = ['x-aspnet-version', 'x-aspnetmvc-version', 'x-drupal-cache', 'x-generator'];
1426
+ for (const vh of versionHeaders) {
1427
+ if (h[vh]) findings.push({
1428
+ check: `${vh} disclosure`, pass: false, severity: 'P2', category: 'information-disclosure',
1429
+ detail: `${vh}: ${h[vh]}`, recommendation: `Remove ${vh} header`,
1430
+ });
1431
+ }
275
1432
 
276
- // Probe sensitive paths
277
1433
  const base = new URL(url).origin;
278
1434
  const sensitives = [
279
- { path: '/.env', name: '.env exposed' },
280
- { path: '/.git/config', name: 'Git config exposed' },
281
- { path: '/phpinfo.php', name: 'phpinfo exposed' },
282
- { path: '/server-status', name: 'Apache server-status' },
283
- { path: '/actuator', name: 'Spring actuator exposed' },
284
- { path: '/graphql', name: 'GraphQL introspection' },
1435
+ { path: '/.env', name: '.env exposed' },
1436
+ { path: '/.env.local', name: '.env.local exposed' },
1437
+ { path: '/.git/config', name: 'Git config exposed' },
1438
+ { path: '/phpinfo.php', name: 'phpinfo exposed' },
1439
+ { path: '/server-status', name: 'Apache server-status' },
1440
+ { path: '/actuator', name: 'Spring actuator' },
1441
+ { path: '/actuator/env', name: 'Spring actuator env' },
1442
+ { path: '/graphql', name: 'GraphQL introspection' },
1443
+ { path: '/api/swagger.json', name: 'Swagger docs exposed' },
1444
+ { path: '/api/openapi.json', name: 'OpenAPI docs exposed' },
1445
+ { path: '/config.json', name: 'config.json exposed' },
1446
+ { path: '/debug', name: 'Debug endpoint' },
1447
+ // NEW v15
1448
+ { path: '/.DS_Store', name: '.DS_Store exposed' },
1449
+ { path: '/wp-config.php', name: 'WordPress config' },
1450
+ { path: '/package.json', name: 'package.json exposed' },
1451
+ { path: '/composer.json', name: 'composer.json exposed' },
1452
+ { path: '/.htaccess', name: '.htaccess exposed' },
1453
+ { path: '/backup.sql', name: 'SQL backup exposed' },
1454
+ { path: '/dump.sql', name: 'SQL dump exposed' },
285
1455
  ];
286
1456
  for (const s of sensitives) {
287
1457
  try {
@@ -290,11 +1460,13 @@ async function runSecurityScan(url) {
290
1460
  const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
291
1461
  clearTimeout(timer);
292
1462
  const exposed = res.status === 200;
293
- findings.push({ check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
1463
+ findings.push({
1464
+ check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
294
1465
  category: 'information-disclosure',
295
1466
  detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
296
1467
  recommendation: exposed ? `Block access to ${s.path} immediately` : null,
297
- evidence: { url: `${base}${s.path}`, status: res.status } });
1468
+ evidence: { url: `${base}${s.path}`, status: res.status },
1469
+ });
298
1470
  } catch {}
299
1471
  }
300
1472
 
@@ -302,13 +1474,13 @@ async function runSecurityScan(url) {
302
1474
  }
303
1475
 
304
1476
  // ═══════════════════════════════════════════════════════════════════════════
305
- // SEO Scanner — real HTML parsing with Googlebot UA
1477
+ // SEO Scanner v15 Extended
306
1478
  // ═══════════════════════════════════════════════════════════════════════════
307
1479
  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)' } });
1480
+ const t0 = Date.now();
1481
+ const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
310
1482
  const html = r.body || '';
311
- const rt = Date.now() - t0;
1483
+ const rt = Date.now() - t0;
312
1484
  const checks = [];
313
1485
 
314
1486
  const has = (p) => p.test(html);
@@ -316,108 +1488,81 @@ async function runSEOScan(url) {
316
1488
 
317
1489
  const title = get(/<title[^>]*>([^<]+)<\/title>/i);
318
1490
  checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
319
- detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>', data: { title, length: title?.length },
320
- recommendation: 'Add unique title (50-60 chars)' });
321
-
1491
+ detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>' });
322
1492
  if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
323
- severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)`,
324
- recommendation: 'Keep title 30-60 chars' });
1493
+ severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)` });
325
1494
 
326
1495
  const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
327
1496
  || get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
328
1497
  checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
329
- detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description',
330
- recommendation: 'Add meta description (120-160 chars)' });
1498
+ detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description' });
1499
+ if (desc) checks.push({ name: 'Description length', pass: desc.length >= 120 && desc.length <= 160,
1500
+ severity: 'P2', category: 'meta', detail: `${desc.length} chars (optimal 120-160)` });
331
1501
 
332
1502
  const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
333
1503
  checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
334
- detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : '1 H1 ✓',
335
- recommendation: 'Use exactly one H1 per page' });
1504
+ detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1s (should be 1)` : '1 H1 ✓' });
1505
+
1506
+ const h2Count = (html.match(/<h2[^>]*>/gi) || []).length;
1507
+ checks.push({ name: 'H2 tags', pass: h2Count > 0, severity: 'P3', category: 'structure',
1508
+ detail: `${h2Count} H2 tags found` });
336
1509
 
337
1510
  const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
338
- checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
339
- detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
340
- recommendation: 'Add <meta name="viewport" content="width=device-width,initial-scale=1">' });
1511
+ checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile', detail: hasVP ? 'Viewport found' : 'Missing' });
341
1512
 
342
1513
  const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
343
- checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
344
- detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
1514
+ checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo', detail: lang ? `lang="${lang}"` : 'Missing' });
345
1515
 
346
1516
  const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
347
- checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
348
- detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
349
- recommendation: 'Add <link rel="canonical">' });
1517
+ checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo', detail: canonical ? `Canonical: ${canonical}` : 'Missing' });
350
1518
 
351
1519
  const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
352
- checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
353
- detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
354
- recommendation: 'Add og:title, og:description, og:image' });
1520
+ checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social', detail: ogOk ? 'OG tags present' : 'Missing og:title/description' });
1521
+ const ogImage = has(/<meta[^>]+property=["']og:image["']/i);
1522
+ checks.push({ name: 'OG image', pass: ogImage, severity: 'P2', category: 'social', detail: ogImage ? 'og:image present' : 'Missing og:image' });
1523
+
1524
+ const twitterCard = has(/<meta[^>]+name=["']twitter:card["']/i);
1525
+ checks.push({ name: 'Twitter Card', pass: twitterCard, severity: 'P3', category: 'social', detail: twitterCard ? 'Twitter card present' : 'Missing' });
355
1526
 
356
- const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
357
- const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
1527
+ const structuredData = has(/<script[^>]+type=["']application\/ld\+json["']/i);
1528
+ checks.push({ name: 'Structured data (JSON-LD)', pass: structuredData, severity: 'P2', category: 'structured-data', detail: structuredData ? 'JSON-LD found' : 'No structured data' });
1529
+
1530
+ const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
1531
+ const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
358
1532
  checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
359
- detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
360
- recommendation: 'Add alt text to all images' });
1533
+ detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt` });
361
1534
 
362
1535
  checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
363
- category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
364
- recommendation: 'Optimize TTFB with CDN and caching' });
1536
+ category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)` });
1537
+
1538
+ // NEW v15: Heading hierarchy
1539
+ const headings = (html.match(/<h[1-6][^>]*>/gi) || []).map(h => parseInt(h[2]));
1540
+ let hierOk = true;
1541
+ for (let i = 1; i < headings.length; i++) {
1542
+ if (headings[i] > headings[i-1] + 1) { hierOk = false; break; }
1543
+ }
1544
+ checks.push({ name: 'Heading hierarchy', pass: hierOk, severity: 'P2', category: 'structure', detail: hierOk ? 'Headings in order' : 'Skipped heading levels' });
1545
+
1546
+ // NEW v15: noindex check
1547
+ const noindex = has(/<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*noindex/i);
1548
+ checks.push({ name: 'Not noindexed', pass: !noindex, severity: noindex ? 'P1' : 'INFO', category: 'crawling', detail: noindex ? 'Page is noindexed!' : 'Indexable' });
365
1549
 
366
- // robots.txt & sitemap
367
1550
  const base = new URL(url).origin;
368
1551
  for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
369
1552
  try {
370
1553
  const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
371
1554
  checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
372
- detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}`,
373
- recommendation: `Ensure ${name} exists` });
1555
+ detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}` });
374
1556
  } catch {
375
1557
  checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
376
1558
  }
377
1559
  }
378
1560
 
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
- };
1561
+ return { pass: checks.filter(c => !c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
417
1562
  }
418
1563
 
419
1564
  // ═══════════════════════════════════════════════════════════════════════════
420
- // Accessibility Scanner — real HTML analysis + axe-core hint
1565
+ // Accessibility Scanner v15 Extended
421
1566
  // ═══════════════════════════════════════════════════════════════════════════
422
1567
  async function runA11yScan(url) {
423
1568
  const r = await httpProbe(url, { timeout: 12000 });
@@ -425,20 +1570,28 @@ async function runA11yScan(url) {
425
1570
  const violations = [], passes = [];
426
1571
 
427
1572
  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' },
1573
+ { id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
1574
+ { id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
1575
+ { id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
1576
+ { id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
1577
+ { id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
1578
+ { id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
1579
+ { id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
435
1580
  { 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' },
1581
+ // NEW v15
1582
+ { id: 'skip-nav', impact: 'moderate', test: () => !/<a[^>]*href=["']#(?:main|content|skip)[^"']*["']/i.test(html), pass: 'Skip nav link', desc: 'Page should have skip navigation link' },
1583
+ { id: 'table-headers', impact: 'serious', test: () => /<table/i.test(html) && !/<th/i.test(html), pass: 'Tables have headers', desc: 'Tables must have header cells' },
1584
+ { id: 'input-purpose', impact: 'serious', test: () => /<input[^>]*type=["'](email|tel|name)[^"']*["']/i.test(html) && !/<input[^>]*autocomplete/i.test(html), pass: 'Inputs have autocomplete', desc: 'Contact inputs should have autocomplete' },
1585
+ { id: 'focus-visible', impact: 'serious', test: () => /:focus\s*\{\s*outline\s*:\s*none/i.test(html) || /:focus\s*\{\s*outline\s*:\s*0/i.test(html), pass: 'Focus indicator not removed', desc: 'CSS must not hide focus indicator' },
1586
+ { id: 'color-contrast-meta', impact: 'moderate', test: () => false, pass: 'Color contrast (check manually with browser)', desc: 'Ensure sufficient color contrast' },
1587
+ { id: 'nav-landmark', impact: 'moderate', test: () => !/<nav[^>]*>/i.test(html), pass: 'Nav landmark present', desc: 'Navigation should use <nav>' },
1588
+ { id: 'button-type', impact: 'minor', test: () => /<button(?![^>]*type=)/i.test(html), pass: 'Buttons have type', desc: 'Buttons should have explicit type attribute' },
436
1589
  ];
437
1590
 
438
1591
  for (const c of checks) {
439
1592
  if (c.test()) {
440
1593
  violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
441
- tags: ['wcag2a'], category: 'wcag2a', nodes: 1, affectedNodes: [],
1594
+ tags: ['wcag2a'], category: 'wcag2a', nodes: 1,
442
1595
  helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
443
1596
  } else {
444
1597
  passes.push({ id: c.id, description: c.pass, nodes: 1 });
@@ -446,63 +1599,68 @@ async function runA11yScan(url) {
446
1599
  }
447
1600
 
448
1601
  const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
449
- return { pass: violations.length === 0, violations, passes, incomplete: [], score, url, mode: 'http-html-analysis' };
1602
+ return { pass: violations.length === 0, violations, passes, incomplete: [], score, url };
450
1603
  }
451
1604
 
452
1605
  // ═══════════════════════════════════════════════════════════════════════════
453
- // AI Bug Classifier — local pattern matching (no external API needed)
1606
+ // AI Bug Classifier v15 Enhanced patterns
454
1607
  // ═══════════════════════════════════════════════════════════════════════════
455
1608
  const SEV_PATTERNS = {
456
- P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
457
- P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
458
- P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
459
- P3: [/warning|minor|style|typo|cosmetic/i],
1609
+ P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical|\.env.*exposed|git.*config.*exposed|database.*dump/i, /crash|fatal|500|server.*down|data.*loss|memory.*leak.*critical/i],
1610
+ P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error|mixed.*content/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious|viewport.*overflow/i],
1611
+ P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue|cookie.*missing|broken.*link/i],
1612
+ P3: [/warning|minor|style|typo|cosmetic|twitter.*card/i],
460
1613
  };
461
1614
  const CAT_PATTERNS = {
462
- security: /security|csp|hsts|cors|xss|injection|auth|token/i,
463
- performance: /lcp|fcp|cls|ttfb|slow|timeout|render/i,
464
- accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
465
- seo: /title|meta|description|canonical|sitemap|robots/i,
466
- api: /api|endpoint|status.*code|response|rest/i,
467
- javascript: /js.*error|console.*error|uncaught|undefined|null/i,
468
- network: /network|fetch|connection|request.*fail/i,
1615
+ security : /security|csp|hsts|cors|xss|injection|auth|token|cookie|env.*exposed/i,
1616
+ performance : /lcp|fcp|cls|ttfb|slow|timeout|render|memory.*leak|resource/i,
1617
+ accessibility : /wcag|a11y|aria|alt.*text|contrast|keyboard|skip.*nav|focus/i,
1618
+ seo : /title|meta|description|canonical|sitemap|robots|structured.*data/i,
1619
+ api : /api|endpoint|status.*code|response|rest|contract/i,
1620
+ javascript : /js.*error|console.*error|uncaught|undefined|null|pageerror/i,
1621
+ network : /network|fetch|connection|request.*fail|broken.*link/i,
1622
+ viewport : /viewport|responsive|mobile|overflow|horizontal.*scroll/i,
1623
+ darkMode : /dark.*mode|color.*scheme|prefers-color/i,
469
1624
  };
470
1625
  function classifyBug(bug) {
471
1626
  const text = `${bug.title} ${bug.description || ''}`;
472
1627
  let severity = bug.severity || 'P3', confidence = 0.7;
473
1628
  for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
474
- if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.85; break; }
1629
+ if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.87; break; }
475
1630
  }
476
1631
  let category = bug.type || 'general';
477
1632
  for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
478
1633
  if (pat.test(text)) { category = cat; break; }
479
1634
  }
480
1635
  const recs = {
481
- security: 'Review security config and run penetration test',
482
- performance: 'Run Lighthouse and optimize assets/server',
483
- accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
484
- seo: 'Fix meta tags and submit sitemap to Search Console',
485
- api: 'Check API contract and add proper error handling',
486
- javascript: 'Debug in browser DevTools, add error boundaries',
487
- network: 'Check CDN, server logs, network config',
1636
+ security : 'Review security config immediately and run penetration test',
1637
+ performance : 'Run Lighthouse, optimize assets, review bundle size',
1638
+ accessibility : 'Fix WCAG 2.1 AA violations with axe DevTools',
1639
+ seo : 'Fix meta tags and submit updated sitemap to Search Console',
1640
+ api : 'Check API contract, add proper error handling and typing',
1641
+ javascript : 'Debug in DevTools, add error boundaries, check for undefined refs',
1642
+ network : 'Check CDN, server logs, review network config and CORS policy',
1643
+ viewport : 'Test on real devices, fix overflow, check responsive breakpoints',
1644
+ darkMode : 'Add prefers-color-scheme media query support',
488
1645
  };
489
- return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
1646
+ return { severity, category, recommendation: recs[category] || 'Review and fix error details', confidence };
490
1647
  }
491
1648
 
492
1649
  // ═══════════════════════════════════════════════════════════════════════════
493
- // Terminal Dashboard — live real-time display
1650
+ // Terminal Dashboard v15 Enhanced live display
494
1651
  // ═══════════════════════════════════════════════════════════════════════════
495
1652
  class TerminalDashboard {
496
1653
  #session; #lines = 0; #active = false; #timer = null;
497
1654
  #phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
1655
+ #pwMode = false; #subPhase = '';
498
1656
 
499
- constructor(s) { this.#session = s; }
1657
+ constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
500
1658
 
501
1659
  start() {
502
1660
  this.#active = true; this.#startTime = Date.now();
503
1661
  process.stdout.write('\x1b[?25l');
504
1662
  this.#render();
505
- this.#timer = setInterval(() => this.#render(), 600);
1663
+ this.#timer = setInterval(() => this.#render(), 400);
506
1664
  }
507
1665
 
508
1666
  stop() {
@@ -513,19 +1671,20 @@ class TerminalDashboard {
513
1671
  this.#printFinal();
514
1672
  }
515
1673
 
516
- setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
1674
+ setPhase(p) { this.#phase = p; this.#subPhase = ''; this.log(chalk.cyan(p)); }
1675
+ setSubPhase(p) { this.#subPhase = p; }
517
1676
  setCurrentTest(t) { this.#currentTest = t; }
518
- addResult() { this.#currentTest = ''; }
1677
+ addResult() { this.#currentTest = ''; }
519
1678
  log(msg) {
520
1679
  this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
521
- if (this.#log.length > 8) this.#log.shift();
1680
+ if (this.#log.length > 10) this.#log.shift();
522
1681
  }
523
1682
 
524
1683
  #render() {
525
1684
  if (!this.#active) return;
526
1685
  this.#clear();
527
1686
  const lines = this.#build();
528
- this.#lines = lines.length;
1687
+ this.#lines = lines.length;
529
1688
  process.stdout.write(lines.join('\n') + '\n');
530
1689
  }
531
1690
 
@@ -546,77 +1705,90 @@ class TerminalDashboard {
546
1705
  const total = s.results.length;
547
1706
  const rate = total > 0 ? Math.round(passed / total * 100) : 0;
548
1707
  const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
549
- const w = Math.min(process.stdout.columns || 80, 88);
1708
+ const w = Math.min(process.stdout.columns || 90, 92);
550
1709
  const bar = '─'.repeat(w - 2);
551
1710
  const c1 = chalk.hex('#00F5FF');
552
1711
  const c2 = chalk.hex('#BF40FF');
553
1712
  const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
1713
+ const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 BACKLIST') : chalk.gray(' HTTP');
1714
+
1715
+ const spin = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'][Math.floor(Date.now() / 100) % 10];
554
1716
 
555
1717
  const pBar = (() => {
556
- const f = Math.min(Math.round(rate / 100 * 26), 26);
1718
+ const f = Math.min(Math.round(rate / 100 * 30), 30);
557
1719
  const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
558
- return col('█'.repeat(f)) + chalk.gray('░'.repeat(26 - f));
1720
+ return col('█'.repeat(f)) + chalk.gray('░'.repeat(30 - f));
559
1721
  })();
560
1722
 
561
1723
  const out = [
562
1724
  c1(`┌${bar}┐`),
563
- c1('│') + c2.bold(pad(` ⚡ BACKLIST ENTERPRISE QA v${VERSION} — REAL RUNTIME TESTING`)) + c1('│'),
1725
+ c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — ULTRA LIVE TESTING EDITION${pwTag}`)) + c1('│'),
564
1726
  c1(`├${bar}┤`),
565
1727
  c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
1728
+ this.#subPhase
1729
+ ? c1('│') + pad(` ${chalk.gray('↳')} ${chalk.gray(this.#subPhase.slice(0, w - 8))}`) + c1('│')
1730
+ : c1('│') + pad('') + c1('│'),
566
1731
  c1(`├${bar}┤`),
567
- c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
1732
+ c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.magenta('📸')} ${chalk.bold(s.screenshots.length)} ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
568
1733
  c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1('│'),
569
1734
  c1(`├${bar}┤`),
570
- c1('│') + pad(this.#currentTest ? ` ${chalk.yellow('⟳')} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Running...')}`) + c1('│'),
1735
+ c1('│') + pad(this.#currentTest ? ` ${chalk.yellow(spin)} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Scanning...')}`) + c1('│'),
571
1736
  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('│'),
1737
+ c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('Sec:')} ${chalk.white(s.secFindings.length)} ${chalk.cyan('Broken links:')} ${chalk.white(s.brokenLinks.length)} ${chalk.cyan('3rd-party:')} ${chalk.white(s.thirdPartyScripts.length)} ${chalk.cyan('Net Err:')} ${chalk.white(s.networkLog.length)}`) + c1('│'),
573
1738
  c1(`├${bar}┤`),
574
1739
  ];
575
1740
 
576
- const recent = s.results.slice(-5);
1741
+ const recent = s.results.slice(-6);
577
1742
  for (const r of recent) {
578
1743
  const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
579
- out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(12) + ']')} ${chalk.white((r.name||'').slice(0, w - 30))}`) + c1('│'));
1744
+ out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(14) + ']')} ${chalk.white((r.name||'').slice(0, w - 32))}`) + c1('│'));
580
1745
  }
581
- for (let i = recent.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
1746
+ for (let i = recent.length; i < 6; i++) out.push(c1('│') + pad('') + c1('│'));
582
1747
 
583
1748
  out.push(c1(`├${bar}┤`));
584
- for (const entry of this.#log.slice(-4)) {
1749
+ for (const entry of this.#log.slice(-5)) {
585
1750
  out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
586
1751
  }
587
- for (let i = this.#log.length; i < 4; i++) out.push(c1('│') + pad('') + c1('│'));
1752
+ for (let i = this.#log.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
588
1753
  out.push(c1(`└${bar}┘`));
589
- out.push(chalk.dim(` Real runtime data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
1754
+ out.push(chalk.dim(` v${VERSION} · ${total} tests · ${s.bugs.length} bugs · ${s.screenshots.length} screenshots · Ctrl+C to stop`));
590
1755
 
591
1756
  return out;
592
1757
  }
593
1758
 
594
1759
  #printFinal() {
595
- const s = this.#session.getSummary();
1760
+ const s = this.#session.getSummary();
596
1761
  const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
597
1762
  console.log('');
598
- console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
599
- console.log(` Tests: ${chalk.white.bold(s.total)}`);
600
- console.log(` Passed: ${chalk.green.bold(s.passed)}`);
601
- console.log(` Failed: ${chalk.red.bold(s.failed)}`);
602
- console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
603
- console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
604
- console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
605
- console.log(` Routes: ${chalk.white(this.#session.routeMap.length)} discovered`);
1763
+ console.log(chalk.hex('#00F5FF').bold(' ── QA Complete (v15) ───────────────────────────────────────'));
1764
+ console.log(` Tests: ${chalk.white.bold(s.total)}`);
1765
+ console.log(` Passed: ${chalk.green.bold(s.passed)}`);
1766
+ console.log(` Failed: ${chalk.red.bold(s.failed)}`);
1767
+ console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
1768
+ console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
1769
+ console.log(` Screenshots: ${chalk.magenta.bold(this.#session.screenshots.length)} (${Object.keys(VIEWPORTS).length} viewports)`);
1770
+ console.log(` Broken Links:${chalk.white(this.#session.brokenLinks.length)}`);
1771
+ console.log(` 3rd Party: ${chalk.white(this.#session.thirdPartyScripts.length)} scripts`);
1772
+ console.log(` Load Test: ${this.#session.loadTestResults.length > 0 ? chalk.white(this.#session.loadTestResults[0].rps + ' req/s') : chalk.gray('not run')}`);
1773
+ console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
1774
+ console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Backlist (Real Browser)') : chalk.gray('HTTP-only')}`);
606
1775
  console.log('');
607
1776
  }
608
1777
  }
609
1778
 
610
1779
  // ═══════════════════════════════════════════════════════════════════════════
611
- // HTML Report Builder — stunning dark theme, 100% real data
1780
+ // HTML Report Builder v15 Ultra Rich
612
1781
  // ═══════════════════════════════════════════════════════════════════════════
613
1782
  function buildHTMLReport(session) {
614
- const summary = session.getSummary();
615
- const passRate = Number(summary.passRate);
616
- const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
1783
+ const summary = session.getSummary();
1784
+ const passRate = Number(summary.passRate);
1785
+ const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
617
1786
 
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]++; });
1787
+ const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
1788
+ session.bugs.forEach(b => {
1789
+ const key = b.aiSeverity || b.severity;
1790
+ if (sevCounts[key] !== undefined) sevCounts[key]++;
1791
+ });
620
1792
 
621
1793
  const coverage = {};
622
1794
  for (const r of session.results) {
@@ -627,6 +1799,39 @@ function buildHTMLReport(session) {
627
1799
 
628
1800
  const esc = (s) => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
629
1801
 
1802
+ // ── Screenshot gallery ───────────────────────────────────────────────────
1803
+ const screenshotsByType = {};
1804
+ for (const sc of session.screenshots) {
1805
+ const t = sc.type || 'other';
1806
+ if (!screenshotsByType[t]) screenshotsByType[t] = [];
1807
+ screenshotsByType[t].push(sc);
1808
+ }
1809
+
1810
+ const screenshotCards = session.screenshots.length
1811
+ ? session.screenshots.map(sc => {
1812
+ let imgTag = '';
1813
+ try {
1814
+ const data = fs.readFileSync(sc.path);
1815
+ imgTag = `<img src="data:image/png;base64,${data.toString('base64')}" alt="${esc(sc.type)}" loading="lazy">`;
1816
+ } catch {
1817
+ imgTag = `<div class="no-img">📸 ${esc(sc.name)}</div>`;
1818
+ }
1819
+ const typeColors = {
1820
+ desktop: '#00f5ff', mobile: '#bf40ff', 'dark-mode': '#f59e0b',
1821
+ };
1822
+ const typeColor = Object.entries(typeColors).find(([k]) => (sc.type||'').includes(k))?.[1] || '#64748b';
1823
+ return `<div class="screenshot-card">
1824
+ <div class="sc-header" style="border-bottom-color:${typeColor}33">
1825
+ <span class="sc-type" style="background:${typeColor}22;color:${typeColor}">${esc(sc.label || sc.type)}</span>
1826
+ <span class="sc-url">${esc((sc.url || '').split('/').slice(0,4).join('/'))}</span>
1827
+ </div>
1828
+ <div class="sc-img-wrap">${imgTag}</div>
1829
+ <div class="sc-path">${esc(sc.name)}</div>
1830
+ </div>`;
1831
+ }).join('')
1832
+ : '<p class="no-data">No screenshots (Playwright not available)</p>';
1833
+
1834
+ // ── Test rows ─────────────────────────────────────────────────────────────
630
1835
  const testRows = session.results.map(r => `
631
1836
  <tr class="result-row" data-type="${r.type}" data-status="${r.status}">
632
1837
  <td>${esc(r.name)}</td>
@@ -637,29 +1842,35 @@ function buildHTMLReport(session) {
637
1842
  <td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
638
1843
  </tr>`).join('');
639
1844
 
640
- const bugCards = session.bugs.length ? session.bugs.map(b => `
641
- <div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">
1845
+ // ── Bug cards ─────────────────────────────────────────────────────────────
1846
+ const bugCards = session.bugs.length
1847
+ ? session.bugs.map(b => `
1848
+ <div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}" data-severity="${b.aiSeverity||b.severity}">
642
1849
  <div class="bug-header">
643
1850
  <span class="bug-id">${esc(b.id)}</span>
644
1851
  <span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
645
- <span class="badge">${b.type||'general'}</span>
1852
+ <span class="badge">${b.aiCategory||b.type||'general'}</span>
646
1853
  ${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
647
1854
  </div>
648
1855
  <div class="bug-title">${esc(b.title)}</div>
649
1856
  ${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
650
1857
  ${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
651
1858
  ${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>';
1859
+ </div>`).join('')
1860
+ : '<p class="no-data">No bugs detected 🎉</p>';
653
1861
 
1862
+ // ── Route rows ────────────────────────────────────────────────────────────
654
1863
  const routeRows = session.routeMap.map(r => `
655
1864
  <tr>
656
1865
  <td><code class="url">${esc(r.url)}</code></td>
657
1866
  <td><span class="badge">${r.type}</span></td>
658
1867
  <td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
1868
+ <td>${r.responseTime ? `${r.responseTime}ms` : '–'}</td>
659
1869
  <td>${r.forms?.length || 0}</td>
660
1870
  <td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
661
1871
  </tr>`).join('');
662
1872
 
1873
+ // ── Security rows ─────────────────────────────────────────────────────────
663
1874
  const secRows = session.secFindings.map(f => `
664
1875
  <tr class="${f.pass ? '' : 'fail-row'}">
665
1876
  <td>${esc(f.check)}</td>
@@ -670,52 +1881,39 @@ function buildHTMLReport(session) {
670
1881
  <td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
671
1882
  </tr>`).join('');
672
1883
 
1884
+ // ── SEO section ───────────────────────────────────────────────────────────
673
1885
  const seoSection = session.seoResults.map(r => `
674
1886
  <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>
1887
+ <div class="seo-header">
1888
+ <a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
1889
+ <span>${r.checks.filter(c=>c.pass).length}/${r.checks.length} passed</span>
1890
+ </div>
677
1891
  <table>
678
1892
  <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>
1893
+ <tbody>${(r.checks||[]).map(c => `<tr>
1894
+ <td>${esc(c.name)}</td><td>${c.category||'–'}</td>
680
1895
  <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>
1896
+ <td>${esc((c.detail||'').slice(0,100))}</td>
1897
+ </tr>`).join('')}</tbody>
682
1898
  </table>
683
1899
  </div>`).join('') || '<p class="no-data">No SEO scans</p>';
684
1900
 
1901
+ // ── A11y section ──────────────────────────────────────────────────────────
685
1902
  const a11ySection = session.a11yResults.map(r => `
686
1903
  <div class="a11y-page">
687
- <div class="a11y-header"><a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
1904
+ <div class="a11y-header">
1905
+ <a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
688
1906
  <span>Score: <strong>${r.score??'–'}%</strong></span>
689
- <span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span></div>
690
- ${(r.violations||[]).map(v => `
691
- <div class="violation impact-${v.impact}">
692
- <div class="violation-header"><span class="impact-badge">${v.impact}</span>
693
- <strong>${esc(v.description)}</strong></div>
694
- <p>${esc(v.help)}</p>
695
- </div>`).join('') || '<p class="no-data">No violations ✓</p>'}
1907
+ <span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
1908
+ </div>
1909
+ ${(r.violations||[]).map(v => `<div class="violation impact-${v.impact}">
1910
+ <div class="violation-header"><span class="impact-badge">${v.impact}</span><strong>${esc(v.description)}</strong></div>
1911
+ <p>${esc(v.help)}</p>
1912
+ </div>`).join('') || '<p class="no-data">No violations ✓</p>'}
696
1913
  </div>`).join('') || '<p class="no-data">No accessibility scans</p>';
697
1914
 
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) {
1915
+ // ── Performance section ───────────────────────────────────────────────────
1916
+ const vitalCard = (name, value, threshold, unit) => {
719
1917
  const na = value === null || value === undefined;
720
1918
  const pass2 = !na && value <= threshold;
721
1919
  const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
@@ -726,22 +1924,284 @@ function buildHTMLReport(session) {
726
1924
  <div class="vital-value" style="color:${color}">${disp}</div>
727
1925
  <div class="vital-threshold">≤${threshold}${unit}</div>
728
1926
  </div>`;
729
- }
1927
+ };
1928
+
1929
+ const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => {
1930
+ const domChecksHtml = m.domChecks?.length ? `
1931
+ <h4 style="color:#94a3b8;margin-top:1.5rem">DOM Checks</h4>
1932
+ <table><thead><tr><th>Check</th><th>Status</th><th>Value</th></tr></thead>
1933
+ <tbody>${m.domChecks.map(c => `<tr><td>${esc(c.name)}</td>
1934
+ <td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
1935
+ <td>${esc(c.value||'')}</td></tr>`).join('')}</tbody></table>` : '';
1936
+
1937
+ const interactionsHtml = m.interactions?.length ? `
1938
+ <h4 style="color:#94a3b8;margin-top:1.5rem">Interaction Tests</h4>
1939
+ <table><thead><tr><th>Test</th><th>Status</th><th>Value</th></tr></thead>
1940
+ <tbody>${m.interactions.map(i => `<tr><td>${esc(i.name)}</td>
1941
+ <td><span class="status ${i.pass?'status-pass':'status-fail'}">${i.pass?'PASS':'FAIL'}</span></td>
1942
+ <td>${esc(i.value||'')}</td></tr>`).join('')}</tbody></table>` : '';
1943
+
1944
+ const resourceHtml = m.resourceStats?.byType ? `
1945
+ <h4 style="color:#94a3b8;margin-top:1.5rem">Resource Breakdown</h4>
1946
+ <table><thead><tr><th>Type</th><th>Count</th><th>Total Size</th><th>Total Time</th></tr></thead>
1947
+ <tbody>${Object.entries(m.resourceStats.byType).map(([t, d]) => `<tr>
1948
+ <td><span class="badge">${esc(t)}</span></td><td>${d.count}</td>
1949
+ <td>${formatBytes(d.size)}</td><td>${Math.round(d.time)}ms</td></tr>`).join('')}</tbody></table>` : '';
1950
+
1951
+ return `<div class="perf-card">
1952
+ <h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Real Vitals</span>' : ''}</h3>
1953
+ <div class="vitals-grid">
1954
+ ${vitalCard('TTFB', m.ttfb, 800, 'ms')}
1955
+ ${vitalCard('LCP', m.lcp, 2500, 'ms')}
1956
+ ${vitalCard('FCP', m.fcp, 1800, 'ms')}
1957
+ ${vitalCard('CLS', m.cls, 0.1, '')}
1958
+ ${vitalCard('TBT', m.tbt, 200, 'ms')}
1959
+ ${vitalCard('DOM Load', m.domLoad, 3000, 'ms')}
1960
+ ${vitalCard('DNS', m.dnsLookup, 100, 'ms')}
1961
+ ${vitalCard('TCP', m.tcpConnect, 200, 'ms')}
1962
+ </div>
1963
+ ${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
1964
+ ${resourceHtml}${domChecksHtml}${interactionsHtml}
1965
+ </div>`;
1966
+ }).join('') || '<p class="no-data">No performance data</p>';
1967
+
1968
+ // ── NEW v15: Load Test section ────────────────────────────────────────────
1969
+ const loadTestSection = session.loadTestResults.length
1970
+ ? session.loadTestResults.map(lt => `
1971
+ <div class="load-test-card ${lt.passed ? 'lt-pass' : 'lt-fail'}">
1972
+ <h3>${esc(lt.url)} <span class="badge">${lt.concurrency} concurrent</span></h3>
1973
+ <div class="vitals-grid">
1974
+ ${vitalCard('RPS', lt.rps, 10, '')}
1975
+ ${vitalCard('Avg Lat', lt.latency?.avg, 500, 'ms')}
1976
+ ${vitalCard('p95', lt.latency?.p95, 2000, 'ms')}
1977
+ ${vitalCard('p99', lt.latency?.p99, 5000, 'ms')}
1978
+ ${vitalCard('Error%', lt.errorRate, 5, '%')}
1979
+ </div>
1980
+ <p style="color:#94a3b8;font-size:.8rem;margin-top:.75rem">${lt.requests} requests · ${lt.errors} errors · ${lt.timeouts} timeouts · ${formatDuration(lt.duration)}</p>
1981
+ <p style="color:#94a3b8;font-size:.78rem">Status codes: ${Object.entries(lt.responses||{}).map(([k,v]) => `${k}: ${v}`).join(', ')}</p>
1982
+ </div>`).join('')
1983
+ : '<p class="no-data">Load test not run (use runUrlQA with loadTest:true)</p>';
1984
+
1985
+ // ── NEW v15: Viewport section ─────────────────────────────────────────────
1986
+ const vpSection = Object.keys(session.viewportResults || {}).length
1987
+ ? `<div class="viewport-grid">${Object.entries(session.viewportResults).map(([key, vp]) => `
1988
+ <div class="vp-card ${vp.passed ? '' : 'vp-fail'}">
1989
+ <div class="vp-label">${esc(vp.label || key)}</div>
1990
+ <div class="vp-dims">${vp.width}×${vp.height}</div>
1991
+ <div class="vp-status"><span class="status ${vp.passed?'status-pass':'status-fail'}">${vp.passed?'PASS':'FAIL'}</span></div>
1992
+ ${(vp.issues||[]).map(i => `<div class="vp-issue">⚠ ${esc(i)}</div>`).join('')}
1993
+ </div>`).join('')}</div>`
1994
+ : '<p class="no-data">No viewport tests (Playwright required)</p>';
1995
+
1996
+ // ── NEW v15: Broken links section ─────────────────────────────────────────
1997
+ const brokenLinksSection = session.brokenLinks.length
1998
+ ? session.brokenLinks.map(bl => `
1999
+ <div class="card" style="margin-bottom:1rem">
2000
+ <div class="card-title">Broken Links on ${esc(bl.sourceUrl)} <span>${bl.broken}/${bl.total} broken</span></div>
2001
+ ${bl.links?.filter(l => !l.ok).length ? `<table>
2002
+ <thead><tr><th>URL</th><th>Status</th><th>Error</th></tr></thead>
2003
+ <tbody>${bl.links.filter(l => !l.ok).map(l => `<tr>
2004
+ <td class="url">${esc(l.url)}</td>
2005
+ <td class="fail">${l.status || 'timeout'}</td>
2006
+ <td class="fail">${esc(l.error || '')}</td>
2007
+ </tr>`).join('')}</tbody>
2008
+ </table>` : '<p class="no-data">No broken links 🎉</p>'}
2009
+ </div>`).join('')
2010
+ : '<p class="no-data">No broken link scans run</p>';
2011
+
2012
+ // ── NEW v15: Cookie audit ─────────────────────────────────────────────────
2013
+ const cookieSection = session.cookieAudit.length
2014
+ ? session.cookieAudit.map(ca => `
2015
+ <div class="card" style="margin-bottom:1rem">
2016
+ <div class="card-title">Cookies for ${esc(ca.url)} <span>${ca.total} cookies · ${ca.issues} with issues</span></div>
2017
+ ${ca.cookies?.length ? `<table>
2018
+ <thead><tr><th>Name</th><th>Secure</th><th>HttpOnly</th><th>SameSite</th><th>Issues</th></tr></thead>
2019
+ <tbody>${ca.cookies.map(c => `<tr class="${c.issues.length ? 'fail-row' : ''}">
2020
+ <td><code>${esc(c.name)}</code></td>
2021
+ <td>${c.secure ? '✓' : '<span class="fail">✗</span>'}</td>
2022
+ <td>${c.httpOnly ? '✓' : '<span class="fail">✗</span>'}</td>
2023
+ <td>${c.sameSite || '<span class="fail">missing</span>'}</td>
2024
+ <td>${c.issues.map(i => `<span class="sev sev-p2">${esc(i)}</span>`).join(' ')}</td>
2025
+ </tr>`).join('')}</tbody>
2026
+ </table>` : '<p class="no-data">No cookies set</p>'}
2027
+ </div>`).join('')
2028
+ : '<p class="no-data">No cookie audits</p>';
2029
+
2030
+ // ── NEW v15: Third-party scripts ──────────────────────────────────────────
2031
+ const thirdPartySection = session.thirdPartyScripts.length
2032
+ ? `<table>
2033
+ <thead><tr><th>Vendor</th><th>Domain</th><th>Count</th><th>URL</th></tr></thead>
2034
+ <tbody>${session.thirdPartyScripts.map(s => `<tr>
2035
+ <td><strong>${esc(s.vendor)}</strong></td>
2036
+ <td>${esc(s.domain)}</td>
2037
+ <td>${s.count || 1}</td>
2038
+ <td class="url">${esc((s.url||'').slice(0, 80))}</td>
2039
+ </tr>`).join('')}</tbody>
2040
+ </table>`
2041
+ : '<p class="no-data">No third-party scripts detected</p>';
2042
+
2043
+ // ── NEW v15: User Flow section ────────────────────────────────────────────
2044
+ const userFlowSection = session.userFlowResults.length
2045
+ ? session.userFlowResults.map(f => `
2046
+ <div class="card" style="margin-bottom:1rem">
2047
+ <div class="card-title">User Flow: ${esc(f.url)} <span>${f.passed}/${f.steps.length} steps · ${f.passRate}%</span></div>
2048
+ <table>
2049
+ <thead><tr><th>Step</th><th>Status</th><th>Duration</th><th>Error</th></tr></thead>
2050
+ <tbody>${f.steps.map(s => `<tr>
2051
+ <td>${esc(s.name)}</td>
2052
+ <td><span class="status ${s.pass?'status-pass':'status-fail'}">${s.pass?'PASS':'FAIL'}</span></td>
2053
+ <td>${s.duration}ms</td>
2054
+ <td>${s.error ? `<span class="fail">${esc(s.error)}</span>` : '–'}</td>
2055
+ </tr>`).join('')}</tbody>
2056
+ </table>
2057
+ </div>`).join('')
2058
+ : '<p class="no-data">No user flow simulations</p>';
2059
+
2060
+ // ── NEW v15: Memory section ───────────────────────────────────────────────
2061
+ const memorySection = session.memorySnapshots.length
2062
+ ? session.memorySnapshots.map(m => `
2063
+ <div class="card" style="margin-bottom:1rem">
2064
+ <div class="card-title">Memory Analysis: ${esc(m.url)}</div>
2065
+ ${m.note ? `<p class="perf-note">${esc(m.note)}</p>` : ''}
2066
+ <div class="vitals-grid">
2067
+ ${vitalCard('Growth', m.growthMB, 5, 'MB')}
2068
+ ${m.snapshots[0] ? vitalCard('Init Heap', Math.round(m.snapshots[0].used/1024/1024), 50, 'MB') : ''}
2069
+ ${m.snapshots[1] ? vitalCard('After Nav', Math.round(m.snapshots[1].used/1024/1024), 60, 'MB') : ''}
2070
+ </div>
2071
+ ${m.hasLeak ? `<div class="bug-rec" style="margin-top:.75rem">⚠️ Possible memory leak: +${m.growthMB}MB after navigation. Check for event listener leaks and detached DOM nodes.</div>` : '<p style="color:#22c55e;margin-top:.75rem">✓ No significant memory growth detected</p>'}
2072
+ </div>`).join('')
2073
+ : '<p class="no-data">No memory tests (Playwright required)</p>';
2074
+
2075
+ // ── NEW v15: Dark mode section ────────────────────────────────────────────
2076
+ const darkModeSection = session.darkModeResults.length
2077
+ ? session.darkModeResults.map(dm => `
2078
+ <div class="card" style="margin-bottom:1rem">
2079
+ <div class="card-title">Dark Mode: ${esc(dm.url)}</div>
2080
+ <div class="metrics" style="grid-template-columns:repeat(3,1fr)">
2081
+ <div class="mc"><div class="ml">Supports Dark Mode</div><div class="mv" style="font-size:1.2rem;color:${dm.supportsDark?'#22c55e':'#ef4444'}">${dm.supportsDark ? '✓ Yes' : '✗ No'}</div></div>
2082
+ <div class="mc"><div class="ml">Media Query Found</div><div class="mv" style="font-size:1.2rem;color:${dm.hasMediaQuery?'#22c55e':'#64748b'}">${dm.hasMediaQuery ? '✓' : '–'}</div></div>
2083
+ <div class="mc"><div class="ml">Background Changes</div><div class="mv" style="font-size:1.2rem;color:${dm.differentFromLight?'#22c55e':'#64748b'}">${dm.differentFromLight ? '✓' : '–'}</div></div>
2084
+ </div>
2085
+ ${!dm.supportsDark ? `<div class="bug-rec">💡 Consider adding dark mode with <code>@media (prefers-color-scheme: dark)</code></div>` : ''}
2086
+ </div>`).join('')
2087
+ : '<p class="no-data">No dark mode tests (Playwright required)</p>';
2088
+
2089
+ // ── NEW v15: Redirect chains section ─────────────────────────────────────
2090
+ const redirectSection = session.redirectChains.length
2091
+ ? session.redirectChains.map(rc => `
2092
+ <div class="card" style="margin-bottom:1rem">
2093
+ <div class="card-title">Redirect Chain: ${esc(rc.url)} <span>${rc.hops} hop${rc.hops !== 1 ? 's' : ''}</span></div>
2094
+ ${rc.hasRedirectLoop ? '<div class="bug-rec" style="background:rgba(239,68,68,.1)">🔴 Redirect loop detected!</div>' : ''}
2095
+ ${rc.isHTTPtoHTTPS ? '<p style="color:#22c55e;margin-bottom:.5rem">✓ HTTP → HTTPS redirect in place</p>' : ''}
2096
+ <table>
2097
+ <thead><tr><th>URL</th><th>Status</th><th>Location</th></tr></thead>
2098
+ <tbody>${rc.chain.map(c => `<tr>
2099
+ <td class="url">${esc(c.url)}</td>
2100
+ <td class="${c.status >= 300 && c.status < 400 ? 'status-flaky' : c.status >= 400 ? 'fail' : 'pass'}">${c.status}</td>
2101
+ <td class="url">${esc(c.location || '–')}</td>
2102
+ </tr>`).join('')}</tbody>
2103
+ </table>
2104
+ </div>`).join('')
2105
+ : '<p class="no-data">No redirect chains analyzed</p>';
2106
+
2107
+ // ── Form tests section ────────────────────────────────────────────────────
2108
+ const formTestSection = session.formTests.length
2109
+ ? session.formTests.map(f => `
2110
+ <div class="card" style="margin-bottom:1rem">
2111
+ <div class="card-title">Form #${f.formIndex + 1} — ${esc(f.url)}</div>
2112
+ <div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:.75rem">
2113
+ <span class="badge">${f.method} ${f.action || 'self'}</span>
2114
+ <span>${f.inputCount} inputs</span>
2115
+ <span class="${f.hasSubmit ? 'pass' : 'fail'}">${f.hasSubmit ? '✓ Has submit' : '✗ No submit'}</span>
2116
+ <span>Label coverage: ${f.labelCoverage}%</span>
2117
+ </div>
2118
+ ${(f.issues||[]).map(i => `<div class="vp-issue">⚠ ${esc(i)}</div>`).join('')}
2119
+ </div>`).join('')
2120
+ : '<p class="no-data">No forms found or Playwright not available</p>';
2121
+
2122
+ // ── Cache headers section ─────────────────────────────────────────────────
2123
+ const cacheSection = session.cacheHeaders.length
2124
+ ? `<table>
2125
+ <thead><tr><th>URL</th><th>Cache-Control</th><th>ETag</th><th>Max-Age</th><th>CDN Cache</th><th>Status</th></tr></thead>
2126
+ <tbody>${session.cacheHeaders.map(c => `<tr>
2127
+ <td class="url">${esc(c.url)}</td>
2128
+ <td><code style="font-size:.7rem">${esc((c.cacheControl||'none').slice(0,50))}</code></td>
2129
+ <td>${c.etag ? '✓' : '–'}</td>
2130
+ <td>${c.maxAge ? `${c.maxAge}s` : '–'}</td>
2131
+ <td>${esc(c.xCache || '–')}</td>
2132
+ <td><span class="status ${c.passed?'status-pass':'status-fail'}">${c.passed?'PASS':'FAIL'}</span></td>
2133
+ </tr>`).join('')}</tbody>
2134
+ </table>`
2135
+ : '<p class="no-data">No cache audits</p>';
2136
+
2137
+ // ── Error pages section ───────────────────────────────────────────────────
2138
+ const errorPageSection = session.errorPageTests.length
2139
+ ? `<table>
2140
+ <thead><tr><th>Test</th><th>Actual Status</th><th>Custom Page</th><th>Status</th></tr></thead>
2141
+ <tbody>${session.errorPageTests.map(e => `<tr>
2142
+ <td>${esc(e.name)}</td>
2143
+ <td class="${e.isCorrectStatus ? 'pass' : 'fail'}">${e.actualStatus}</td>
2144
+ <td>${e.hasCustomPage ? '✓' : '–'}</td>
2145
+ <td><span class="status ${e.passed?'status-pass':'status-fail'}">${e.passed?'PASS':'FAIL'}</span></td>
2146
+ </tr>`).join('')}</tbody>
2147
+ </table>`
2148
+ : '<p class="no-data">No error page tests</p>';
2149
+
2150
+ // ── Console errors table ──────────────────────────────────────────────────
2151
+ const consoleSection = session.consoleErrors.length
2152
+ ? `<table>
2153
+ <thead><tr><th>Type</th><th>Message</th><th>URL</th></tr></thead>
2154
+ <tbody>${session.consoleErrors.slice(0, 100).map(e => `<tr>
2155
+ <td><span class="badge">${esc(e.type)}</span></td>
2156
+ <td>${esc(e.text?.slice(0, 200) || '')}</td>
2157
+ <td class="url">${esc(e.url || '')}</td>
2158
+ </tr>`).join('')}</tbody>
2159
+ </table>`
2160
+ : '<p class="no-data">No console errors 🎉</p>';
2161
+
2162
+ // ── Network failures table ────────────────────────────────────────────────
2163
+ const networkSection = session.networkLog.length
2164
+ ? `<table>
2165
+ <thead><tr><th>URL</th><th>Method</th><th>Failure</th></tr></thead>
2166
+ <tbody>${session.networkLog.slice(0, 100).map(e => `<tr>
2167
+ <td class="url">${esc(e.url || '')}</td>
2168
+ <td>${esc(e.method || '')}</td>
2169
+ <td class="fail">${esc(e.failure || e.error || `HTTP ${e.status}`)}</td>
2170
+ </tr>`).join('')}</tbody>
2171
+ </table>`
2172
+ : '<p class="no-data">No network failures 🎉</p>';
2173
+
2174
+ // Mixed content
2175
+ const mixedContentSection = session.mixedContentIssues.length
2176
+ ? `<table>
2177
+ <thead><tr><th>URL</th><th>Issue</th></tr></thead>
2178
+ <tbody>${session.mixedContentIssues.map(m => `<tr>
2179
+ <td class="url">${esc(m.url)}</td>
2180
+ <td class="fail">${esc(m.text?.slice(0, 200))}</td>
2181
+ </tr>`).join('')}</tbody>
2182
+ </table>`
2183
+ : '<p class="no-data">No mixed content issues 🎉</p>';
730
2184
 
731
2185
  const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
732
2186
  .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
2187
 
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]);
2188
+ const chartTypes = JSON.stringify(Object.keys(coverage));
2189
+ const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
2190
+ const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
2191
+ const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
2192
+ const pwBadge = session.playwrightMode
2193
+ ? '<span style="background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44;padding:3px 12px;border-radius:20px;font-size:.7rem">🎭 Backlist Real Browser</span>'
2194
+ : '<span style="background:#1e293b;color:#64748b;padding:3px 12px;border-radius:20px;font-size:.7rem">HTTP-only</span>';
2195
+
2196
+ const vpCount = Object.keys(session.viewportResults || {}).length;
2197
+ const brokenCount = session.brokenLinks.reduce((a, bl) => a + (bl.broken || 0), 0);
738
2198
 
739
2199
  return `<!DOCTYPE html>
740
2200
  <html lang="en">
741
2201
  <head>
742
2202
  <meta charset="UTF-8">
743
2203
  <meta name="viewport" content="width=device-width,initial-scale=1">
744
- <title>Backlist QA Report — ${esc(session.id)}</title>
2204
+ <title>Backlist QA v15 Report — ${esc(session.id)}</title>
745
2205
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
746
2206
  <style>
747
2207
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
@@ -750,103 +2210,144 @@ function buildHTMLReport(session) {
750
2210
  body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
751
2211
  a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
752
2212
  header{background:linear-gradient(135deg,#0a0a1a,#12122a);border-bottom:1px solid #00f5ff22;padding:1.5rem 2rem;display:flex;justify-content:space-between;align-items:flex-start;position:sticky;top:0;z-index:100;backdrop-filter:blur(10px)}
753
- .logo{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,var(--cyan),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
754
- .header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
755
- .version-badge{font-size:.7rem;padding:3px 10px;border-radius:20px;border:1px solid var(--purple);color:var(--purple)}
756
- nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
757
- .nav-tab{padding:.75rem 1.25rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.82rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s;font-family:'Syne',sans-serif}
2213
+ .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}
2214
+ .header-meta{font-family:'JetBrains Mono',monospace;font-size:.72rem;color:var(--dim);margin-top:.3rem}
2215
+ nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 1rem;display:flex;overflow-x:auto;gap:0;scrollbar-width:none}
2216
+ nav::-webkit-scrollbar{display:none}
2217
+ .nav-tab{padding:.65rem 1rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.78rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s;font-family:'Syne',sans-serif}
758
2218
  .nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
759
2219
  .container{max-width:1400px;margin:0 auto;padding:2rem}
760
2220
  .tab-panel{display:none}.tab-panel.active{display:block}
761
- .real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
762
- .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
763
- .mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
2221
+ .pw-banner{background:rgba(191,64,255,.08);border:1px solid #bf40ff44;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.82rem;color:#c084fc;display:flex;align-items:center;gap:.5rem}
2222
+ .real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.82rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
2223
+ .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.6rem;margin-bottom:1.5rem}
2224
+ .mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:.9rem;transition:.2s;cursor:default}
764
2225
  .mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
765
- .ml{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
766
- .mv{font-size:1.8rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
2226
+ .ml{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
2227
+ .mv{font-size:1.7rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
767
2228
  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
768
2229
  .card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
769
- .card-title{font-size:.9rem;font-weight:700;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
770
- .chart-wrap{position:relative;height:240px}
771
- .search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
772
- .search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.83rem;flex:1;font-family:'Syne',sans-serif}
773
- table{width:100%;border-collapse:collapse;font-size:.8rem}
774
- th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em}
775
- td{padding:.45rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
2230
+ .card-title{font-size:.88rem;font-weight:700;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
2231
+ .chart-wrap{position:relative;height:220px}
2232
+ .search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem;flex-wrap:wrap}
2233
+ .search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.82rem;flex:1;font-family:'Syne',sans-serif;min-width:120px}
2234
+ table{width:100%;border-collapse:collapse;font-size:.78rem}
2235
+ th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
2236
+ td{padding:.4rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
776
2237
  tr.fail-row td{background:rgba(239,68,68,.04)}
777
2238
  .pass{color:var(--green)}.fail{color:var(--red)}
778
- .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace}
2239
+ .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.68rem;font-weight:700;font-family:'JetBrains Mono',monospace}
779
2240
  .status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}.status-flaky{background:#422006;color:#fbbf24}.status-skip{background:#1e293b;color:#94a3b8}
780
- .sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
781
- .sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
782
- .badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1e293b;color:#94a3b8}
783
- .url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
784
- code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
785
- pre{white-space:pre-wrap;word-break:break-all;font-size:.73rem;padding:.75rem;background:#080814;border-radius:6px;overflow-x:auto;max-height:300px;font-family:'JetBrains Mono',monospace}
786
- details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:none}
2241
+ .sev{padding:2px 7px;border-radius:3px;font-size:.68rem;font-weight:800}
2242
+ .sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}.sev-info{background:#1e293b;color:#64748b}
2243
+ .badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.68rem;background:#1e293b;color:#94a3b8}
2244
+ .pw-badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.68rem;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
2245
+ .url{font-family:'JetBrains Mono',monospace;font-size:.72rem;color:var(--cyan);word-break:break-all}
2246
+ code{font-family:'JetBrains Mono',monospace;font-size:.72rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
2247
+ pre{white-space:pre-wrap;word-break:break-all;font-size:.7rem;padding:.75rem;background:#080814;border-radius:6px;overflow-x:auto;max-height:300px;font-family:'JetBrains Mono',monospace}
2248
+ details summary{cursor:pointer;color:var(--cyan);font-size:.76rem;user-select:none}
787
2249
  .bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
788
2250
  .bug-card:hover{border-left-color:var(--cyan)}
789
2251
  .sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
790
2252
  .sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
791
2253
  .sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
792
2254
  .bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
793
- .bug-id{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim)}
2255
+ .bug-id{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--dim)}
794
2256
  .bug-title{font-weight:700;margin-bottom:.3rem}
795
- .bug-url{font-size:.75rem;margin-bottom:.3rem}
796
- .bug-rec{font-size:.78rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
797
- .ai-badge{font-size:.68rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
798
- .rec{font-size:.75rem;color:#86efac}
2257
+ .bug-url{font-size:.73rem;margin-bottom:.3rem}
2258
+ .bug-rec{font-size:.76rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
2259
+ .ai-badge{font-size:.67rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
2260
+ .rec{font-size:.73rem;color:#86efac}
799
2261
  .no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
800
2262
  .url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
801
- .url-label{font-size:.7rem;color:var(--dim);text-transform:uppercase;min-width:90px}
802
- .vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
803
- .vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
804
- .vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
805
- .vital-label{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
806
- .vital-threshold{font-size:.68rem;color:var(--dim);margin-top:2px}
2263
+ .url-label{font-size:.68rem;color:var(--dim);text-transform:uppercase;min-width:90px}
2264
+ .screenshot-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem;margin-top:1rem}
2265
+ .screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:.2s}
2266
+ .screenshot-card:hover{border-color:var(--purple);transform:translateY(-2px);box-shadow:0 8px 24px rgba(191,64,255,.12)}
2267
+ .sc-header{display:flex;justify-content:space-between;align-items:center;padding:.65rem 1rem;border-bottom:2px solid transparent}
2268
+ .sc-type{font-size:.68rem;padding:2px 8px;border-radius:4px;text-transform:uppercase;font-weight:700}
2269
+ .sc-url{font-size:.7rem;color:var(--dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;max-width:200px}
2270
+ .sc-img-wrap{background:#000;min-height:160px;display:flex;align-items:center;justify-content:center;overflow:hidden}
2271
+ .sc-img-wrap img{width:100%;height:auto;display:block;max-height:350px;object-fit:cover}
2272
+ .no-img{color:var(--dim);font-style:italic;padding:2rem;text-align:center}
2273
+ .sc-path{font-family:'JetBrains Mono',monospace;font-size:.65rem;color:var(--dim);padding:.4rem 1rem;background:#080810}
2274
+ .vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:.65rem;margin:.75rem 0}
2275
+ .vital-card{border-radius:8px;padding:.875rem;text-align:center;border:1px solid var(--border)}
2276
+ .vital-value{font-size:1.4rem;font-weight:800;margin:.2rem 0;font-family:'JetBrains Mono',monospace}
2277
+ .vital-label{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
2278
+ .vital-threshold{font-size:.65rem;color:var(--dim);margin-top:2px}
807
2279
  .vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
808
2280
  .vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
809
2281
  .vital-na{background:var(--surface)}
810
2282
  .perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
811
2283
  .perf-card h3{color:var(--cyan);margin-bottom:.5rem}
812
- .perf-note{font-size:.78rem;color:var(--dim);font-style:italic;margin-top:.75rem}
2284
+ .perf-note{font-size:.76rem;color:var(--dim);font-style:italic;margin-top:.75rem}
2285
+ .load-test-card{border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
2286
+ .lt-pass{background:rgba(34,197,94,.05);border-color:#22c55e44}
2287
+ .lt-fail{background:rgba(239,68,68,.05);border-color:#ef444444}
813
2288
  .seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
814
- .seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.85rem}
2289
+ .seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.83rem}
815
2290
  .violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
816
2291
  .impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
817
2292
  .impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
818
2293
  .impact-moderate{border-left-color:#3b82f6;background:rgba(59,130,246,.05)}
2294
+ .impact-minor{border-left-color:#64748b;background:rgba(100,116,139,.04)}
819
2295
  .violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
820
- .impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
821
- .err-cell details{font-size:.78rem}
822
- footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem;font-family:'JetBrains Mono',monospace}
823
- @media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
2296
+ .impact-badge{font-size:.68rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
2297
+ .err-cell details{font-size:.76rem}
2298
+ .viewport-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.75rem;margin-top:1rem}
2299
+ .vp-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;text-align:center}
2300
+ .vp-fail{border-color:#ef444444;background:rgba(239,68,68,.05)}
2301
+ .vp-label{font-size:.78rem;font-weight:700;margin-bottom:.25rem}
2302
+ .vp-dims{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim);margin-bottom:.5rem}
2303
+ .vp-status{margin-bottom:.5rem}
2304
+ .vp-issue{font-size:.7rem;color:#f87171;margin-top:.25rem}
2305
+ footer{text-align:center;color:var(--dim);font-size:.68rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem;font-family:'JetBrains Mono',monospace}
2306
+ @media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
824
2307
  </style>
825
2308
  </head>
826
2309
  <body>
827
2310
  <header>
828
2311
  <div>
829
- <div class="logo">⚡ Backlist Enterprise QA</div>
2312
+ <div class="logo">⚡ Backlist Enterprise QA v15</div>
830
2313
  <div class="header-meta">
831
2314
  Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
832
2315
  </div>
833
2316
  </div>
834
- <span class="version-badge">v${VERSION}</span>
2317
+ ${pwBadge}
835
2318
  </header>
836
2319
 
837
2320
  <nav>
838
2321
  <button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
2322
+ <button class="nav-tab" onclick="showTab('screenshots',this)">📸 Screenshots (${session.screenshots.length})</button>
2323
+ <button class="nav-tab" onclick="showTab('viewports',this)">📱 Viewports (${vpCount})</button>
839
2324
  <button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
840
2325
  <button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
841
2326
  <button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
842
2327
  <button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
843
2328
  <button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
2329
+ <button class="nav-tab" onclick="showTab('loadtest',this)">🔥 Load Test</button>
844
2330
  <button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
845
2331
  <button class="nav-tab" onclick="showTab('seo',this)">🔎 SEO</button>
2332
+ <button class="nav-tab" onclick="showTab('darkmode',this)">🌙 Dark Mode</button>
2333
+ <button class="nav-tab" onclick="showTab('userflow',this)">🧑‍💻 User Flow</button>
2334
+ <button class="nav-tab" onclick="showTab('forms',this)">📝 Forms</button>
2335
+ <button class="nav-tab" onclick="showTab('cookies',this)">🍪 Cookies</button>
2336
+ <button class="nav-tab" onclick="showTab('memory',this)">🧠 Memory</button>
2337
+ <button class="nav-tab" onclick="showTab('brokenlinks',this)">🔗 Links (${brokenCount} broken)</button>
2338
+ <button class="nav-tab" onclick="showTab('redirects',this)">↪ Redirects</button>
2339
+ <button class="nav-tab" onclick="showTab('thirdparty',this)">📦 3rd Party (${session.thirdPartyScripts.length})</button>
2340
+ <button class="nav-tab" onclick="showTab('cache',this)">💾 Cache</button>
2341
+ <button class="nav-tab" onclick="showTab('errorpages',this)">🚫 Error Pages</button>
2342
+ <button class="nav-tab" onclick="showTab('mixed',this)">⚠️ Mixed Content</button>
2343
+ <button class="nav-tab" onclick="showTab('console',this)">🖥️ Console (${session.consoleErrors.length})</button>
2344
+ <button class="nav-tab" onclick="showTab('network',this)">📡 Network</button>
846
2345
  </nav>
847
2346
 
848
2347
  <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>
2348
+
2349
+ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real Browser Mode</strong> — Screenshots, Web Vitals, DOM tests, Interactions, User Flow, Memory, Dark Mode, All Viewports</div>' : ''}
2350
+ <div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live Chromium browser testing.</div>
850
2351
 
851
2352
  <!-- OVERVIEW -->
852
2353
  <div id="tab-overview" class="tab-panel active">
@@ -859,11 +2360,15 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
859
2360
  <div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
860
2361
  <div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
861
2362
  <div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
2363
+ <div class="mc"><div class="ml">Screenshots</div><div class="mv" style="color:#c084fc">${session.screenshots.length}</div></div>
2364
+ <div class="mc"><div class="ml">Viewports</div><div class="mv">${vpCount}</div></div>
862
2365
  <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>
2366
+ <div class="mc"><div class="ml">Broken Links</div><div class="mv" style="color:${brokenCount > 0 ? 'var(--red)' : 'var(--green)'}">${brokenCount}</div></div>
864
2367
  <div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
865
- <div class="mc"><div class="ml">SEO Checks</div><div class="mv">${session.seoResults.reduce((a,r)=>a+(r.checks?.length||0),0)}</div></div>
866
- <div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
2368
+ <div class="mc"><div class="ml">3rd Party</div><div class="mv">${session.thirdPartyScripts.length}</div></div>
2369
+ <div class="mc"><div class="ml">Console Errs</div><div class="mv" style="color:${session.consoleErrors.length > 0 ? 'var(--yellow)' : 'var(--green)'}">${session.consoleErrors.length}</div></div>
2370
+ <div class="mc"><div class="ml">Dark Mode</div><div class="mv" style="font-size:1rem">${session.darkModeResults.some(d => d.supportsDark) ? '✓' : '–'}</div></div>
2371
+ <div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:.95rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
867
2372
  </div>
868
2373
  <div class="grid2">
869
2374
  <div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
@@ -871,6 +2376,22 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
871
2376
  </div>
872
2377
  </div>
873
2378
 
2379
+ <!-- SCREENSHOTS -->
2380
+ <div id="tab-screenshots" class="tab-panel">
2381
+ <div class="card">
2382
+ <div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured (${vpCount} viewports + dark mode)</span></div>
2383
+ <div class="screenshot-gallery">${screenshotCards}</div>
2384
+ </div>
2385
+ </div>
2386
+
2387
+ <!-- VIEWPORTS -->
2388
+ <div id="tab-viewports" class="tab-panel">
2389
+ <div class="card">
2390
+ <div class="card-title">Multi-Viewport Testing <span>${vpCount} viewports</span></div>
2391
+ ${vpSection}
2392
+ </div>
2393
+ </div>
2394
+
874
2395
  <!-- TESTS -->
875
2396
  <div id="tab-tests" class="tab-panel">
876
2397
  <div class="search-bar">
@@ -903,6 +2424,10 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
903
2424
  <option value="P0">P0 Critical</option><option value="P1">P1 High</option>
904
2425
  <option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
905
2426
  </select>
2427
+ <select id="bugCat" onchange="filterBugs()">
2428
+ <option value="">All categories</option>
2429
+ ${[...new Set(session.bugs.map(b=>b.aiCategory||b.type||'general'))].map(c=>`<option value="${esc(c)}">${c}</option>`).join('')}
2430
+ </select>
906
2431
  </div>
907
2432
  <div id="bugList">${bugCards}</div>
908
2433
  </div>
@@ -910,10 +2435,10 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
910
2435
  <!-- ROUTES -->
911
2436
  <div id="tab-routes" class="tab-panel">
912
2437
  <div class="card">
913
- <div class="card-title">Discovered Routes <span>${session.routeMap.length} real pages/APIs</span></div>
2438
+ <div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
914
2439
  <table>
915
- <thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
916
- <tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
2440
+ <thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Time</th><th>Forms</th><th>Result</th></tr></thead>
2441
+ <tbody>${routeRows || '<tr><td colspan="6" class="no-data">No routes discovered</td></tr>'}</tbody>
917
2442
  </table>
918
2443
  </div>
919
2444
  </div>
@@ -921,7 +2446,7 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
921
2446
  <!-- SECURITY -->
922
2447
  <div id="tab-security" class="tab-panel">
923
2448
  <div class="card">
924
- <div class="card-title">Security Scan Results <span>${session.secFindings.length} checks</span></div>
2449
+ <div class="card-title">Security Scan <span>${session.secFindings.length} checks · ${session.secFindings.filter(f=>!f.pass).length} issues</span></div>
925
2450
  <table>
926
2451
  <thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
927
2452
  <tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
@@ -931,25 +2456,121 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
931
2456
 
932
2457
  <!-- PERFORMANCE -->
933
2458
  <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>
2459
+ <div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals (Playwright Chromium)</div>
935
2460
  ${perfSection}
936
2461
  </div>
937
2462
 
938
- <!-- ACCESSIBILITY -->
2463
+ <!-- LOAD TEST -->
2464
+ <div id="tab-loadtest" class="tab-panel">
2465
+ <div class="card-title" style="padding:.5rem 0 1rem">Load Testing — Concurrent Requests</div>
2466
+ ${loadTestSection}
2467
+ </div>
2468
+
2469
+ <!-- A11Y -->
939
2470
  <div id="tab-a11y" class="tab-panel">
940
- <div class="card-title" style="padding:.5rem 0 1rem">Accessibility Analysis Real HTML WCAG Checks</div>
2471
+ <div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG 2.1 HTML Analysis (15 rules)</div>
941
2472
  ${a11ySection}
942
2473
  </div>
943
2474
 
944
2475
  <!-- SEO -->
945
2476
  <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>
2477
+ <div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent (21 checks)</div>
947
2478
  ${seoSection}
948
2479
  </div>
949
2480
 
2481
+ <!-- DARK MODE -->
2482
+ <div id="tab-darkmode" class="tab-panel">
2483
+ <div class="card-title" style="padding:.5rem 0 1rem">Dark Mode Testing</div>
2484
+ ${darkModeSection}
2485
+ </div>
2486
+
2487
+ <!-- USER FLOW -->
2488
+ <div id="tab-userflow" class="tab-panel">
2489
+ <div class="card-title" style="padding:.5rem 0 1rem">User Flow Simulation</div>
2490
+ ${userFlowSection}
2491
+ </div>
2492
+
2493
+ <!-- FORMS -->
2494
+ <div id="tab-forms" class="tab-panel">
2495
+ <div class="card-title" style="padding:.5rem 0 1rem">Form Testing</div>
2496
+ ${formTestSection}
2497
+ </div>
2498
+
2499
+ <!-- COOKIES -->
2500
+ <div id="tab-cookies" class="tab-panel">
2501
+ <div class="card-title" style="padding:.5rem 0 1rem">Cookie Security Audit</div>
2502
+ ${cookieSection}
950
2503
  </div>
951
2504
 
952
- <footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${new Date().toLocaleString()}</footer>
2505
+ <!-- MEMORY -->
2506
+ <div id="tab-memory" class="tab-panel">
2507
+ <div class="card-title" style="padding:.5rem 0 1rem">Memory Leak Detection</div>
2508
+ ${memorySection}
2509
+ </div>
2510
+
2511
+ <!-- BROKEN LINKS -->
2512
+ <div id="tab-brokenlinks" class="tab-panel">
2513
+ <div class="card-title" style="padding:.5rem 0 1rem">Broken Link Scanner</div>
2514
+ ${brokenLinksSection}
2515
+ </div>
2516
+
2517
+ <!-- REDIRECTS -->
2518
+ <div id="tab-redirects" class="tab-panel">
2519
+ <div class="card-title" style="padding:.5rem 0 1rem">Redirect Chain Analysis</div>
2520
+ ${redirectSection}
2521
+ </div>
2522
+
2523
+ <!-- THIRD PARTY -->
2524
+ <div id="tab-thirdparty" class="tab-panel">
2525
+ <div class="card">
2526
+ <div class="card-title">Third-Party Script Audit <span>${session.thirdPartyScripts.length} external scripts</span></div>
2527
+ ${thirdPartySection}
2528
+ </div>
2529
+ </div>
2530
+
2531
+ <!-- CACHE -->
2532
+ <div id="tab-cache" class="tab-panel">
2533
+ <div class="card">
2534
+ <div class="card-title">Cache Headers Audit</div>
2535
+ ${cacheSection}
2536
+ </div>
2537
+ </div>
2538
+
2539
+ <!-- ERROR PAGES -->
2540
+ <div id="tab-errorpages" class="tab-panel">
2541
+ <div class="card">
2542
+ <div class="card-title">Error Page Testing</div>
2543
+ ${errorPageSection}
2544
+ </div>
2545
+ </div>
2546
+
2547
+ <!-- MIXED CONTENT -->
2548
+ <div id="tab-mixed" class="tab-panel">
2549
+ <div class="card">
2550
+ <div class="card-title">Mixed Content Issues</div>
2551
+ ${mixedContentSection}
2552
+ </div>
2553
+ </div>
2554
+
2555
+ <!-- CONSOLE -->
2556
+ <div id="tab-console" class="tab-panel">
2557
+ <div class="card">
2558
+ <div class="card-title">Console Errors &amp; Warnings <span>${session.consoleErrors.length} entries</span></div>
2559
+ ${consoleSection}
2560
+ </div>
2561
+ </div>
2562
+
2563
+ <!-- NETWORK -->
2564
+ <div id="tab-network" class="tab-panel">
2565
+ <div class="card">
2566
+ <div class="card-title">Network Failures <span>${session.networkLog.length} failures</span></div>
2567
+ ${networkSection}
2568
+ </div>
2569
+ </div>
2570
+
2571
+ </div>
2572
+
2573
+ <footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${session.screenshots.length} screenshots · ${vpCount} viewports · ${new Date().toLocaleString()}</footer>
953
2574
 
954
2575
  <script>
955
2576
  function showTab(name, el) {
@@ -959,43 +2580,41 @@ function showTab(name, el) {
959
2580
  el?.classList.add('active');
960
2581
  }
961
2582
  function filterTests() {
962
- const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
2583
+ const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
963
2584
  const st = document.getElementById('testStatus')?.value||'';
964
2585
  const ty = document.getElementById('testType')?.value||'';
965
2586
  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';
2587
+ row.style.display = (row.textContent.toLowerCase().includes(s) && (!st || row.dataset.status===st) && (!ty || row.dataset.type===ty)) ? '' : 'none';
970
2588
  });
971
2589
  }
972
2590
  function filterBugs() {
973
2591
  const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
974
2592
  const sv = document.getElementById('bugSev')?.value||'';
2593
+ const ca = document.getElementById('bugCat')?.value||'';
975
2594
  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';
2595
+ const matchSev = !sv || card.dataset.severity===sv;
2596
+ const matchCat = !ca || card.textContent.toLowerCase().includes(ca.toLowerCase());
2597
+ card.style.display = (card.textContent.toLowerCase().includes(s) && matchSev && matchCat) ? '' : 'none';
979
2598
  });
980
2599
  }
981
2600
  const chartCfg = {
982
- plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
983
- scales:{x:{ticks:{color:'#64748b'},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1},grid:{color:'#1e293b'},beginAtZero:true}}
2601
+ plugins:{legend:{labels:{color:'#94a3b8',font:{size:10}}}},
2602
+ scales:{x:{ticks:{color:'#64748b',font:{size:10}},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1,font:{size:10}},grid:{color:'#1e293b'},beginAtZero:true}}
984
2603
  };
985
2604
  new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
986
2605
  {label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
987
2606
  {label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
988
2607
  ]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
989
- new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}}}});
2608
+ new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:10}}}}}});
990
2609
  </script>
991
2610
  </body>
992
2611
  </html>`;
993
2612
  }
994
2613
 
995
2614
  // ═══════════════════════════════════════════════════════════════════════════
996
- // Main QA Runner
2615
+ // Main QA Runner v15 — All phases
997
2616
  // ═══════════════════════════════════════════════════════════════════════════
998
- async function runQAEngine(session) {
2617
+ async function runQAEngine(session, opts = {}) {
999
2618
  const dash = new TerminalDashboard(session);
1000
2619
  dash.start();
1001
2620
 
@@ -1012,45 +2631,208 @@ async function runQAEngine(session) {
1012
2631
  for (const [label, url] of Object.entries(session.urls)) {
1013
2632
  if (!url) continue;
1014
2633
  dash.log(`Crawling ${label}: ${url}`);
1015
- const t0 = Date.now();
2634
+ const t0 = Date.now();
1016
2635
  const routes = await crawlSite(url, {
1017
- maxPages: 50,
2636
+ maxPages: 60,
1018
2637
  onRoute: (route) => {
1019
2638
  session.routeMap.push(route);
1020
- dash.log(` Found: ${route.url} (${route.type})`);
2639
+ dash.setSubPhase(`Found: ${route.url} (${route.type})`);
1021
2640
  },
1022
2641
  });
1023
- addResult({ name: `[${label}] Route Discovery`, type: 'discovery', category: 'crawl',
2642
+ addResult({ name: `[${label}] Route Discovery`, type: 'discovery',
1024
2643
  status: routes.length > 0 ? 'PASS' : 'FAIL',
1025
2644
  message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
1026
2645
  }
1027
2646
 
1028
- // ── Phase 2: API Validation ──────────────────────────────────────────
1029
- dash.setPhase('📡 Phase 2: API Validation');
2647
+ // ── Phase 2: Redirect Chain Analysis (v15) ───────────────────────────
2648
+ dash.setPhase(' Phase 2: Redirect Chain Analysis');
2649
+ for (const [label, url] of Object.entries(session.urls)) {
2650
+ if (!url) continue;
2651
+ dash.setCurrentTest(`Redirect: ${url}`);
2652
+ const rc = await analyzeRedirectChain(url);
2653
+ session.redirectChains.push(rc);
2654
+ addResult({ name: `[${label}] Redirect Chain`, type: 'redirect',
2655
+ status: rc.hasRedirectLoop ? 'FAIL' : 'PASS',
2656
+ message: `${rc.hops} hops → ${rc.finalUrl}`, url, label });
2657
+ if (rc.hasRedirectLoop) session.addBug({ title: `Redirect loop: ${url}`, severity: 'P1', type: 'network', url });
2658
+ if (!rc.isHTTPtoHTTPS && url.startsWith('http://')) {
2659
+ session.addBug({ title: `No HTTP→HTTPS redirect: ${url}`, severity: 'P2', type: 'security', url });
2660
+ }
2661
+ }
2662
+
2663
+ // ── Phase 3: Playwright Real Browser ────────────────────────────────
2664
+ dash.setPhase('🎭 Phase 3: Playwright Real Browser Tests');
2665
+ const chromium = await getPlaywright();
2666
+
2667
+ if (chromium) {
2668
+ session.playwrightMode = true;
2669
+ dash.log(chalk.hex('#BF40FF')(' 🎭 Backlist available! Real browser mode ACTIVE'));
2670
+
2671
+ for (const [label, url] of Object.entries(session.urls)) {
2672
+ if (!url) continue;
2673
+ dash.setCurrentTest(`🎭 Browser: ${url}`);
2674
+
2675
+ const pwResult = await runPlaywrightScan(url, session, dash);
2676
+
2677
+ if (pwResult && !pwResult.error) {
2678
+ const { results: pw } = pwResult;
2679
+
2680
+ session.perfMetrics[label] = {
2681
+ ...session.perfMetrics[label],
2682
+ ...pw.vitals,
2683
+ slowResources: pw.networkFails.filter(n => n.duration > 1000),
2684
+ resourceStats: pw.resourceStats,
2685
+ domChecks: pw.domChecks,
2686
+ interactions: pw.interactions,
2687
+ playwrightMode: true,
2688
+ };
2689
+
2690
+ for (const check of pw.domChecks || []) {
2691
+ addResult({ name: `DOM: ${check.name}`, type: 'browser-dom',
2692
+ status: check.pass ? 'PASS' : 'FAIL', message: check.value, url, label });
2693
+ }
2694
+
2695
+ for (const i of pw.interactions || []) {
2696
+ addResult({ name: `Interaction: ${i.name}`, type: 'browser-interaction',
2697
+ status: i.pass ? 'PASS' : 'FAIL', message: i.value, url, label });
2698
+ if (!i.pass) session.addBug({ title: `Interaction Failed: ${i.name}`, severity: 'P2', type: 'javascript', url, evidence: { value: i.value } });
2699
+ }
2700
+
2701
+ // Viewport results
2702
+ for (const [vk, vp] of Object.entries(pw.viewportResults || {})) {
2703
+ if (!vp.error) {
2704
+ addResult({ name: `Viewport: ${vp.label}`, type: 'viewport',
2705
+ status: vp.passed ? 'PASS' : 'FAIL', message: (vp.issues||[]).join(', ') || 'OK', url, label });
2706
+ if (!vp.passed) session.addBug({ title: `Viewport Issue (${vp.label}): ${(vp.issues||[]).join(', ')}`, severity: 'P2', type: 'viewport', url, evidence: { viewport: vk, issues: vp.issues } });
2707
+ }
2708
+ }
2709
+
2710
+ // User flow
2711
+ if (pw.userFlow?.steps) {
2712
+ for (const step of pw.userFlow.steps) {
2713
+ addResult({ name: `Flow: ${step.name}`, type: 'user-flow',
2714
+ status: step.pass ? 'PASS' : 'FAIL', message: step.error || `${step.duration}ms`, url, label });
2715
+ }
2716
+ }
2717
+
2718
+ // Dark mode
2719
+ if (pw.darkMode && !pw.darkMode.error) {
2720
+ addResult({ name: `[${label}] Dark Mode Support`, type: 'dark-mode',
2721
+ status: pw.darkMode.supportsDark ? 'PASS' : 'FAIL',
2722
+ message: pw.darkMode.supportsDark ? 'prefers-color-scheme supported' : 'No dark mode support', url, label });
2723
+ }
2724
+
2725
+ // Memory
2726
+ if (pw.memoryLeak) {
2727
+ addResult({ name: `[${label}] Memory Leak Check`, type: 'memory',
2728
+ status: pw.memoryLeak.hasLeak ? 'FAIL' : 'PASS',
2729
+ message: `Heap growth: ${pw.memoryLeak.growthMB}MB`, url, label });
2730
+ if (pw.memoryLeak.hasLeak) session.addBug({ title: `Memory leak: +${pw.memoryLeak.growthMB}MB`, severity: pw.memoryLeak.severity, type: 'performance', url, evidence: pw.memoryLeak });
2731
+ }
2732
+
2733
+ // Form tests
2734
+ for (const f of pw.forms || []) {
2735
+ addResult({ name: `Form #${f.formIndex+1}: ${f.action||'self'}`, type: 'form',
2736
+ status: f.passed ? 'PASS' : 'FAIL', message: (f.issues||[]).join(', ') || 'OK', url, label });
2737
+ }
2738
+
2739
+ // Third-party
2740
+ if (pw.thirdParty?.length > 0) {
2741
+ addResult({ name: `[${label}] Third-party scripts`, type: 'third-party',
2742
+ status: 'PASS', message: `${pw.thirdParty.length} external scripts: ${pw.thirdParty.map(t=>t.vendor).join(', ')}`, url, label });
2743
+ }
2744
+
2745
+ // JS errors
2746
+ for (const err of pw.jsErrors || []) {
2747
+ addResult({ name: `JS Error: ${err.message?.slice(0,60)}`, type: 'javascript',
2748
+ status: 'FAIL', message: err.message, url, label, severity: 'P2' });
2749
+ session.addBug({ title: `JS Error: ${err.message?.slice(0,80)}`, severity: 'P2', type: 'javascript', url, evidence: { message: err.message } });
2750
+ }
2751
+
2752
+ // Network failures
2753
+ for (const fail of pw.networkFails || []) {
2754
+ addResult({ name: `Network Fail: ${fail.url?.split('/').pop()?.slice(0,40)}`, type: 'network',
2755
+ status: 'FAIL', message: fail.failure || `HTTP ${fail.status}`, url: fail.url, label });
2756
+ session.addBug({ title: `Network Failure: ${fail.url?.split('/').pop()}`, severity: fail.status >= 500 ? 'P1' : 'P2', type: 'network', url: fail.url });
2757
+ }
2758
+
2759
+ // Mixed content
2760
+ for (const mc of pw.mixedContent || []) {
2761
+ addResult({ name: `Mixed Content`, type: 'security', status: 'FAIL', message: mc, url, label });
2762
+ session.addBug({ title: `Mixed Content detected`, severity: 'P1', type: 'security', url, evidence: { text: mc } });
2763
+ }
2764
+
2765
+ // Web Vitals
2766
+ const { lcp, fcp, cls, tbt, ttfb } = pw.vitals || {};
2767
+ const vitalTests = [
2768
+ { name: 'TTFB', val: ttfb || pw.vitals?.ttfb, threshold: 800 },
2769
+ { name: 'LCP', val: lcp, threshold: 2500 },
2770
+ { name: 'FCP', val: fcp, threshold: 1800 },
2771
+ { name: 'CLS', val: cls, threshold: 0.1 },
2772
+ { name: 'TBT', val: tbt, threshold: 200 },
2773
+ ];
2774
+ for (const vt of vitalTests) {
2775
+ if (vt.val !== undefined && vt.val !== null) {
2776
+ addResult({ name: `[${label}] ${vt.name}`, type: 'performance',
2777
+ status: vt.val <= vt.threshold ? 'PASS' : 'FAIL',
2778
+ message: `${vt.name}: ${vt.val}`, url, label });
2779
+ if (vt.val > vt.threshold && vt.name === 'LCP') {
2780
+ session.addBug({ title: `Poor LCP: ${lcp}ms`, severity: lcp > 4000 ? 'P1' : 'P2', type: 'performance', url, evidence: { lcp } });
2781
+ }
2782
+ if (vt.val > vt.threshold && vt.name === 'CLS') {
2783
+ session.addBug({ title: `High CLS: ${cls}`, severity: 'P2', type: 'performance', url, evidence: { cls } });
2784
+ }
2785
+ }
2786
+ }
2787
+
2788
+ addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'PASS',
2789
+ message: `${pw.screenshots?.length||0} screenshots, ${pw.domChecks?.length||0} DOM checks, ${Object.keys(pw.viewportResults||{}).length} viewports`, url, label });
2790
+
2791
+ } else {
2792
+ addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'FAIL',
2793
+ message: pwResult?.error || 'Playwright scan failed', url, label });
2794
+ }
2795
+ }
2796
+ } else {
2797
+ dash.log(chalk.yellow(' ⚠ Playwright not installed — HTTP-only mode'));
2798
+ for (const [label, url] of Object.entries(session.urls)) {
2799
+ if (!url) continue;
2800
+ const t0 = Date.now();
2801
+ const r = await httpProbe(url, { timeout: 15000 });
2802
+ const ttfb = Date.now() - t0;
2803
+ session.perfMetrics[label] = { ttfb, bodySize: r.bodySize, statusCode: r.status, slowResources: [],
2804
+ note: 'Install Playwright for real Web Vitals, screenshots, dark mode, viewport tests' };
2805
+ addResult({ name: `[${label}] TTFB`, type: 'performance',
2806
+ status: ttfb <= 800 ? 'PASS' : 'FAIL', message: `TTFB: ${ttfb}ms`, url, label });
2807
+ }
2808
+ }
2809
+
2810
+ // ── Phase 4: API Validation ──────────────────────────────────────────
2811
+ dash.setPhase('📡 Phase 4: API Validation & Contract Testing');
1030
2812
  const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
1031
2813
  dash.log(`Validating ${apiRoutes.length} API endpoints...`);
1032
2814
  for (const route of apiRoutes) {
1033
2815
  dash.setCurrentTest(`API: ${route.url}`);
1034
- const r = await httpProbe(route.url);
1035
- session.apiLog.push({ ...r, id: shortId() });
1036
- addResult({ name: `API: ${route.url}`, type: 'api', category: 'api',
1037
- status: r.ok ? 'PASS' : 'FAIL',
1038
- message: `${r.status} ${r.ok ? 'OK' : 'FAIL'} (${r.responseTime}ms)`,
1039
- url: route.url, duration: r.responseTime });
1040
- if (!r.ok) session.addBug({ title: `API Failure: ${route.url}`,
1041
- severity: r.status >= 500 ? 'P0' : 'P1', type: 'api',
1042
- description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
2816
+ const contract = await testAPIContract(route.url);
2817
+ session.apiContracts.push(contract);
2818
+ session.apiLog.push({ ...contract, id: shortId() });
2819
+ addResult({ name: `API: ${route.url}`, type: 'api',
2820
+ status: contract.passed ? 'PASS' : 'FAIL',
2821
+ message: `${contract.status} (${contract.responseTime}ms)${contract.issues.length ? ' · ' + contract.issues.join(', ') : ''}`,
2822
+ url: route.url, duration: contract.responseTime });
2823
+ if (!contract.passed) session.addBug({ title: `API Issue: ${route.url}`, severity: contract.status >= 500 ? 'P0' : 'P1', type: 'api',
2824
+ description: contract.issues.join(', '), evidence: { status: contract.status, issues: contract.issues } });
1043
2825
  }
1044
2826
 
1045
- // ── Phase 3: Security ────────────────────────────────────────────────
1046
- dash.setPhase('🛡️ Phase 3: Security Scan');
2827
+ // ── Phase 5: Security ────────────────────────────────────────────────
2828
+ dash.setPhase('🛡️ Phase 5: Security Scan (20+ checks)');
1047
2829
  for (const [label, url] of Object.entries(session.urls)) {
1048
2830
  if (!url) continue;
1049
2831
  dash.setCurrentTest(`Security: ${url}`);
1050
2832
  const findings = await runSecurityScan(url);
1051
2833
  session.secFindings.push(...findings);
1052
2834
  for (const f of findings) {
1053
- addResult({ name: `Security: ${f.check}`, type: 'security', category: f.category,
2835
+ addResult({ name: `Security: ${f.check}`, type: 'security',
1054
2836
  status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
1055
2837
  if (!f.pass && ['P0','P1'].includes(f.severity)) {
1056
2838
  session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
@@ -1059,70 +2841,130 @@ async function runQAEngine(session) {
1059
2841
  }
1060
2842
  }
1061
2843
 
1062
- // ── Phase 4: Performance ─────────────────────────────────────────────
1063
- dash.setPhase(' Phase 4: Performance Profiling');
2844
+ // ── Phase 6: Cookie Audit (v15) ──────────────────────────────────────
2845
+ dash.setPhase('🍪 Phase 6: Cookie Security Audit');
1064
2846
  for (const [label, url] of Object.entries(session.urls)) {
1065
2847
  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 });
2848
+ dash.setCurrentTest(`Cookies: ${url}`);
2849
+ const cookieResult = await runCookieAudit(url);
2850
+ session.cookieAudit.push(cookieResult);
2851
+ for (const cookie of cookieResult.cookies) {
2852
+ addResult({ name: `Cookie: ${cookie.name}`, type: 'security',
2853
+ status: cookie.issues.length === 0 ? 'PASS' : 'FAIL',
2854
+ message: cookie.issues.join(', ') || 'Secure', url, label, severity: cookie.severity });
2855
+ if (cookie.issues.length > 0 && cookie.severity !== 'INFO') {
2856
+ session.addBug({ title: `Cookie "${cookie.name}": ${cookie.issues.join(', ')}`, severity: cookie.severity, type: 'security', url, evidence: { cookie: cookie.name, issues: cookie.issues } });
2857
+ }
1077
2858
  }
1078
2859
  }
1079
2860
 
1080
- // ── Phase 5: Accessibility ───────────────────────────────────────────
1081
- dash.setPhase(' Phase 5: Accessibility Check');
1082
- const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
1083
- for (const route of pageRoutes) {
2861
+ // ── Phase 7: Cache Headers (v15) ─────────────────────────────────────
2862
+ dash.setPhase('💾 Phase 7: Cache Headers Audit');
2863
+ for (const [label, url] of Object.entries(session.urls)) {
2864
+ if (!url) continue;
2865
+ const cacheResult = await auditCacheHeaders(url);
2866
+ session.cacheHeaders.push(cacheResult);
2867
+ addResult({ name: `[${label}] Cache Headers`, type: 'performance',
2868
+ status: cacheResult.passed ? 'PASS' : 'FAIL',
2869
+ message: cacheResult.cacheControl || 'No cache headers', url, label });
2870
+ }
2871
+
2872
+ // ── Phase 8: Broken Links (v15) ──────────────────────────────────────
2873
+ dash.setPhase('🔗 Phase 8: Broken Link Scanner');
2874
+ const pageRoutes8 = session.routeMap.filter(r => r.type === 'page').slice(0, 5);
2875
+ for (const route of pageRoutes8) {
2876
+ dash.setCurrentTest(`Links: ${route.url}`);
2877
+ const blResult = await scanBrokenLinks(route.url, { maxLinks: 60 });
2878
+ session.brokenLinks.push(blResult);
2879
+ addResult({ name: `Broken Links: ${route.url}`, type: 'broken-links',
2880
+ status: blResult.broken === 0 ? 'PASS' : 'FAIL',
2881
+ message: `${blResult.broken}/${blResult.total} links broken`, url: route.url });
2882
+ for (const bl of blResult.links.filter(l => !l.ok).slice(0, 5)) {
2883
+ session.addBug({ title: `Broken link: ${bl.url}`, severity: 'P2', type: 'network',
2884
+ url: route.url, evidence: { brokenUrl: bl.url, status: bl.status } });
2885
+ }
2886
+ }
2887
+
2888
+ // ── Phase 9: Error Pages (v15) ───────────────────────────────────────
2889
+ dash.setPhase('🚫 Phase 9: Error Page Testing');
2890
+ for (const [label, url] of Object.entries(session.urls)) {
2891
+ if (!url) continue;
2892
+ const errResults = await testErrorPages(url);
2893
+ session.errorPageTests.push(...errResults);
2894
+ for (const er of errResults) {
2895
+ addResult({ name: `${er.name}: ${url}`, type: 'error-page',
2896
+ status: er.passed ? 'PASS' : 'FAIL', message: er.issues.join(', ') || 'OK', url, label });
2897
+ if (!er.passed) session.addBug({ title: `${er.name} issue: ${er.issues.join(', ')}`, severity: 'P2', type: 'api', url, evidence: { test: er.name, status: er.actualStatus, issues: er.issues } });
2898
+ }
2899
+ }
2900
+
2901
+ // ── Phase 10: Accessibility ──────────────────────────────────────────
2902
+ dash.setPhase('♿ Phase 10: Accessibility Check (WCAG 2.1)');
2903
+ const pageRoutes10 = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
2904
+ for (const route of pageRoutes10) {
1084
2905
  dash.setCurrentTest(`A11y: ${route.url}`);
1085
2906
  const result = await runA11yScan(route.url);
1086
2907
  session.a11yResults.push({ url: route.url, ...result });
1087
2908
  for (const v of result.violations) {
1088
- addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility', category: 'wcag',
1089
- status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : v.impact === 'serious' ? 'P1' : 'P2',
1090
- url: route.url });
1091
- if (['critical','serious'].includes(v.impact)) session.addBug({
1092
- title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
1093
- type: 'accessibility', description: v.help, url: route.url,
1094
- recommendation: v.helpUrl });
2909
+ addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility',
2910
+ status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : 'P1', url: route.url });
2911
+ if (['critical','serious'].includes(v.impact)) session.addBug({ title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1', type: 'accessibility', description: v.help, url: route.url });
1095
2912
  }
1096
- for (const pass of result.passes.slice(0, 3)) {
2913
+ for (const pass of result.passes.slice(0, 4)) {
1097
2914
  addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
1098
2915
  }
1099
2916
  }
1100
2917
 
1101
- // ── Phase 6: SEO ─────────────────────────────────────────────────────
1102
- dash.setPhase('🔎 Phase 6: SEO Validation');
2918
+ // ── Phase 11: SEO ────────────────────────────────────────────────────
2919
+ dash.setPhase('🔎 Phase 11: SEO Validation (21 checks)');
1103
2920
  const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
1104
2921
  for (const route of seoRoutes) {
1105
2922
  dash.setCurrentTest(`SEO: ${route.url}`);
1106
2923
  const result = await runSEOScan(route.url);
1107
2924
  session.seoResults.push({ url: route.url, ...result });
1108
2925
  for (const c of result.checks) {
1109
- addResult({ name: `SEO: ${c.name}`, type: 'seo', category: c.category,
2926
+ addResult({ name: `SEO: ${c.name}`, type: 'seo',
1110
2927
  status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
1111
- if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
1112
- title: `SEO: ${c.name}`, severity: c.severity, type: 'seo',
1113
- description: c.detail, url: route.url, recommendation: c.recommendation });
2928
+ if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({ title: `SEO: ${c.name}`, severity: c.severity, type: 'seo', description: c.detail, url: route.url });
2929
+ }
2930
+ }
2931
+
2932
+ // ── Phase 12: Load Test (v15) ────────────────────────────────────────
2933
+ if (opts.loadTest !== false) {
2934
+ dash.setPhase('🔥 Phase 12: Load Testing');
2935
+ for (const [label, url] of Object.entries(session.urls)) {
2936
+ if (!url) continue;
2937
+ dash.setCurrentTest(`Load test: ${url} (10 concurrent, 10s)`);
2938
+ dash.log(chalk.yellow(` 🔥 Load testing ${url}...`));
2939
+ const lt = await runLoadTest(url, { concurrency: 10, duration: 10000, rampUp: 2000 });
2940
+ session.loadTestResults.push(lt);
2941
+ addResult({ name: `[${label}] Load Test`, type: 'load-test',
2942
+ status: lt.passed ? 'PASS' : 'FAIL',
2943
+ message: `${lt.rps} req/s · p95=${lt.latency.p95}ms · ${lt.errorRate}% errors`, url, label });
2944
+ if (!lt.passed) session.addBug({ title: `Load test failed: ${lt.errorRate}% error rate or slow p95`, severity: lt.errorRate > 20 ? 'P1' : 'P2', type: 'performance', url, evidence: { rps: lt.rps, p95: lt.latency.p95, errorRate: lt.errorRate } });
2945
+ dash.log(chalk.green(` ✓ Load test: ${lt.rps} req/s, p95=${lt.latency.p95}ms`));
1114
2946
  }
1115
2947
  }
1116
2948
 
1117
- // ── Phase 7: AI Classification ───────────────────────────────────────
1118
- dash.setPhase('🤖 Phase 7: AI Bug Classification');
2949
+ // ── Phase 13: HTTP Version (v15) ─────────────────────────────────────
2950
+ dash.setPhase('🌐 Phase 13: HTTP Version & Protocol Check');
2951
+ for (const [label, url] of Object.entries(session.urls)) {
2952
+ if (!url) continue;
2953
+ const http = await inspectHTTPVersion(url);
2954
+ session.httpVersions[label] = http;
2955
+ addResult({ name: `[${label}] HTTPS`, type: 'security',
2956
+ status: http.isHTTPS ? 'PASS' : 'FAIL', message: http.isHTTPS ? 'HTTPS in use' : 'HTTP only', url, label });
2957
+ }
2958
+
2959
+ // ── Phase 14: AI Classification ──────────────────────────────────────
2960
+ dash.setPhase('🤖 Phase 14: AI Bug Classification');
1119
2961
  dash.log(`Classifying ${session.bugs.length} bugs...`);
1120
2962
  for (const bug of session.bugs) {
1121
2963
  const cls = classifyBug(bug);
1122
- bug.aiSeverity = cls.severity;
1123
- bug.aiCategory = cls.category;
2964
+ bug.aiSeverity = cls.severity;
2965
+ bug.aiCategory = cls.category;
1124
2966
  bug.aiRecommendation = cls.recommendation;
1125
- bug.aiConfidence = cls.confidence;
2967
+ bug.aiConfidence = cls.confidence;
1126
2968
  }
1127
2969
  session.bugs.sort((a, b) => {
1128
2970
  const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -1141,22 +2983,33 @@ async function runQAEngine(session) {
1141
2983
  // ═══════════════════════════════════════════════════════════════════════════
1142
2984
  async function generateReports(session) {
1143
2985
  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();
2986
+ const base = session.id.toLowerCase();
2987
+ const htmlPath = path.join(REPORT_DIR, `${base}.html`);
2988
+ const jsonPath = path.join(REPORT_DIR, `${base}.json`);
2989
+ const summary = session.getSummary();
1148
2990
 
1149
2991
  await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
1150
2992
  await fs.writeJson(jsonPath, {
1151
- meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(), dataSource: 'real-runtime' },
2993
+ meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
2994
+ dataSource: session.playwrightMode ? 'playwright-real-browser' : 'http-only' },
1152
2995
  urls: session.urls, summary, results: session.results, bugs: session.bugs,
1153
2996
  routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
1154
2997
  perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
2998
+ cookieAudit: session.cookieAudit, loadTestResults: session.loadTestResults,
2999
+ brokenLinks: session.brokenLinks, redirectChains: session.redirectChains,
3000
+ memorySnapshots: session.memorySnapshots, darkModeResults: session.darkModeResults,
3001
+ viewportResults: session.viewportResults, userFlowResults: session.userFlowResults,
3002
+ thirdPartyScripts: session.thirdPartyScripts, cacheHeaders: session.cacheHeaders,
3003
+ errorPageTests: session.errorPageTests, formTests: session.formTests,
3004
+ mixedContentIssues: session.mixedContentIssues, cspViolations: session.cspViolations,
3005
+ httpVersions: session.httpVersions,
3006
+ screenshots: session.screenshots.map(s => ({ ...s, path: undefined })),
3007
+ playwrightMode: session.playwrightMode,
1155
3008
  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,
3009
+ exitCode: summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
3010
+ p0Bugs: session.bugs.filter(b => b.severity === 'P0').length,
3011
+ p1Bugs: session.bugs.filter(b => b.severity === 'P1').length,
3012
+ passRate: summary.passRate,
1160
3013
  },
1161
3014
  }, { spaces: 2 });
1162
3015
 
@@ -1170,6 +3023,7 @@ export async function initQASystem() {
1170
3023
  await fs.ensureDir(QA_DIR);
1171
3024
  await fs.ensureDir(REPORT_DIR);
1172
3025
  await fs.ensureDir(SCREENSHOT_DIR);
3026
+ await fs.ensureDir(BASELINE_DIR);
1173
3027
  if (!await fs.pathExists(HISTORY_FILE)) {
1174
3028
  await fs.writeJson(HISTORY_FILE, { runs: [], version: VERSION }, { spaces: 2 });
1175
3029
  }
@@ -1182,6 +3036,8 @@ async function saveToHistory(session, htmlPath, jsonPath) {
1182
3036
  history.runs.unshift({
1183
3037
  id: session.id, startedAt: session.startedAt, urls: session.urls,
1184
3038
  summary, version: VERSION, bugCount: session.bugs.length,
3039
+ screenshotCount: session.screenshots.length,
3040
+ playwrightMode: session.playwrightMode,
1185
3041
  htmlPath, jsonPath,
1186
3042
  });
1187
3043
  if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
@@ -1189,10 +3045,9 @@ async function saveToHistory(session, htmlPath, jsonPath) {
1189
3045
  }
1190
3046
 
1191
3047
  // ═══════════════════════════════════════════════════════════════════════════
1192
- // Public API
3048
+ // Public API — runUrlQA (main entry point)
1193
3049
  // ═══════════════════════════════════════════════════════════════════════════
1194
-
1195
- export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
3050
+ export async function runUrlQA({ localUrl, stagingUrl, prodUrl, loadTest = true } = {}) {
1196
3051
  const urls = {};
1197
3052
  if (localUrl) urls.localhost = localUrl;
1198
3053
  if (stagingUrl) urls.staging = stagingUrl;
@@ -1200,17 +3055,31 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1200
3055
 
1201
3056
  if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
1202
3057
 
3058
+ const chromium = await getPlaywright();
3059
+ console.log('');
3060
+ console.log(chalk.hex('#00F5FF').bold(` ⚡ Backlist QA Engine v${VERSION}`));
3061
+ console.log(chalk.hex('#00F5FF')(' ─────────────────────────────────────────'));
3062
+ if (chromium) {
3063
+ console.log(chalk.hex('#BF40FF')(' 🎭 Backlist: Real Browser Mode ACTIVE'));
3064
+ console.log(chalk.gray(' Multi-viewport · Dark mode · Memory · User flows'));
3065
+ console.log(chalk.gray(' Screenshots (7 viewports) · Web Vitals · Forms'));
3066
+ } else {
3067
+ console.log(chalk.yellow(' ⚠ Playwright not found — HTTP-only mode'));
3068
+ console.log(chalk.gray(' Run: npm install playwright && npx playwright install chromium'));
3069
+ }
3070
+ console.log('');
3071
+
1203
3072
  const session = new QASession(urls);
1204
- await runQAEngine(session);
3073
+ await runQAEngine(session, { loadTest });
1205
3074
  const { htmlPath, jsonPath } = await generateReports(session);
1206
3075
  await saveToHistory(session, htmlPath, jsonPath);
1207
3076
 
1208
3077
  const summary = session.getSummary();
1209
- console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs`));
3078
+ console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
1210
3079
  console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
1211
3080
  console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
3081
+ if (session.screenshots.length > 0) console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
1212
3082
 
1213
- // Auto-open report
1214
3083
  try {
1215
3084
  const { exec } = await import('node:child_process');
1216
3085
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -1221,14 +3090,14 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1221
3090
  return { session, htmlPath, jsonPath };
1222
3091
  }
1223
3092
 
1224
- export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
3093
+ export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl, loadTest = false } = {}) {
1225
3094
  const run = async () => {
1226
3095
  const urls = {};
1227
3096
  if (localUrl) urls.localhost = localUrl;
1228
3097
  if (stagingUrl) urls.staging = stagingUrl;
1229
3098
  if (prodUrl) urls.production = prodUrl;
1230
3099
  const session = new QASession(urls);
1231
- await runQAEngine(session);
3100
+ await runQAEngine(session, { loadTest });
1232
3101
  const { htmlPath, jsonPath } = await generateReports(session);
1233
3102
  await saveToHistory(session, htmlPath, jsonPath);
1234
3103
  console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
@@ -1249,11 +3118,15 @@ export async function runManualQA() {
1249
3118
  const action = await p.select({
1250
3119
  message: 'Manual QA mode:',
1251
3120
  options: [
1252
- { value: 'full', label: '🌐 Full Scan (All phases)' },
1253
- { value: 'security', label: '🛡️ Security only' },
1254
- { value: 'seo', label: '🔎 SEO only' },
1255
- { value: 'a11y', label: ' Accessibility only' },
1256
- { value: 'perf', label: ' Performance only' },
3121
+ { value: 'full', label: '🌐 Full Scan (All 14 phases + Playwright)' },
3122
+ { value: 'browser', label: '🎭 Browser-only (screenshots, vitals, dark mode, viewports)' },
3123
+ { value: 'security', label: '🛡️ Security only (20+ checks)' },
3124
+ { value: 'seo', label: '🔎 SEO only (21 checks)' },
3125
+ { value: 'a11y', label: ' Accessibility only (WCAG 2.1)' },
3126
+ { value: 'perf', label: '⚡ Performance + Load Test' },
3127
+ { value: 'links', label: '🔗 Broken Link Scanner' },
3128
+ { value: 'cookies', label: '🍪 Cookie Audit' },
3129
+ { value: 'userflow', label: '🧑‍💻 User Flow Simulation' },
1257
3130
  ],
1258
3131
  });
1259
3132
  if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
@@ -1265,31 +3138,63 @@ export async function runManualQA() {
1265
3138
  const sess = new QASession({ localhost: url });
1266
3139
 
1267
3140
  if (action === 'full') {
1268
- await runQAEngine(sess);
3141
+ await runQAEngine(sess, { loadTest: true });
1269
3142
  } else {
1270
3143
  const dash = new TerminalDashboard(sess);
1271
3144
  dash.start();
1272
3145
  try {
1273
- if (action === 'security') {
1274
- const f = await runSecurityScan(url);
1275
- sess.secFindings.push(...f);
1276
- f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
1277
- status: finding.pass ? 'PASS' : 'FAIL', message: finding.detail, timestamp: timestamp() }));
3146
+ if (action === 'browser') {
3147
+ const chromium = await getPlaywright();
3148
+ if (!chromium) { dash.log(chalk.red('Playwright not installed!')); }
3149
+ else {
3150
+ sess.playwrightMode = true;
3151
+ await runPlaywrightScan(url, sess, dash);
3152
+ }
3153
+ } else if (action === 'security') {
3154
+ const findings = await runSecurityScan(url);
3155
+ sess.secFindings.push(...findings);
3156
+ findings.forEach(f => sess.addResult({ id: shortId(), name: `Security: ${f.check}`, type: 'security', status: f.pass ? 'PASS' : 'FAIL', message: f.detail, timestamp: timestamp() }));
1278
3157
  } else if (action === 'seo') {
1279
3158
  const r = await runSEOScan(url);
1280
3159
  sess.seoResults.push({ url, ...r });
1281
- r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
1282
- status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
3160
+ r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo', status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
1283
3161
  } else if (action === 'a11y') {
1284
3162
  const r = await runA11yScan(url);
1285
3163
  sess.a11yResults.push({ url, ...r });
1286
- r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
1287
- status: 'FAIL', message: v.help, timestamp: timestamp() }));
3164
+ r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility', status: 'FAIL', message: v.help, timestamp: timestamp() }));
3165
+ r.passes.forEach(p => sess.addResult({ id: shortId(), name: `A11y ✓: ${p.description}`, type: 'accessibility', status: 'PASS', timestamp: timestamp() }));
1288
3166
  } 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() });
3167
+ const chromium2 = await getPlaywright();
3168
+ if (chromium2) {
3169
+ sess.playwrightMode = true;
3170
+ await runPlaywrightScan(url, sess, dash);
3171
+ }
3172
+ const lt = await runLoadTest(url, { concurrency: 10, duration: 8000 });
3173
+ sess.loadTestResults.push(lt);
3174
+ sess.addResult({ id: shortId(), name: `Load Test: ${lt.rps} req/s`, type: 'load-test', status: lt.passed ? 'PASS' : 'FAIL', message: `p95=${lt.latency.p95}ms errors=${lt.errorRate}%`, timestamp: timestamp() });
3175
+ } else if (action === 'links') {
3176
+ dash.log(chalk.cyan('Scanning for broken links...'));
3177
+ const bl = await scanBrokenLinks(url, { maxLinks: 100 });
3178
+ sess.brokenLinks.push(bl);
3179
+ sess.addResult({ id: shortId(), name: `Broken Links: ${bl.broken}/${bl.total}`, type: 'broken-links', status: bl.broken === 0 ? 'PASS' : 'FAIL', message: `${bl.broken} broken links found`, timestamp: timestamp() });
3180
+ } else if (action === 'cookies') {
3181
+ const ca = await runCookieAudit(url);
3182
+ sess.cookieAudit.push(ca);
3183
+ ca.cookies.forEach(c => sess.addResult({ id: shortId(), name: `Cookie: ${c.name}`, type: 'security', status: c.issues.length === 0 ? 'PASS' : 'FAIL', message: c.issues.join(', ') || 'OK', timestamp: timestamp() }));
3184
+ } else if (action === 'userflow') {
3185
+ const chromium3 = await getPlaywright();
3186
+ if (chromium3) {
3187
+ sess.playwrightMode = true;
3188
+ const browser = await chromium3.launch({ headless: true, args: ['--no-sandbox'] });
3189
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
3190
+ const page = await context.newPage();
3191
+ const flow = await simulateUserFlow(page, url);
3192
+ sess.userFlowResults.push(flow);
3193
+ flow.steps.forEach(s => sess.addResult({ id: shortId(), name: `Flow: ${s.name}`, type: 'user-flow', status: s.pass ? 'PASS' : 'FAIL', message: s.error || `${s.duration}ms`, timestamp: timestamp() }));
3194
+ await page.close(); await context.close(); await browser.close();
3195
+ } else {
3196
+ dash.log(chalk.red('Playwright required for user flow simulation'));
3197
+ }
1293
3198
  }
1294
3199
  } finally { dash.stop(); }
1295
3200
  }
@@ -1320,13 +3225,14 @@ export async function viewQAHistory() {
1320
3225
  if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
1321
3226
 
1322
3227
  console.log('');
1323
- console.log(chalk.hex('#00F5FF').bold(' QA History'));
1324
- console.log(chalk.gray(' ──────────────────────────────────────────────────'));
3228
+ console.log(chalk.hex('#00F5FF').bold(' QA History — v15'));
3229
+ console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
1325
3230
  for (const run of history.runs.slice(0, 15)) {
1326
3231
  const rate = run.summary?.passRate ?? '–';
1327
3232
  const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
1328
3233
  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))}`);
3234
+ const pwIcon = run.playwrightMode ? chalk.hex('#BF40FF')('🎭') : chalk.gray('');
3235
+ 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.magenta((run.screenshotCount||0)+' 📸')} ${pwIcon} ${chalk.dim(urls.slice(0,40))}`);
1330
3236
  }
1331
3237
  console.log('');
1332
3238
 
@@ -1335,7 +3241,7 @@ export async function viewQAHistory() {
1335
3241
  options: [
1336
3242
  ...history.runs.slice(0, 8).map(r => ({
1337
3243
  value: r.htmlPath || r.id,
1338
- label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
3244
+ label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs${r.playwrightMode ? ' 🎭' : ''}`,
1339
3245
  })),
1340
3246
  { value: '__back', label: '↩ Back' },
1341
3247
  ],