create-backlist 10.1.0 → 10.1.2

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 v13.0 — PLAYWRIGHT REAL BROWSER EDITION
3
- // 100% Real Runtime Testing · Live Playwright Tests · Rich HTML Reports
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,15 +13,28 @@ 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 = '13.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
40
  export const timestamp = () => new Date().toISOString();
@@ -58,8 +75,15 @@ async function getPlaywright() {
58
75
  }
59
76
  }
60
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
+
61
85
  // ═══════════════════════════════════════════════════════════════════════════
62
- // QA Session
86
+ // QA Session v15 — Extended with new trackers
63
87
  // ═══════════════════════════════════════════════════════════════════════════
64
88
  export class QASession {
65
89
  id;
@@ -77,6 +101,30 @@ export class QASession {
77
101
  a11yResults = [];
78
102
  seoResults = [];
79
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 = {};
80
128
 
81
129
  constructor(urls = {}) {
82
130
  this.id = `QA-${shortId().toUpperCase()}`;
@@ -108,19 +156,21 @@ export class QASession {
108
156
  }
109
157
 
110
158
  // ═══════════════════════════════════════════════════════════════════════════
111
- // HTTP Probe — real HTTP requests
159
+ // HTTP Probe — real HTTP requests with v15 extras
112
160
  // ═══════════════════════════════════════════════════════════════════════════
113
- async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
161
+ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {}, body: reqBody = null, followRedirects = true } = {}) {
114
162
  const t0 = Date.now();
115
163
  try {
116
164
  const ctrl = new AbortController();
117
165
  const timer = setTimeout(() => ctrl.abort(), timeout);
118
- const res = await fetch(url, {
166
+ const fetchOpts = {
119
167
  method,
120
168
  signal : ctrl.signal,
121
- headers : { 'User-Agent': 'Backlist-QA/13.0', Accept: '*/*', ...headers },
122
- redirect: 'follow',
123
- });
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);
124
174
  clearTimeout(timer);
125
175
 
126
176
  const rt = Date.now() - t0;
@@ -137,7 +187,7 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
137
187
  return {
138
188
  ok: res.status >= 200 && res.status < 400,
139
189
  status: res.status, contentType, headers: hdrs,
140
- body: body.slice(0, 3000), parsed, bodySize,
190
+ body: body.slice(0, 5000), parsed, bodySize,
141
191
  responseTime: rt, url, method, error: null,
142
192
  };
143
193
  } catch (err) {
@@ -151,13 +201,726 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
151
201
  }
152
202
 
153
203
  // ═══════════════════════════════════════════════════════════════════════════
154
- // PLAYWRIGHT REAL BROWSER ENGINE
155
- // - Real browser rendering (Chromium)
156
- // - Console error capture
157
- // - Network request interception
158
- // - Real Web Vitals (LCP, FCP, CLS, TBT)
159
- // - Screenshot capture
160
- // - DOM interaction tests
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
161
924
  // ═══════════════════════════════════════════════════════════════════════════
162
925
  async function runPlaywrightScan(url, session, dash, options = {}) {
163
926
  const chromium = await getPlaywright();
@@ -166,39 +929,51 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
166
929
  return null;
167
930
  }
168
931
 
169
- dash?.log(chalk.cyan(` 🎭 Playwright browser launching for ${url}...`));
932
+ dash?.log(chalk.cyan(` 🎭 backlist browser launching for ${url}...`));
170
933
 
171
934
  let browser, context, page;
172
935
  const results = {
173
- consoleErrors : [],
174
- networkFails : [],
175
- screenshots : [],
176
- vitals : {},
177
- interactions : [],
178
- domChecks : [],
179
- jsErrors : [],
936
+ consoleErrors : [],
937
+ networkFails : [],
938
+ screenshots : [],
939
+ vitals : {},
940
+ interactions : [],
941
+ domChecks : [],
942
+ jsErrors : [],
180
943
  networkRequests: [],
944
+ darkMode : {},
945
+ viewportResults: {},
946
+ fonts : {},
947
+ thirdParty : [],
948
+ forms : [],
949
+ memoryLeak : {},
950
+ userFlow : {},
951
+ mixedContent : {},
952
+ cspViolations : [],
181
953
  };
182
954
 
183
955
  try {
184
956
  browser = await chromium.launch({
185
957
  headless: options.headless !== false,
186
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
958
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--enable-precise-memory-info'],
187
959
  });
188
960
 
189
961
  context = await browser.newContext({
190
962
  viewport: { width: 1280, height: 900 },
191
- userAgent: 'Backlist-QA/13.0 (Playwright)',
963
+ userAgent: 'Backlist-QA/15.0 (Playwright)',
192
964
  ignoreHTTPSErrors: true,
193
- recordVideo: options.recordVideo ? { dir: SCREENSHOT_DIR } : undefined,
194
965
  });
195
966
 
196
967
  page = await context.newPage();
197
968
 
198
- // ── Capture console messages ─────────────────────────────────────────
969
+ // ── Mixed Content & CSP violations ──────────────────────────────────
970
+ const mixedContent = [];
971
+ const cspViolations2 = [];
199
972
  page.on('console', (msg) => {
200
973
  const type = msg.type();
201
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);
202
977
  if (['error', 'warning'].includes(type)) {
203
978
  const entry = { type, text, timestamp: Date.now(), url: page.url() };
204
979
  results.consoleErrors.push(entry);
@@ -220,10 +995,8 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
220
995
  });
221
996
  page.on('requestfailed', (req) => {
222
997
  const entry = {
223
- url : req.url(),
224
- method : req.method(),
225
- failure : req.failure()?.errorText || 'unknown',
226
- timestamp: Date.now(),
998
+ url: req.url(), method: req.method(),
999
+ failure: req.failure()?.errorText || 'unknown', timestamp: Date.now(),
227
1000
  };
228
1001
  results.networkFails.push(entry);
229
1002
  session.networkLog.push(entry);
@@ -232,11 +1005,9 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
232
1005
  const start = requestTimings.get(res.url()) || Date.now();
233
1006
  const duration = Date.now() - start;
234
1007
  const entry = {
235
- url : res.url(),
236
- status : res.status(),
237
- duration,
238
- size : parseInt(res.headers()['content-length'] || '0'),
239
- type : res.headers()['content-type'] || '',
1008
+ url: res.url(), status: res.status(), duration,
1009
+ size: parseInt(res.headers()['content-length'] || '0'),
1010
+ type: res.headers()['content-type'] || '',
240
1011
  };
241
1012
  results.networkRequests.push(entry);
242
1013
  if (res.status() >= 400) {
@@ -247,8 +1018,7 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
247
1018
  // ── Navigate ─────────────────────────────────────────────────────────
248
1019
  const navStart = Date.now();
249
1020
  const response = await page.goto(url, {
250
- waitUntil: 'networkidle',
251
- timeout : 30000,
1021
+ waitUntil: 'networkidle', timeout: 30000,
252
1022
  }).catch(err => ({ error: err.message }));
253
1023
  const navDuration = Date.now() - navStart;
254
1024
 
@@ -257,93 +1027,74 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
257
1027
  return { error: response.error, results };
258
1028
  }
259
1029
 
260
- // ── Screenshot: Desktop ──────────────────────────────────────────────
261
1030
  await fs.ensureDir(SCREENSHOT_DIR);
262
- const screenshotName = `${session.id}-desktop-${shortId()}.png`;
263
- const screenshotPath = path.join(SCREENSHOT_DIR, screenshotName);
264
- await page.screenshot({ path: screenshotPath, fullPage: true });
265
- results.screenshots.push({ path: screenshotPath, name: screenshotName, type: 'desktop', url });
266
- session.screenshots.push({ path: screenshotPath, name: screenshotName, type: 'desktop', url });
267
- dash?.log(chalk.green(` 📸 Desktop screenshot: ${screenshotName}`));
268
-
269
- // ── Screenshot: Mobile (viewport switch) ─────────────────────────────
270
- await page.setViewportSize({ width: 390, height: 844 });
271
- await page.waitForTimeout(500);
272
- const mobileScreenshotName = `${session.id}-mobile-${shortId()}.png`;
273
- const mobileScreenshotPath = path.join(SCREENSHOT_DIR, mobileScreenshotName);
274
- await page.screenshot({ path: mobileScreenshotPath, fullPage: false });
275
- results.screenshots.push({ path: mobileScreenshotPath, name: mobileScreenshotName, type: 'mobile', url });
276
- session.screenshots.push({ path: mobileScreenshotPath, name: mobileScreenshotName, type: 'mobile', url });
277
- dash?.log(chalk.green(` 📸 Mobile screenshot: ${mobileScreenshotName}`));
278
- await page.setViewportSize({ width: 1280, height: 900 });
279
-
280
- // ── Real Web Vitals via PerformanceObserver ───────────────────────────
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 ────────────────────────────────────────────────
281
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(() => {});
282
1066
  const vitals = await page.evaluate(() => {
283
1067
  return new Promise((resolve) => {
284
- const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
1068
+ const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
285
1069
  let clsVal = 0;
286
-
287
- // Navigation timing (TTFB)
288
1070
  const navEntry = performance.getEntriesByType('navigation')[0];
289
1071
  if (navEntry) v.ttfb = Math.round(navEntry.responseStart - navEntry.requestStart);
290
-
291
- // FCP
292
- const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
293
- if (fcpEntry) v.fcp = Math.round(fcpEntry.startTime);
294
-
295
- // Paint entries
296
1072
  const paintEntries = performance.getEntriesByType('paint');
297
- paintEntries.forEach(entry => {
298
- if (entry.name === 'first-contentful-paint') v.fcp = Math.round(entry.startTime);
299
- });
300
-
301
- // LCP Observer
302
- try {
303
- new PerformanceObserver((list) => {
304
- const entries = list.getEntries();
305
- const last = entries[entries.length - 1];
306
- if (last) v.lcp = Math.round(last.startTime);
307
- }).observe({ type: 'largest-contentful-paint', buffered: true });
308
- } catch {}
309
-
310
- // CLS Observer
311
- try {
312
- new PerformanceObserver((list) => {
313
- for (const entry of list.getEntries()) {
314
- if (!entry.hadRecentInput) clsVal += entry.value;
315
- }
316
- v.cls = parseFloat(clsVal.toFixed(4));
317
- }).observe({ type: 'layout-shift', buffered: true });
318
- } catch {}
319
-
320
- // Long tasks (TBT estimation)
321
- try {
322
- new PerformanceObserver((list) => {
323
- for (const entry of list.getEntries()) {
324
- if (entry.duration > 50) v.tbt += Math.round(entry.duration - 50);
325
- }
326
- }).observe({ type: 'longtask', buffered: true });
327
- } catch {}
328
-
329
- // Wait for all observers
330
- setTimeout(() => {
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; }
331
1080
  v.cls = parseFloat(clsVal.toFixed(4));
332
- resolve(v);
333
- }, 2000);
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);
334
1086
  });
335
1087
  }).catch(() => ({}));
336
1088
 
337
- // Merge with navigation timing
338
1089
  const navTiming = await page.evaluate(() => {
339
1090
  const nav = performance.getEntriesByType('navigation')[0];
340
1091
  if (!nav) return {};
341
1092
  return {
342
- ttfb : Math.round(nav.responseStart - nav.requestStart),
343
- domLoad : Math.round(nav.domContentLoadedEventEnd),
344
- fullLoad : Math.round(nav.loadEventEnd),
345
- dnsLookup : Math.round(nav.domainLookupEnd - nav.domainLookupStart),
346
- tcpConnect : Math.round(nav.connectEnd - nav.connectStart),
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),
347
1098
  transferSize: nav.transferSize,
348
1099
  };
349
1100
  }).catch(() => ({}));
@@ -351,73 +1102,90 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
351
1102
  results.vitals = { ...vitals, ...navTiming, navDuration };
352
1103
  dash?.log(chalk.green(` ✓ Vitals: TTFB=${navTiming.ttfb||'?'}ms LCP=${vitals.lcp||'?'}ms FCP=${vitals.fcp||'?'}ms CLS=${vitals.cls??'?'}`));
353
1104
 
354
- // ── DOM Checks ───────────────────────────────────────────────────────
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 ────────────────────────────────────────────────────
355
1115
  dash?.log(chalk.cyan(' 🔍 Running DOM checks...'));
1116
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
356
1117
  const domChecks = await page.evaluate(() => {
357
1118
  const checks = [];
358
-
359
- // Title
360
1119
  const title = document.title;
361
1120
  checks.push({ name: 'Page title', pass: !!title && title.length > 0, value: title?.slice(0, 80) });
362
-
363
- // H1
364
1121
  const h1s = document.querySelectorAll('h1');
365
1122
  checks.push({ name: 'Single H1', pass: h1s.length === 1, value: `${h1s.length} H1 tags` });
366
-
367
- // Images without alt
368
- const imgs = document.querySelectorAll('img');
369
- const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
1123
+ const imgs = document.querySelectorAll('img');
1124
+ const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
370
1125
  checks.push({ name: 'Images alt text', pass: noAlt === 0, value: `${noAlt}/${imgs.length} missing alt` });
371
-
372
- // Buttons accessible
373
1126
  const btns = document.querySelectorAll('button');
374
1127
  const noText = [...btns].filter(b => !b.textContent?.trim() && !b.getAttribute('aria-label')).length;
375
1128
  checks.push({ name: 'Buttons accessible', pass: noText === 0, value: `${noText} buttons missing label` });
376
-
377
- // Links with href
378
- const links = document.querySelectorAll('a');
379
- const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
1129
+ const links = document.querySelectorAll('a');
1130
+ const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
380
1131
  checks.push({ name: 'Links have href', pass: noHref === 0, value: `${noHref}/${links.length} empty links` });
381
-
382
- // Forms with submit
383
1132
  const forms = document.querySelectorAll('form');
384
1133
  const noSubmit = [...forms].filter(f => !f.querySelector('[type="submit"], button')).length;
385
1134
  checks.push({ name: 'Forms have submit', pass: noSubmit === 0 || forms.length === 0, value: `${forms.length} forms` });
386
-
387
- // Meta viewport
388
1135
  const vp = document.querySelector('meta[name="viewport"]');
389
1136
  checks.push({ name: 'Viewport meta', pass: !!vp, value: vp?.content || 'missing' });
390
-
391
- // Color contrast check (heuristic)
392
- const body = document.body;
393
- const bodyStyle = window.getComputedStyle(body);
1137
+ const bodyStyle = window.getComputedStyle(document.body);
394
1138
  checks.push({ name: 'Body has styles', pass: !!bodyStyle.backgroundColor || !!bodyStyle.color, value: 'CSS applied' });
395
-
396
- // Broken internal links check
397
- const internalLinks = [...links].filter(l => {
398
- try { return new URL(l.href).origin === window.location.origin; } catch { return false; }
399
- });
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; } });
400
1157
  checks.push({ name: 'Internal links count', pass: true, value: `${internalLinks.length} internal links` });
401
-
402
1158
  return checks;
403
1159
  }).catch(() => []);
404
1160
 
405
1161
  results.domChecks = domChecks;
406
1162
  dash?.log(chalk.green(` ✓ DOM: ${domChecks.filter(c => c.pass).length}/${domChecks.length} checks passed`));
407
1163
 
408
- // ── Interaction Tests ────────────────────────────────────────────────
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 ─────────────────────────────────────────────
409
1183
  dash?.log(chalk.cyan(' 🖱️ Testing interactions...'));
410
1184
  const interactions = [];
411
-
412
- // Test all clickable buttons
413
- const buttonCount = await page.locator('button:visible').count().catch(() => 0);
1185
+ const buttonCount = await page.locator('button:visible').count().catch(() => 0);
414
1186
  interactions.push({ name: 'Visible buttons found', pass: true, value: `${buttonCount} buttons` });
415
-
416
- // Test form inputs exist
417
1187
  const inputCount = await page.locator('input:visible').count().catch(() => 0);
418
1188
  interactions.push({ name: 'Form inputs found', pass: true, value: `${inputCount} inputs` });
419
-
420
- // Test scroll behavior
421
1189
  try {
422
1190
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
423
1191
  await page.waitForTimeout(300);
@@ -426,8 +1194,6 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
426
1194
  } catch (err) {
427
1195
  interactions.push({ name: 'Page scroll', pass: false, value: err.message });
428
1196
  }
429
-
430
- // Test keyboard navigation (Tab key)
431
1197
  try {
432
1198
  await page.keyboard.press('Tab');
433
1199
  await page.waitForTimeout(100);
@@ -436,28 +1202,39 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
436
1202
  } catch {
437
1203
  interactions.push({ name: 'Keyboard navigation', pass: false, value: 'Tab focus failed' });
438
1204
  }
439
-
440
- // Hover test on first link
441
1205
  try {
442
1206
  const firstLink = page.locator('a:visible').first();
443
- if (await firstLink.count() > 0) {
444
- await firstLink.hover();
445
- interactions.push({ name: 'Link hover', pass: true, value: 'Hover works' });
446
- }
447
- } catch {
448
- interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' });
449
- }
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' }); }
450
1222
 
451
1223
  results.interactions = interactions;
452
1224
  dash?.log(chalk.green(` ✓ Interactions: ${interactions.filter(i => i.pass).length}/${interactions.length} passed`));
453
1225
 
454
- // ── Resource Analysis ─────────────────────────────────────────────────
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 ─────────────────────────────────────────────
455
1234
  const resourceStats = await page.evaluate(() => {
456
1235
  const entries = performance.getEntriesByType('resource');
457
1236
  const byType = {};
458
1237
  let totalSize = 0;
459
- let totalTime = 0;
460
-
461
1238
  for (const e of entries) {
462
1239
  const t = e.initiatorType || 'other';
463
1240
  if (!byType[t]) byType[t] = { count: 0, size: 0, time: 0, slow: [] };
@@ -465,15 +1242,18 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
465
1242
  byType[t].size += e.transferSize || 0;
466
1243
  byType[t].time += e.duration;
467
1244
  totalSize += e.transferSize || 0;
468
- totalTime += e.duration;
469
- if (e.duration > 500) {
470
- byType[t].slow.push({ url: e.name.split('/').pop().slice(0, 60), duration: Math.round(e.duration), size: e.transferSize || 0 });
471
- }
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 });
472
1246
  }
473
- return { byType, totalSize, totalTime: Math.round(totalTime), count: entries.length };
1247
+ return { byType, totalSize, count: entries.length };
474
1248
  }).catch(() => ({}));
475
1249
 
476
- results.resourceStats = resourceStats;
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 })));
477
1257
 
478
1258
  return { results, navDuration, error: null };
479
1259
 
@@ -490,7 +1270,7 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
490
1270
  // ═══════════════════════════════════════════════════════════════════════════
491
1271
  // Route Crawler — real HTTP crawl
492
1272
  // ═══════════════════════════════════════════════════════════════════════════
493
- async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
1273
+ async function crawlSite(baseUrl, { maxPages = 60, onRoute } = {}) {
494
1274
  const visited = new Set();
495
1275
  const queue = [{ url: baseUrl, depth: 0 }];
496
1276
  const routes = [];
@@ -501,16 +1281,17 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
501
1281
  while (queue.length > 0 && routes.length < maxPages) {
502
1282
  const { url, depth } = queue.shift();
503
1283
  const n = norm(url);
504
- if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
1284
+ if (!n || visited.has(n) || !sameOrigin(n) || depth > 4) continue;
505
1285
  visited.add(n);
506
1286
 
507
1287
  const r = await httpProbe(n, { timeout: 10000 });
508
1288
  const type = (() => {
509
- if (r.status >= 400) return 'error-page';
510
- if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
511
- if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
512
- if (/\/(login|signin|auth)/i.test(n)) return 'auth';
513
- if (/\/(admin)/i.test(n)) return 'admin';
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';
514
1295
  return 'page';
515
1296
  })();
516
1297
 
@@ -523,7 +1304,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
523
1304
  }
524
1305
  }
525
1306
 
526
- const forms = [];
1307
+ const forms = [];
527
1308
  const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
528
1309
  let fm;
529
1310
  while ((fm = formRe.exec(r.body)) !== null) {
@@ -539,7 +1320,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
539
1320
  forms.push({ action, method, fields });
540
1321
  }
541
1322
 
542
- 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 };
543
1324
  routes.push(route);
544
1325
  if (onRoute) onRoute(route);
545
1326
 
@@ -549,8 +1330,15 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
549
1330
  }
550
1331
  }
551
1332
 
552
- // Common paths probe
553
- 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
+ ];
554
1342
  for (const p2 of commonPaths) {
555
1343
  try {
556
1344
  const u = new URL(p2, baseUrl).toString();
@@ -559,7 +1347,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
559
1347
  visited.add(n);
560
1348
  const r = await httpProbe(u, { timeout: 5000 });
561
1349
  if (r.status > 0 && r.status < 500) {
562
- const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
1350
+ const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [], responseTime: r.responseTime };
563
1351
  routes.push(route);
564
1352
  if (onRoute) onRoute(route);
565
1353
  }
@@ -570,36 +1358,41 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
570
1358
  }
571
1359
 
572
1360
  // ═══════════════════════════════════════════════════════════════════════════
573
- // Security Scanner
1361
+ // Security Scanner v15 — Extended
574
1362
  // ═══════════════════════════════════════════════════════════════════════════
575
1363
  async function runSecurityScan(url) {
576
1364
  const findings = [];
577
1365
  const r = await httpProbe(url);
578
1366
 
579
1367
  if (!r.ok && r.status === 0) {
580
- return [{
581
- check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
582
- detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running',
583
- }];
1368
+ return [{ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
1369
+ detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running' }];
584
1370
  }
585
1371
 
586
1372
  const h = r.headers;
587
1373
 
588
1374
  const headerChecks = [
589
- { 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',
590
1376
  validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
591
- { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
1377
+ { id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
592
1378
  validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
593
- { 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',
594
1380
  validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
595
- { id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
1381
+ { id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
596
1382
  validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
597
- { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
1383
+ { id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
598
1384
  validate: v => !!v, rec: 'Add Referrer-Policy header' },
599
- { 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',
600
1388
  validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
601
- { id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
1389
+ { id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
602
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' },
603
1396
  ];
604
1397
 
605
1398
  for (const c of headerChecks) {
@@ -616,28 +1409,49 @@ async function runSecurityScan(url) {
616
1409
  findings.push({
617
1410
  check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
618
1411
  category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted',
619
- recommendation: 'Use HTTPS with valid SSL', evidence: { protocol: new URL(url).protocol },
1412
+ recommendation: 'Use HTTPS with valid SSL',
620
1413
  });
621
1414
 
622
1415
  const corsOrigin = h['access-control-allow-origin'];
623
1416
  const corsCreds = h['access-control-allow-credentials'];
624
1417
  const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
625
1418
  findings.push({
626
- check: 'CORS wildcard + credentials', pass: corsPass,
627
- severity: corsPass ? 'INFO' : 'P0', category: 'cors',
1419
+ check: 'CORS wildcard + credentials', pass: corsPass, severity: corsPass ? 'INFO' : 'P0', category: 'cors',
628
1420
  detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
629
1421
  recommendation: 'Never combine CORS * with allow-credentials',
630
- evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds },
631
1422
  });
632
1423
 
633
- const base = new URL(url).origin;
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
+ }
1432
+
1433
+ const base = new URL(url).origin;
634
1434
  const sensitives = [
635
- { path: '/.env', name: '.env exposed' },
636
- { path: '/.git/config', name: 'Git config exposed' },
637
- { path: '/phpinfo.php', name: 'phpinfo exposed' },
638
- { path: '/server-status', name: 'Apache server-status' },
639
- { path: '/actuator', name: 'Spring actuator exposed' },
640
- { path: '/graphql', name: 'GraphQL introspection' },
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' },
641
1455
  ];
642
1456
  for (const s of sensitives) {
643
1457
  try {
@@ -660,7 +1474,7 @@ async function runSecurityScan(url) {
660
1474
  }
661
1475
 
662
1476
  // ═══════════════════════════════════════════════════════════════════════════
663
- // SEO Scanner
1477
+ // SEO Scanner v15 — Extended
664
1478
  // ═══════════════════════════════════════════════════════════════════════════
665
1479
  async function runSEOScan(url) {
666
1480
  const t0 = Date.now();
@@ -674,60 +1488,71 @@ async function runSEOScan(url) {
674
1488
 
675
1489
  const title = get(/<title[^>]*>([^<]+)<\/title>/i);
676
1490
  checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
677
- detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>', data: { title, length: title?.length },
678
- recommendation: 'Add unique title (50-60 chars)' });
679
-
1491
+ detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>' });
680
1492
  if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
681
- severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)`,
682
- recommendation: 'Keep title 30-60 chars' });
1493
+ severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)` });
683
1494
 
684
1495
  const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
685
1496
  || get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
686
1497
  checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
687
- detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description',
688
- recommendation: 'Add meta description (120-160 chars)' });
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)` });
689
1501
 
690
1502
  const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
691
1503
  checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
692
- detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : '1 H1 ✓',
693
- recommendation: 'Use exactly one H1 per page' });
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` });
694
1509
 
695
1510
  const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
696
- checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
697
- detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
698
- recommendation: 'Add viewport meta tag' });
1511
+ checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile', detail: hasVP ? 'Viewport found' : 'Missing' });
699
1512
 
700
1513
  const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
701
- checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
702
- detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
1514
+ checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo', detail: lang ? `lang="${lang}"` : 'Missing' });
703
1515
 
704
1516
  const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
705
- checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
706
- detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
707
- recommendation: 'Add <link rel="canonical">' });
1517
+ checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo', detail: canonical ? `Canonical: ${canonical}` : 'Missing' });
708
1518
 
709
1519
  const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
710
- checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
711
- detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
712
- recommendation: 'Add og:title, og:description, og:image' });
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' });
1526
+
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' });
713
1529
 
714
1530
  const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
715
1531
  const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
716
1532
  checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
717
- detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
718
- recommendation: 'Add alt text to all images' });
1533
+ detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt` });
719
1534
 
720
1535
  checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
721
- category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
722
- recommendation: 'Optimize TTFB with CDN and caching' });
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' });
723
1549
 
724
1550
  const base = new URL(url).origin;
725
1551
  for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
726
1552
  try {
727
1553
  const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
728
1554
  checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
729
- detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}`,
730
- recommendation: `Ensure ${name} exists` });
1555
+ detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}` });
731
1556
  } catch {
732
1557
  checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
733
1558
  }
@@ -737,7 +1562,7 @@ async function runSEOScan(url) {
737
1562
  }
738
1563
 
739
1564
  // ═══════════════════════════════════════════════════════════════════════════
740
- // Accessibility Scanner — HTML analysis
1565
+ // Accessibility Scanner v15 Extended
741
1566
  // ═══════════════════════════════════════════════════════════════════════════
742
1567
  async function runA11yScan(url) {
743
1568
  const r = await httpProbe(url, { timeout: 12000 });
@@ -745,20 +1570,28 @@ async function runA11yScan(url) {
745
1570
  const violations = [], passes = [];
746
1571
 
747
1572
  const checks = [
748
- { id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
749
- { id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
750
- { id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
751
- { id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
752
- { id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
753
- { id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
754
- { id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
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' },
755
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' },
756
1589
  ];
757
1590
 
758
1591
  for (const c of checks) {
759
1592
  if (c.test()) {
760
1593
  violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
761
- tags: ['wcag2a'], category: 'wcag2a', nodes: 1, affectedNodes: [],
1594
+ tags: ['wcag2a'], category: 'wcag2a', nodes: 1,
762
1595
  helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
763
1596
  } else {
764
1597
  passes.push({ id: c.id, description: c.pass, nodes: 1 });
@@ -766,56 +1599,60 @@ async function runA11yScan(url) {
766
1599
  }
767
1600
 
768
1601
  const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
769
- return { pass: violations.length === 0, violations, passes, incomplete: [], score, url, mode: 'http-html-analysis' };
1602
+ return { pass: violations.length === 0, violations, passes, incomplete: [], score, url };
770
1603
  }
771
1604
 
772
1605
  // ═══════════════════════════════════════════════════════════════════════════
773
- // AI Bug Classifier
1606
+ // AI Bug Classifier v15 — Enhanced patterns
774
1607
  // ═══════════════════════════════════════════════════════════════════════════
775
1608
  const SEV_PATTERNS = {
776
- P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
777
- P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
778
- P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
779
- P3: [/warning|minor|style|typo|cosmetic/i],
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],
780
1613
  };
781
1614
  const CAT_PATTERNS = {
782
- security : /security|csp|hsts|cors|xss|injection|auth|token/i,
783
- performance : /lcp|fcp|cls|ttfb|slow|timeout|render/i,
784
- accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
785
- seo : /title|meta|description|canonical|sitemap|robots/i,
786
- api : /api|endpoint|status.*code|response|rest/i,
787
- javascript : /js.*error|console.*error|uncaught|undefined|null/i,
788
- network : /network|fetch|connection|request.*fail/i,
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,
789
1624
  };
790
1625
  function classifyBug(bug) {
791
1626
  const text = `${bug.title} ${bug.description || ''}`;
792
1627
  let severity = bug.severity || 'P3', confidence = 0.7;
793
1628
  for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
794
- 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; }
795
1630
  }
796
1631
  let category = bug.type || 'general';
797
1632
  for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
798
1633
  if (pat.test(text)) { category = cat; break; }
799
1634
  }
800
1635
  const recs = {
801
- security : 'Review security config and run penetration test',
802
- performance : 'Run Lighthouse and optimize assets/server',
803
- accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
804
- seo : 'Fix meta tags and submit sitemap to Search Console',
805
- api : 'Check API contract and add proper error handling',
806
- javascript : 'Debug in browser DevTools, add error boundaries',
807
- network : 'Check CDN, server logs, network config',
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',
808
1645
  };
809
- return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
1646
+ return { severity, category, recommendation: recs[category] || 'Review and fix error details', confidence };
810
1647
  }
811
1648
 
812
1649
  // ═══════════════════════════════════════════════════════════════════════════
813
- // Terminal Dashboard
1650
+ // Terminal Dashboard v15 — Enhanced live display
814
1651
  // ═══════════════════════════════════════════════════════════════════════════
815
1652
  class TerminalDashboard {
816
1653
  #session; #lines = 0; #active = false; #timer = null;
817
1654
  #phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
818
- #pwMode = false;
1655
+ #pwMode = false; #subPhase = '';
819
1656
 
820
1657
  constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
821
1658
 
@@ -823,7 +1660,7 @@ class TerminalDashboard {
823
1660
  this.#active = true; this.#startTime = Date.now();
824
1661
  process.stdout.write('\x1b[?25l');
825
1662
  this.#render();
826
- this.#timer = setInterval(() => this.#render(), 600);
1663
+ this.#timer = setInterval(() => this.#render(), 400);
827
1664
  }
828
1665
 
829
1666
  stop() {
@@ -834,12 +1671,13 @@ class TerminalDashboard {
834
1671
  this.#printFinal();
835
1672
  }
836
1673
 
837
- 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; }
838
1676
  setCurrentTest(t) { this.#currentTest = t; }
839
1677
  addResult() { this.#currentTest = ''; }
840
1678
  log(msg) {
841
1679
  this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
842
- if (this.#log.length > 8) this.#log.shift();
1680
+ if (this.#log.length > 10) this.#log.shift();
843
1681
  }
844
1682
 
845
1683
  #render() {
@@ -867,48 +1705,53 @@ class TerminalDashboard {
867
1705
  const total = s.results.length;
868
1706
  const rate = total > 0 ? Math.round(passed / total * 100) : 0;
869
1707
  const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
870
- const w = Math.min(process.stdout.columns || 80, 88);
1708
+ const w = Math.min(process.stdout.columns || 90, 92);
871
1709
  const bar = '─'.repeat(w - 2);
872
1710
  const c1 = chalk.hex('#00F5FF');
873
1711
  const c2 = chalk.hex('#BF40FF');
874
1712
  const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
875
- const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 PLAYWRIGHT') : chalk.gray(' HTTP');
1713
+ const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 BACKLIST') : chalk.gray(' HTTP');
1714
+
1715
+ const spin = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'][Math.floor(Date.now() / 100) % 10];
876
1716
 
877
1717
  const pBar = (() => {
878
- const f = Math.min(Math.round(rate / 100 * 26), 26);
1718
+ const f = Math.min(Math.round(rate / 100 * 30), 30);
879
1719
  const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
880
- return col('█'.repeat(f)) + chalk.gray('░'.repeat(26 - f));
1720
+ return col('█'.repeat(f)) + chalk.gray('░'.repeat(30 - f));
881
1721
  })();
882
1722
 
883
1723
  const out = [
884
1724
  c1(`┌${bar}┐`),
885
- c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — REAL BROWSER TESTING${pwTag}`)) + c1('│'),
1725
+ c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — ULTRA LIVE TESTING EDITION${pwTag}`)) + c1('│'),
886
1726
  c1(`├${bar}┤`),
887
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('│'),
888
1731
  c1(`├${bar}┤`),
889
- c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
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('│'),
890
1733
  c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1('│'),
891
1734
  c1(`├${bar}┤`),
892
- 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('│'),
893
1736
  c1(`├${bar}┤`),
894
- c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('Screenshots:')} ${chalk.white(s.screenshots.length)} ${chalk.cyan('Bugs:')} ${chalk.white(s.bugs.length)} ${chalk.cyan('Net Errors:')} ${chalk.white(s.networkLog.length)}`) + c1('│'),
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('│'),
895
1738
  c1(`├${bar}┤`),
896
1739
  ];
897
1740
 
898
- const recent = s.results.slice(-5);
1741
+ const recent = s.results.slice(-6);
899
1742
  for (const r of recent) {
900
1743
  const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
901
- out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(12) + ']')} ${chalk.white((r.name||'').slice(0, w - 30))}`) + c1('│'));
1744
+ out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(14) + ']')} ${chalk.white((r.name||'').slice(0, w - 32))}`) + c1('│'));
902
1745
  }
903
- 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('│'));
904
1747
 
905
1748
  out.push(c1(`├${bar}┤`));
906
- for (const entry of this.#log.slice(-4)) {
1749
+ for (const entry of this.#log.slice(-5)) {
907
1750
  out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
908
1751
  }
909
- 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('│'));
910
1753
  out.push(c1(`└${bar}┘`));
911
- out.push(chalk.dim(` Real browser 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`));
912
1755
 
913
1756
  return out;
914
1757
  }
@@ -917,21 +1760,24 @@ class TerminalDashboard {
917
1760
  const s = this.#session.getSummary();
918
1761
  const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
919
1762
  console.log('');
920
- console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
1763
+ console.log(chalk.hex('#00F5FF').bold(' ── QA Complete (v15) ───────────────────────────────────────'));
921
1764
  console.log(` Tests: ${chalk.white.bold(s.total)}`);
922
1765
  console.log(` Passed: ${chalk.green.bold(s.passed)}`);
923
1766
  console.log(` Failed: ${chalk.red.bold(s.failed)}`);
924
1767
  console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
925
1768
  console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
926
- console.log(` Screenshots: ${chalk.white(this.#session.screenshots.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')}`);
927
1773
  console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
928
- console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Playwright (Real Browser)') : chalk.gray('HTTP-only')}`);
1774
+ console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Backlist (Real Browser)') : chalk.gray('HTTP-only')}`);
929
1775
  console.log('');
930
1776
  }
931
1777
  }
932
1778
 
933
1779
  // ═══════════════════════════════════════════════════════════════════════════
934
- // HTML Report Builder — v13, Dark Theme, Screenshot Gallery + Vitals
1780
+ // HTML Report Builder v15 Ultra Rich
935
1781
  // ═══════════════════════════════════════════════════════════════════════════
936
1782
  function buildHTMLReport(session) {
937
1783
  const summary = session.getSummary();
@@ -954,25 +1800,33 @@ function buildHTMLReport(session) {
954
1800
  const esc = (s) => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
955
1801
 
956
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
+
957
1810
  const screenshotCards = session.screenshots.length
958
1811
  ? session.screenshots.map(sc => {
959
- // Embed screenshot as base64 if possible, else show path
960
1812
  let imgTag = '';
961
1813
  try {
962
1814
  const data = fs.readFileSync(sc.path);
963
- const b64 = data.toString('base64');
964
- imgTag = `<img src="data:image/png;base64,${b64}" alt="${esc(sc.type)} screenshot" loading="lazy">`;
1815
+ imgTag = `<img src="data:image/png;base64,${data.toString('base64')}" alt="${esc(sc.type)}" loading="lazy">`;
965
1816
  } catch {
966
- imgTag = `<div class="no-img">Screenshot: ${esc(sc.name)}</div>`;
1817
+ imgTag = `<div class="no-img">📸 ${esc(sc.name)}</div>`;
967
1818
  }
968
- return `
969
- <div class="screenshot-card">
970
- <div class="sc-header">
971
- <span class="sc-type">${esc(sc.type)}</span>
972
- <span class="sc-url">${esc(sc.url || '')}</span>
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>
973
1827
  </div>
974
1828
  <div class="sc-img-wrap">${imgTag}</div>
975
- <div class="sc-path">${esc(sc.path)}</div>
1829
+ <div class="sc-path">${esc(sc.name)}</div>
976
1830
  </div>`;
977
1831
  }).join('')
978
1832
  : '<p class="no-data">No screenshots (Playwright not available)</p>';
@@ -995,7 +1849,7 @@ function buildHTMLReport(session) {
995
1849
  <div class="bug-header">
996
1850
  <span class="bug-id">${esc(b.id)}</span>
997
1851
  <span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
998
- <span class="badge">${b.type||'general'}</span>
1852
+ <span class="badge">${b.aiCategory||b.type||'general'}</span>
999
1853
  ${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
1000
1854
  </div>
1001
1855
  <div class="bug-title">${esc(b.title)}</div>
@@ -1011,6 +1865,7 @@ function buildHTMLReport(session) {
1011
1865
  <td><code class="url">${esc(r.url)}</code></td>
1012
1866
  <td><span class="badge">${r.type}</span></td>
1013
1867
  <td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
1868
+ <td>${r.responseTime ? `${r.responseTime}ms` : '–'}</td>
1014
1869
  <td>${r.forms?.length || 0}</td>
1015
1870
  <td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
1016
1871
  </tr>`).join('');
@@ -1051,14 +1906,10 @@ function buildHTMLReport(session) {
1051
1906
  <span>Score: <strong>${r.score??'–'}%</strong></span>
1052
1907
  <span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
1053
1908
  </div>
1054
- ${(r.violations||[]).map(v => `
1055
- <div class="violation impact-${v.impact}">
1056
- <div class="violation-header">
1057
- <span class="impact-badge">${v.impact}</span>
1058
- <strong>${esc(v.description)}</strong>
1059
- </div>
1060
- <p>${esc(v.help)}</p>
1061
- </div>`).join('') || '<p class="no-data">No violations ✓</p>'}
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>'}
1062
1913
  </div>`).join('') || '<p class="no-data">No accessibility scans</p>';
1063
1914
 
1064
1915
  // ── Performance section ───────────────────────────────────────────────────
@@ -1076,46 +1927,29 @@ function buildHTMLReport(session) {
1076
1927
  };
1077
1928
 
1078
1929
  const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => {
1079
- const slowResHtml = (m.slowResources||[]).length ? `
1080
- <h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
1081
- <table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
1082
- <tbody>${m.slowResources.map(r => `<tr>
1083
- <td class="url">${esc((r.url||'').split('/').pop())}</td>
1084
- <td class="fail">${r.duration}ms</td>
1085
- <td>${formatBytes(r.size)}</td>
1086
- </tr>`).join('')}</tbody></table>` : '';
1087
-
1088
- const resourceTableHtml = m.resourceStats?.byType ? `
1089
- <h4 style="color:#94a3b8;margin-top:1.5rem">Resource Breakdown</h4>
1090
- <table><thead><tr><th>Type</th><th>Count</th><th>Total Size</th><th>Total Time</th></tr></thead>
1091
- <tbody>${Object.entries(m.resourceStats.byType).map(([t, d]) => `<tr>
1092
- <td><span class="badge">${esc(t)}</span></td>
1093
- <td>${d.count}</td>
1094
- <td>${formatBytes(d.size)}</td>
1095
- <td>${Math.round(d.time)}ms</td>
1096
- </tr>`).join('')}</tbody></table>` : '';
1097
-
1098
1930
  const domChecksHtml = m.domChecks?.length ? `
1099
1931
  <h4 style="color:#94a3b8;margin-top:1.5rem">DOM Checks</h4>
1100
1932
  <table><thead><tr><th>Check</th><th>Status</th><th>Value</th></tr></thead>
1101
- <tbody>${m.domChecks.map(c => `<tr>
1102
- <td>${esc(c.name)}</td>
1933
+ <tbody>${m.domChecks.map(c => `<tr><td>${esc(c.name)}</td>
1103
1934
  <td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
1104
- <td>${esc(c.value||'')}</td>
1105
- </tr>`).join('')}</tbody></table>` : '';
1935
+ <td>${esc(c.value||'')}</td></tr>`).join('')}</tbody></table>` : '';
1106
1936
 
1107
1937
  const interactionsHtml = m.interactions?.length ? `
1108
1938
  <h4 style="color:#94a3b8;margin-top:1.5rem">Interaction Tests</h4>
1109
1939
  <table><thead><tr><th>Test</th><th>Status</th><th>Value</th></tr></thead>
1110
- <tbody>${m.interactions.map(i => `<tr>
1111
- <td>${esc(i.name)}</td>
1940
+ <tbody>${m.interactions.map(i => `<tr><td>${esc(i.name)}</td>
1112
1941
  <td><span class="status ${i.pass?'status-pass':'status-fail'}">${i.pass?'PASS':'FAIL'}</span></td>
1113
- <td>${esc(i.value||'')}</td>
1114
- </tr>`).join('')}</tbody></table>` : '';
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>` : '';
1115
1950
 
1116
- return `
1117
- <div class="perf-card">
1118
- <h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Playwright</span>' : ''}</h3>
1951
+ return `<div class="perf-card">
1952
+ <h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Real Vitals</span>' : ''}</h3>
1119
1953
  <div class="vitals-grid">
1120
1954
  ${vitalCard('TTFB', m.ttfb, 800, 'ms')}
1121
1955
  ${vitalCard('LCP', m.lcp, 2500, 'ms')}
@@ -1124,15 +1958,195 @@ function buildHTMLReport(session) {
1124
1958
  ${vitalCard('TBT', m.tbt, 200, 'ms')}
1125
1959
  ${vitalCard('DOM Load', m.domLoad, 3000, 'ms')}
1126
1960
  ${vitalCard('DNS', m.dnsLookup, 100, 'ms')}
1961
+ ${vitalCard('TCP', m.tcpConnect, 200, 'ms')}
1127
1962
  </div>
1128
1963
  ${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
1129
- ${slowResHtml}
1130
- ${resourceTableHtml}
1131
- ${domChecksHtml}
1132
- ${interactionsHtml}
1964
+ ${resourceHtml}${domChecksHtml}${interactionsHtml}
1133
1965
  </div>`;
1134
1966
  }).join('') || '<p class="no-data">No performance data</p>';
1135
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
+
1136
2150
  // ── Console errors table ──────────────────────────────────────────────────
1137
2151
  const consoleSection = session.consoleErrors.length
1138
2152
  ? `<table>
@@ -1157,23 +2171,37 @@ function buildHTMLReport(session) {
1157
2171
  </table>`
1158
2172
  : '<p class="no-data">No network failures 🎉</p>';
1159
2173
 
1160
- const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
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>';
2184
+
2185
+ const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
1161
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('');
1162
2187
 
1163
2188
  const chartTypes = JSON.stringify(Object.keys(coverage));
1164
2189
  const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
1165
2190
  const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
1166
2191
  const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
1167
- const pwBadge = session.playwrightMode
1168
- ? '<span style="background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44;padding:3px 10px;border-radius:20px;font-size:.7rem">🎭 Playwright</span>'
1169
- : '<span style="background:#1e293b;color:#64748b;padding:3px 10px;border-radius:20px;font-size:.7rem">HTTP-only</span>';
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);
1170
2198
 
1171
2199
  return `<!DOCTYPE html>
1172
2200
  <html lang="en">
1173
2201
  <head>
1174
2202
  <meta charset="UTF-8">
1175
2203
  <meta name="viewport" content="width=device-width,initial-scale=1">
1176
- <title>Backlist QA Report — ${esc(session.id)}</title>
2204
+ <title>Backlist QA v15 Report — ${esc(session.id)}</title>
1177
2205
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
1178
2206
  <style>
1179
2207
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
@@ -1182,96 +2210,106 @@ function buildHTMLReport(session) {
1182
2210
  body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
1183
2211
  a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
1184
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)}
1185
- .logo{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,var(--cyan),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
1186
- .header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
1187
- nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
1188
- .nav-tab{padding:.75rem 1.25rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.82rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s;font-family:'Syne',sans-serif}
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}
1189
2218
  .nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
1190
2219
  .container{max-width:1400px;margin:0 auto;padding:2rem}
1191
2220
  .tab-panel{display:none}.tab-panel.active{display:block}
1192
- .pw-banner{background:rgba(191,64,255,.08);border:1px solid #bf40ff44;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:#c084fc;display:flex;align-items:center;gap:.5rem}
1193
- .real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.83rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
1194
- .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
1195
- .mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
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}
1196
2225
  .mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
1197
- .ml{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
1198
- .mv{font-size:1.8rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
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}
1199
2228
  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
1200
2229
  .card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
1201
- .card-title{font-size:.9rem;font-weight:700;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
1202
- .chart-wrap{position:relative;height:240px}
1203
- .search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
1204
- .search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.83rem;flex:1;font-family:'Syne',sans-serif}
1205
- table{width:100%;border-collapse:collapse;font-size:.8rem}
1206
- th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em}
1207
- td{padding:.45rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
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}
1208
2237
  tr.fail-row td{background:rgba(239,68,68,.04)}
1209
2238
  .pass{color:var(--green)}.fail{color:var(--red)}
1210
- .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace}
2239
+ .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.68rem;font-weight:700;font-family:'JetBrains Mono',monospace}
1211
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}
1212
- .sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
1213
- .sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
1214
- .badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1e293b;color:#94a3b8}
1215
- .pw-badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
1216
- .url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
1217
- code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
1218
- pre{white-space:pre-wrap;word-break:break-all;font-size:.73rem;padding:.75rem;background:#080814;border-radius:6px;overflow-x:auto;max-height:300px;font-family:'JetBrains Mono',monospace}
1219
- details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:none}
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}
1220
2249
  .bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
1221
2250
  .bug-card:hover{border-left-color:var(--cyan)}
1222
2251
  .sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
1223
2252
  .sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
1224
2253
  .sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
1225
2254
  .bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
1226
- .bug-id{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim)}
2255
+ .bug-id{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--dim)}
1227
2256
  .bug-title{font-weight:700;margin-bottom:.3rem}
1228
- .bug-url{font-size:.75rem;margin-bottom:.3rem}
1229
- .bug-rec{font-size:.78rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
1230
- .ai-badge{font-size:.68rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
1231
- .rec{font-size:.75rem;color:#86efac}
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}
1232
2261
  .no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
1233
2262
  .url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
1234
- .url-label{font-size:.7rem;color:var(--dim);text-transform:uppercase;min-width:90px}
1235
- /* Screenshot gallery */
1236
- .screenshot-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:1.25rem;margin-top:1rem}
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}
1237
2265
  .screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:.2s}
1238
- .screenshot-card:hover{border-color:var(--purple);transform:translateY(-3px);box-shadow:0 8px 32px rgba(191,64,255,.15)}
1239
- .sc-header{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--border)}
1240
- .sc-type{font-size:.7rem;padding:2px 8px;border-radius:4px;background:#1a1a3b;color:#c084fc;text-transform:uppercase;font-weight:700}
1241
- .sc-url{font-size:.72rem;color:var(--dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;max-width:240px}
1242
- .sc-img-wrap{background:#000;min-height:200px;display:flex;align-items:center;justify-content:center;overflow:hidden}
1243
- .sc-img-wrap img{width:100%;height:auto;display:block;max-height:400px;object-fit:cover}
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}
1244
2272
  .no-img{color:var(--dim);font-style:italic;padding:2rem;text-align:center}
1245
- .sc-path{font-family:'JetBrains Mono',monospace;font-size:.67rem;color:var(--dim);padding:.5rem 1rem;background:#080810}
1246
- /* Vitals */
1247
- .vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
1248
- .vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
1249
- .vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
1250
- .vital-label{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
1251
- .vital-threshold{font-size:.68rem;color:var(--dim);margin-top:2px}
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}
1252
2279
  .vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
1253
2280
  .vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
1254
2281
  .vital-na{background:var(--surface)}
1255
2282
  .perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
1256
2283
  .perf-card h3{color:var(--cyan);margin-bottom:.5rem}
1257
- .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}
1258
2288
  .seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
1259
- .seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.85rem}
2289
+ .seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.83rem}
1260
2290
  .violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
1261
2291
  .impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
1262
2292
  .impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
1263
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)}
1264
2295
  .violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
1265
- .impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
1266
- .err-cell details{font-size:.78rem}
1267
- footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem;font-family:'JetBrains Mono',monospace}
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}
1268
2306
  @media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
1269
2307
  </style>
1270
2308
  </head>
1271
2309
  <body>
1272
2310
  <header>
1273
2311
  <div>
1274
- <div class="logo">⚡ Backlist Enterprise QA</div>
2312
+ <div class="logo">⚡ Backlist Enterprise QA v15</div>
1275
2313
  <div class="header-meta">
1276
2314
  Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
1277
2315
  </div>
@@ -1282,21 +2320,34 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
1282
2320
  <nav>
1283
2321
  <button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
1284
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>
1285
2324
  <button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
1286
2325
  <button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
1287
2326
  <button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
1288
2327
  <button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
1289
2328
  <button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
2329
+ <button class="nav-tab" onclick="showTab('loadtest',this)">🔥 Load Test</button>
1290
2330
  <button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
1291
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>
1292
2343
  <button class="nav-tab" onclick="showTab('console',this)">🖥️ Console (${session.consoleErrors.length})</button>
1293
2344
  <button class="nav-tab" onclick="showTab('network',this)">📡 Network</button>
1294
2345
  </nav>
1295
2346
 
1296
2347
  <div class="container">
1297
2348
 
1298
- ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real Browser Mode</strong> — Screenshots, Web Vitals, DOM tests, Interaction tests captured from live Chromium browser</div>' : ''}
1299
- <div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live application testing.</div>
2349
+ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>BACKLIST 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>
1300
2351
 
1301
2352
  <!-- OVERVIEW -->
1302
2353
  <div id="tab-overview" class="tab-panel active">
@@ -1310,10 +2361,14 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1310
2361
  <div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
1311
2362
  <div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
1312
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>
1313
2365
  <div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.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>
1314
2367
  <div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
1315
- <div class="mc"><div class="ml">Console Errors</div><div class="mv" style="color:${session.consoleErrors.length>0?'var(--yellow)':'var(--green)'}">${session.consoleErrors.length}</div></div>
1316
- <div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
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>
1317
2372
  </div>
1318
2373
  <div class="grid2">
1319
2374
  <div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
@@ -1324,12 +2379,19 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1324
2379
  <!-- SCREENSHOTS -->
1325
2380
  <div id="tab-screenshots" class="tab-panel">
1326
2381
  <div class="card">
1327
- <div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured</span></div>
1328
- ${session.playwrightMode ? '' : '<div class="perf-note" style="margin-bottom:1rem">⚠️ Screenshots require Playwright. Install: <code>npm install playwright && npx playwright install chromium</code></div>'}
2382
+ <div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured (${vpCount} viewports + dark mode)</span></div>
1329
2383
  <div class="screenshot-gallery">${screenshotCards}</div>
1330
2384
  </div>
1331
2385
  </div>
1332
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
+
1333
2395
  <!-- TESTS -->
1334
2396
  <div id="tab-tests" class="tab-panel">
1335
2397
  <div class="search-bar">
@@ -1362,6 +2424,10 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1362
2424
  <option value="P0">P0 Critical</option><option value="P1">P1 High</option>
1363
2425
  <option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
1364
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>
1365
2431
  </div>
1366
2432
  <div id="bugList">${bugCards}</div>
1367
2433
  </div>
@@ -1371,8 +2437,8 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1371
2437
  <div class="card">
1372
2438
  <div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
1373
2439
  <table>
1374
- <thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
1375
- <tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
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>
1376
2442
  </table>
1377
2443
  </div>
1378
2444
  </div>
@@ -1380,7 +2446,7 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1380
2446
  <!-- SECURITY -->
1381
2447
  <div id="tab-security" class="tab-panel">
1382
2448
  <div class="card">
1383
- <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>
1384
2450
  <table>
1385
2451
  <thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
1386
2452
  <tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
@@ -1390,22 +2456,102 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1390
2456
 
1391
2457
  <!-- PERFORMANCE -->
1392
2458
  <div id="tab-performance" class="tab-panel">
1393
- <div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals + Resource Analysis</div>
2459
+ <div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals (Playwright Chromium)</div>
1394
2460
  ${perfSection}
1395
2461
  </div>
1396
2462
 
1397
- <!-- 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 -->
1398
2470
  <div id="tab-a11y" class="tab-panel">
1399
- <div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG HTML Analysis</div>
2471
+ <div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG 2.1 HTML Analysis (15 rules)</div>
1400
2472
  ${a11ySection}
1401
2473
  </div>
1402
2474
 
1403
2475
  <!-- SEO -->
1404
2476
  <div id="tab-seo" class="tab-panel">
1405
- <div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent</div>
2477
+ <div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent (21 checks)</div>
1406
2478
  ${seoSection}
1407
2479
  </div>
1408
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}
2503
+ </div>
2504
+
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
+
1409
2555
  <!-- CONSOLE -->
1410
2556
  <div id="tab-console" class="tab-panel">
1411
2557
  <div class="card">
@@ -1424,7 +2570,7 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
1424
2570
 
1425
2571
  </div>
1426
2572
 
1427
- <footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${session.screenshots.length} screenshots · ${new Date().toLocaleString()}</footer>
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>
1428
2574
 
1429
2575
  <script>
1430
2576
  function showTab(name, el) {
@@ -1444,28 +2590,31 @@ function filterTests() {
1444
2590
  function filterBugs() {
1445
2591
  const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
1446
2592
  const sv = document.getElementById('bugSev')?.value||'';
2593
+ const ca = document.getElementById('bugCat')?.value||'';
1447
2594
  document.querySelectorAll('#bugList .bug-card').forEach(card => {
1448
- card.style.display = (card.textContent.toLowerCase().includes(s) && (!sv || card.dataset.severity===sv)) ? '' : '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';
1449
2598
  });
1450
2599
  }
1451
2600
  const chartCfg = {
1452
- plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
1453
- scales:{x:{ticks:{color:'#64748b'},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1},grid:{color:'#1e293b'},beginAtZero:true}}
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}}
1454
2603
  };
1455
2604
  new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
1456
2605
  {label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
1457
2606
  {label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
1458
2607
  ]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
1459
- new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}}}});
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}}}}}});
1460
2609
  </script>
1461
2610
  </body>
1462
2611
  </html>`;
1463
2612
  }
1464
2613
 
1465
2614
  // ═══════════════════════════════════════════════════════════════════════════
1466
- // Main QA Runner — v13 with Playwright integration
2615
+ // Main QA Runner v15 All phases
1467
2616
  // ═══════════════════════════════════════════════════════════════════════════
1468
- async function runQAEngine(session) {
2617
+ async function runQAEngine(session, opts = {}) {
1469
2618
  const dash = new TerminalDashboard(session);
1470
2619
  dash.start();
1471
2620
 
@@ -1484,161 +2633,206 @@ async function runQAEngine(session) {
1484
2633
  dash.log(`Crawling ${label}: ${url}`);
1485
2634
  const t0 = Date.now();
1486
2635
  const routes = await crawlSite(url, {
1487
- maxPages: 50,
2636
+ maxPages: 60,
1488
2637
  onRoute: (route) => {
1489
2638
  session.routeMap.push(route);
1490
- dash.log(` Found: ${route.url} (${route.type})`);
2639
+ dash.setSubPhase(`Found: ${route.url} (${route.type})`);
1491
2640
  },
1492
2641
  });
1493
- addResult({ name: `[${label}] Route Discovery`, type: 'discovery', category: 'crawl',
2642
+ addResult({ name: `[${label}] Route Discovery`, type: 'discovery',
1494
2643
  status: routes.length > 0 ? 'PASS' : 'FAIL',
1495
2644
  message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
1496
2645
  }
1497
2646
 
1498
- // ── Phase 2: Playwright Real Browser Tests ───────────────────────────
1499
- dash.setPhase('🎭 Phase 2: Playwright Real Browser Tests');
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');
1500
2665
  const chromium = await getPlaywright();
1501
2666
 
1502
2667
  if (chromium) {
1503
2668
  session.playwrightMode = true;
1504
- dash.log(chalk.hex('#BF40FF')(' 🎭 Playwright available! Running real browser tests...'));
2669
+ dash.log(chalk.hex('#BF40FF')(' 🎭 Backlist available! Real browser mode ACTIVE'));
1505
2670
 
1506
2671
  for (const [label, url] of Object.entries(session.urls)) {
1507
2672
  if (!url) continue;
1508
2673
  dash.setCurrentTest(`🎭 Browser: ${url}`);
1509
- dash.log(chalk.cyan(` Launching Chromium for ${label}...`));
1510
2674
 
1511
2675
  const pwResult = await runPlaywrightScan(url, session, dash);
1512
2676
 
1513
2677
  if (pwResult && !pwResult.error) {
1514
2678
  const { results: pw } = pwResult;
1515
2679
 
1516
- // Store playwright perf data merged with session
1517
2680
  session.perfMetrics[label] = {
1518
2681
  ...session.perfMetrics[label],
1519
2682
  ...pw.vitals,
1520
- slowResources : pw.networkFails.filter(n => n.duration > 1000),
1521
- resourceStats : pw.resourceStats,
1522
- domChecks : pw.domChecks,
1523
- interactions : pw.interactions,
2683
+ slowResources: pw.networkFails.filter(n => n.duration > 1000),
2684
+ resourceStats: pw.resourceStats,
2685
+ domChecks: pw.domChecks,
2686
+ interactions: pw.interactions,
1524
2687
  playwrightMode: true,
1525
2688
  };
1526
2689
 
1527
- // Add DOM check results
1528
2690
  for (const check of pw.domChecks || []) {
1529
- addResult({ name: `DOM: ${check.name}`, type: 'browser-dom', category: 'playwright',
2691
+ addResult({ name: `DOM: ${check.name}`, type: 'browser-dom',
1530
2692
  status: check.pass ? 'PASS' : 'FAIL', message: check.value, url, label });
1531
2693
  }
1532
2694
 
1533
- // Add interaction results
1534
- for (const interaction of pw.interactions || []) {
1535
- addResult({ name: `Interaction: ${interaction.name}`, type: 'browser-interaction', category: 'playwright',
1536
- status: interaction.pass ? 'PASS' : 'FAIL', message: interaction.value, url, label });
1537
- if (!interaction.pass) {
1538
- session.addBug({ title: `Interaction Failed: ${interaction.name}`,
1539
- severity: 'P2', type: 'javascript', url, evidence: { value: interaction.value } });
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 } });
1540
2707
  }
1541
2708
  }
1542
2709
 
1543
- // Add network failure results
1544
- for (const fail of pw.networkFails || []) {
1545
- addResult({ name: `Network Fail: ${fail.url?.split('/').pop()?.slice(0,40)}`, type: 'network', category: 'playwright',
1546
- status: 'FAIL', message: fail.failure || `HTTP ${fail.status}`, url: fail.url, label });
1547
- session.addBug({ title: `Network Failure: ${fail.url?.split('/').pop()}`,
1548
- severity: fail.status >= 500 ? 'P1' : 'P2', type: 'network', url: fail.url,
1549
- evidence: { status: fail.status, failure: fail.failure } });
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
+ }
1550
2716
  }
1551
2717
 
1552
- // Add console error results
1553
- for (const err of pw.jsErrors || []) {
1554
- addResult({ name: `JS Error: ${err.message?.slice(0,60)}`, type: 'javascript', category: 'playwright',
1555
- status: 'FAIL', message: err.message, url, label, severity: 'P2' });
1556
- session.addBug({ title: `JS Error: ${err.message?.slice(0,80)}`,
1557
- severity: 'P2', type: 'javascript', url, evidence: { message: err.message, stack: err.stack?.slice(0,200) } });
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 });
1558
2723
  }
1559
2724
 
1560
- // Web vitals results
1561
- const { lcp, fcp, cls, tbt, ttfb } = pw.vitals || {};
1562
- if (ttfb !== undefined && ttfb !== null) {
1563
- addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
1564
- status: ttfb <= 800 ? 'PASS' : 'FAIL', message: `TTFB: ${ttfb}ms`, url, label, duration: ttfb });
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 });
1565
2731
  }
1566
- if (lcp !== undefined && lcp !== null) {
1567
- addResult({ name: `[${label}] LCP`, type: 'performance', category: 'web-vitals',
1568
- status: lcp <= 2500 ? 'PASS' : 'FAIL', message: `LCP: ${lcp}ms (≤2500ms)`, url, label });
1569
- if (lcp > 2500) session.addBug({ title: `Poor LCP: ${lcp}ms`, severity: lcp > 4000 ? 'P1' : 'P2',
1570
- type: 'performance', url, evidence: { lcp }, recommendation: 'Optimize largest contentful paint' });
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 });
1571
2737
  }
1572
- if (fcp !== undefined && fcp !== null) {
1573
- addResult({ name: `[${label}] FCP`, type: 'performance', category: 'web-vitals',
1574
- status: fcp <= 1800 ? 'PASS' : 'FAIL', message: `FCP: ${fcp}ms (≤1800ms)`, url, label });
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 });
1575
2743
  }
1576
- if (cls !== undefined && cls !== null) {
1577
- addResult({ name: `[${label}] CLS`, type: 'performance', category: 'web-vitals',
1578
- status: cls <= 0.1 ? 'PASS' : 'FAIL', message: `CLS: ${cls} (≤0.1)`, url, label });
1579
- if (cls > 0.1) session.addBug({ title: `High CLS: ${cls}`, severity: 'P2', type: 'performance',
1580
- url, evidence: { cls }, recommendation: 'Fix layout shifts — set image dimensions, avoid dynamic content insertion' });
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 } });
1581
2750
  }
1582
- if (tbt !== undefined && tbt !== null) {
1583
- addResult({ name: `[${label}] TBT`, type: 'performance', category: 'web-vitals',
1584
- status: tbt <= 200 ? 'PASS' : 'FAIL', message: `TBT: ${tbt}ms (≤200ms)`, url, label });
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 });
1585
2757
  }
1586
2758
 
1587
- addResult({ name: `[${label}] Playwright Scan`, type: 'browser', category: 'playwright',
1588
- status: 'PASS', message: `${pw.screenshots?.length || 0} screenshots, ${pw.domChecks?.length || 0} DOM checks`, url, label });
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 });
1589
2790
 
1590
- dash.log(chalk.green(` ✅ Playwright scan complete for ${label}`));
1591
2791
  } else {
1592
- dash.log(chalk.yellow(` ⚠ Playwright scan failed: ${pwResult?.error || 'unknown error'}`));
1593
2792
  addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'FAIL',
1594
2793
  message: pwResult?.error || 'Playwright scan failed', url, label });
1595
2794
  }
1596
2795
  }
1597
2796
  } else {
1598
- dash.log(chalk.yellow(' ⚠ Playwright not installed. HTTP-only mode.'));
1599
- dash.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
1600
- // Fallback: HTTP TTFB
2797
+ dash.log(chalk.yellow(' ⚠ Playwright not installed HTTP-only mode'));
1601
2798
  for (const [label, url] of Object.entries(session.urls)) {
1602
2799
  if (!url) continue;
1603
2800
  const t0 = Date.now();
1604
2801
  const r = await httpProbe(url, { timeout: 15000 });
1605
2802
  const ttfb = Date.now() - t0;
1606
- session.perfMetrics[label] = { ttfb, bodySize: r.bodySize, statusCode: r.status,
1607
- slowResources: [], note: 'Install Playwright for real Web Vitals (LCP, FCP, CLS)' };
1608
- addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
1609
- status: ttfb <= 800 ? 'PASS' : 'FAIL',
1610
- message: `TTFB: ${ttfb}ms (threshold: ≤800ms)`, url, label, duration: ttfb });
1611
- if (ttfb > 800) session.addBug({ title: `Slow TTFB: ${ttfb}ms`, severity: ttfb > 2000 ? 'P1' : 'P2',
1612
- type: 'performance', url, evidence: { ttfb }, recommendation: 'Optimize server response time' });
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 });
1613
2807
  }
1614
2808
  }
1615
2809
 
1616
- // ── Phase 3: API Validation ──────────────────────────────────────────
1617
- dash.setPhase('📡 Phase 3: API Validation');
2810
+ // ── Phase 4: API Validation ──────────────────────────────────────────
2811
+ dash.setPhase('📡 Phase 4: API Validation & Contract Testing');
1618
2812
  const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
1619
2813
  dash.log(`Validating ${apiRoutes.length} API endpoints...`);
1620
2814
  for (const route of apiRoutes) {
1621
2815
  dash.setCurrentTest(`API: ${route.url}`);
1622
- const r = await httpProbe(route.url);
1623
- session.apiLog.push({ ...r, id: shortId() });
1624
- addResult({ name: `API: ${route.url}`, type: 'api', category: 'api',
1625
- status: r.ok ? 'PASS' : 'FAIL',
1626
- message: `${r.status} ${r.ok ? 'OK' : 'FAIL'} (${r.responseTime}ms)`,
1627
- url: route.url, duration: r.responseTime });
1628
- if (!r.ok) session.addBug({ title: `API Failure: ${route.url}`,
1629
- severity: r.status >= 500 ? 'P0' : 'P1', type: 'api',
1630
- description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
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 } });
1631
2825
  }
1632
2826
 
1633
- // ── Phase 4: Security ────────────────────────────────────────────────
1634
- dash.setPhase('🛡️ Phase 4: Security Scan');
2827
+ // ── Phase 5: Security ────────────────────────────────────────────────
2828
+ dash.setPhase('🛡️ Phase 5: Security Scan (20+ checks)');
1635
2829
  for (const [label, url] of Object.entries(session.urls)) {
1636
2830
  if (!url) continue;
1637
2831
  dash.setCurrentTest(`Security: ${url}`);
1638
2832
  const findings = await runSecurityScan(url);
1639
2833
  session.secFindings.push(...findings);
1640
2834
  for (const f of findings) {
1641
- addResult({ name: `Security: ${f.check}`, type: 'security', category: f.category,
2835
+ addResult({ name: `Security: ${f.check}`, type: 'security',
1642
2836
  status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
1643
2837
  if (!f.pass && ['P0','P1'].includes(f.severity)) {
1644
2838
  session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
@@ -1647,47 +2841,126 @@ async function runQAEngine(session) {
1647
2841
  }
1648
2842
  }
1649
2843
 
1650
- // ── Phase 5: Accessibility ───────────────────────────────────────────
1651
- dash.setPhase(' Phase 5: Accessibility Check');
1652
- const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
1653
- for (const route of pageRoutes) {
2844
+ // ── Phase 6: Cookie Audit (v15) ──────────────────────────────────────
2845
+ dash.setPhase('🍪 Phase 6: Cookie Security Audit');
2846
+ for (const [label, url] of Object.entries(session.urls)) {
2847
+ if (!url) continue;
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
+ }
2858
+ }
2859
+ }
2860
+
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) {
1654
2905
  dash.setCurrentTest(`A11y: ${route.url}`);
1655
2906
  const result = await runA11yScan(route.url);
1656
2907
  session.a11yResults.push({ url: route.url, ...result });
1657
2908
  for (const v of result.violations) {
1658
- addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility', category: 'wcag',
1659
- status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : v.impact === 'serious' ? 'P1' : 'P2',
1660
- url: route.url });
1661
- if (['critical','serious'].includes(v.impact)) session.addBug({
1662
- title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
1663
- type: 'accessibility', description: v.help, url: route.url, recommendation: v.helpUrl });
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 });
1664
2912
  }
1665
- for (const pass of result.passes.slice(0, 3)) {
2913
+ for (const pass of result.passes.slice(0, 4)) {
1666
2914
  addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
1667
2915
  }
1668
2916
  }
1669
2917
 
1670
- // ── Phase 6: SEO ─────────────────────────────────────────────────────
1671
- dash.setPhase('🔎 Phase 6: SEO Validation');
2918
+ // ── Phase 11: SEO ────────────────────────────────────────────────────
2919
+ dash.setPhase('🔎 Phase 11: SEO Validation (21 checks)');
1672
2920
  const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
1673
2921
  for (const route of seoRoutes) {
1674
2922
  dash.setCurrentTest(`SEO: ${route.url}`);
1675
2923
  const result = await runSEOScan(route.url);
1676
2924
  session.seoResults.push({ url: route.url, ...result });
1677
2925
  for (const c of result.checks) {
1678
- addResult({ name: `SEO: ${c.name}`, type: 'seo', category: c.category,
2926
+ addResult({ name: `SEO: ${c.name}`, type: 'seo',
1679
2927
  status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
1680
- if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
1681
- title: `SEO: ${c.name}`, severity: c.severity, type: 'seo',
1682
- description: c.detail, url: route.url, recommendation: c.recommendation });
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 });
1683
2929
  }
1684
2930
  }
1685
2931
 
1686
- // ── Phase 7: AI Classification ───────────────────────────────────────
1687
- dash.setPhase('🤖 Phase 7: AI Bug Classification');
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`));
2946
+ }
2947
+ }
2948
+
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');
1688
2961
  dash.log(`Classifying ${session.bugs.length} bugs...`);
1689
2962
  for (const bug of session.bugs) {
1690
- const cls = classifyBug(bug);
2963
+ const cls = classifyBug(bug);
1691
2964
  bug.aiSeverity = cls.severity;
1692
2965
  bug.aiCategory = cls.category;
1693
2966
  bug.aiRecommendation = cls.recommendation;
@@ -1722,12 +2995,20 @@ async function generateReports(session) {
1722
2995
  urls: session.urls, summary, results: session.results, bugs: session.bugs,
1723
2996
  routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
1724
2997
  perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
1725
- screenshots: session.screenshots.map(s => ({ ...s, path: undefined })), // strip paths from JSON
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 })),
1726
3007
  playwrightMode: session.playwrightMode,
1727
3008
  ci: {
1728
3009
  exitCode: summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
1729
- p0Bugs : session.bugs.filter(b => b.severity === 'P0').length,
1730
- p1Bugs : session.bugs.filter(b => b.severity === 'P1').length,
3010
+ p0Bugs: session.bugs.filter(b => b.severity === 'P0').length,
3011
+ p1Bugs: session.bugs.filter(b => b.severity === 'P1').length,
1731
3012
  passRate: summary.passRate,
1732
3013
  },
1733
3014
  }, { spaces: 2 });
@@ -1742,6 +3023,7 @@ export async function initQASystem() {
1742
3023
  await fs.ensureDir(QA_DIR);
1743
3024
  await fs.ensureDir(REPORT_DIR);
1744
3025
  await fs.ensureDir(SCREENSHOT_DIR);
3026
+ await fs.ensureDir(BASELINE_DIR);
1745
3027
  if (!await fs.pathExists(HISTORY_FILE)) {
1746
3028
  await fs.writeJson(HISTORY_FILE, { runs: [], version: VERSION }, { spaces: 2 });
1747
3029
  }
@@ -1765,7 +3047,7 @@ async function saveToHistory(session, htmlPath, jsonPath) {
1765
3047
  // ═══════════════════════════════════════════════════════════════════════════
1766
3048
  // Public API — runUrlQA (main entry point)
1767
3049
  // ═══════════════════════════════════════════════════════════════════════════
1768
- export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
3050
+ export async function runUrlQA({ localUrl, stagingUrl, prodUrl, loadTest = true } = {}) {
1769
3051
  const urls = {};
1770
3052
  if (localUrl) urls.localhost = localUrl;
1771
3053
  if (stagingUrl) urls.staging = stagingUrl;
@@ -1773,20 +3055,22 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1773
3055
 
1774
3056
  if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
1775
3057
 
1776
- // Check Playwright availability and warn
1777
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')(' ─────────────────────────────────────────'));
1778
3062
  if (chromium) {
1779
- console.log(chalk.hex('#BF40FF')(' 🎭 Playwright detected — Real browser mode ENABLED'));
1780
- console.log(chalk.gray(' Screenshots, Web Vitals, DOM tests, Interactions will be captured'));
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'));
1781
3066
  } else {
1782
3067
  console.log(chalk.yellow(' ⚠ Playwright not found — HTTP-only mode'));
1783
- console.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
1784
- console.log(chalk.gray(' For real Web Vitals, screenshots, and DOM tests'));
3068
+ console.log(chalk.gray(' Run: npm install playwright && npx playwright install chromium'));
1785
3069
  }
1786
3070
  console.log('');
1787
3071
 
1788
3072
  const session = new QASession(urls);
1789
- await runQAEngine(session);
3073
+ await runQAEngine(session, { loadTest });
1790
3074
  const { htmlPath, jsonPath } = await generateReports(session);
1791
3075
  await saveToHistory(session, htmlPath, jsonPath);
1792
3076
 
@@ -1794,9 +3078,7 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1794
3078
  console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
1795
3079
  console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
1796
3080
  console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
1797
- if (session.screenshots.length > 0) {
1798
- console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
1799
- }
3081
+ if (session.screenshots.length > 0) console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
1800
3082
 
1801
3083
  try {
1802
3084
  const { exec } = await import('node:child_process');
@@ -1808,14 +3090,14 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
1808
3090
  return { session, htmlPath, jsonPath };
1809
3091
  }
1810
3092
 
1811
- export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
3093
+ export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl, loadTest = false } = {}) {
1812
3094
  const run = async () => {
1813
3095
  const urls = {};
1814
3096
  if (localUrl) urls.localhost = localUrl;
1815
3097
  if (stagingUrl) urls.staging = stagingUrl;
1816
3098
  if (prodUrl) urls.production = prodUrl;
1817
3099
  const session = new QASession(urls);
1818
- await runQAEngine(session);
3100
+ await runQAEngine(session, { loadTest });
1819
3101
  const { htmlPath, jsonPath } = await generateReports(session);
1820
3102
  await saveToHistory(session, htmlPath, jsonPath);
1821
3103
  console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
@@ -1836,12 +3118,15 @@ export async function runManualQA() {
1836
3118
  const action = await p.select({
1837
3119
  message: 'Manual QA mode:',
1838
3120
  options: [
1839
- { value: 'full', label: '🌐 Full Scan (All phases + Playwright)' },
1840
- { value: 'browser', label: '🎭 Browser-only (Playwright: screenshots + vitals)' },
1841
- { value: 'security', label: '🛡️ Security only' },
1842
- { value: 'seo', label: '🔎 SEO only' },
1843
- { value: 'a11y', label: '♿ Accessibility only' },
1844
- { value: 'perf', label: '⚡ Performance only' },
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' },
1845
3130
  ],
1846
3131
  });
1847
3132
  if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
@@ -1853,48 +3138,62 @@ export async function runManualQA() {
1853
3138
  const sess = new QASession({ localhost: url });
1854
3139
 
1855
3140
  if (action === 'full') {
1856
- await runQAEngine(sess);
3141
+ await runQAEngine(sess, { loadTest: true });
1857
3142
  } else {
1858
3143
  const dash = new TerminalDashboard(sess);
1859
3144
  dash.start();
1860
3145
  try {
1861
3146
  if (action === 'browser') {
1862
3147
  const chromium = await getPlaywright();
1863
- if (!chromium) { dash.log(chalk.red('Playwright not installed! Run: npm install playwright && npx playwright install chromium')); }
3148
+ if (!chromium) { dash.log(chalk.red('Playwright not installed!')); }
1864
3149
  else {
1865
3150
  sess.playwrightMode = true;
1866
3151
  await runPlaywrightScan(url, sess, dash);
1867
- sess.perfMetrics.localhost = { ...sess.perfMetrics.localhost, playwrightMode: true };
1868
3152
  }
1869
3153
  } else if (action === 'security') {
1870
- const f = await runSecurityScan(url);
1871
- sess.secFindings.push(...f);
1872
- f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
1873
- status: finding.pass ? 'PASS' : 'FAIL', message: finding.detail, timestamp: timestamp() }));
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() }));
1874
3157
  } else if (action === 'seo') {
1875
3158
  const r = await runSEOScan(url);
1876
3159
  sess.seoResults.push({ url, ...r });
1877
- r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
1878
- status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
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() }));
1879
3161
  } else if (action === 'a11y') {
1880
3162
  const r = await runA11yScan(url);
1881
3163
  sess.a11yResults.push({ url, ...r });
1882
- r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
1883
- status: 'FAIL', message: v.help, timestamp: timestamp() }));
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() }));
1884
3166
  } else if (action === 'perf') {
1885
3167
  const chromium2 = await getPlaywright();
1886
3168
  if (chromium2) {
1887
3169
  sess.playwrightMode = true;
1888
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();
1889
3195
  } else {
1890
- const m = await (async () => {
1891
- const t0 = Date.now(); const r = await httpProbe(url, { timeout: 15000 });
1892
- return { ttfb: Date.now()-t0, bodySize: r.bodySize, statusCode: r.status, slowResources: [],
1893
- note: 'Install Playwright for real LCP/FCP/CLS metrics' };
1894
- })();
1895
- sess.perfMetrics.localhost = m;
1896
- sess.addResult({ id: shortId(), name: `TTFB: ${m.ttfb}ms`, type: 'performance',
1897
- status: m.ttfb <= 800 ? 'PASS' : 'FAIL', message: `${m.ttfb}ms`, timestamp: timestamp() });
3196
+ dash.log(chalk.red('Playwright required for user flow simulation'));
1898
3197
  }
1899
3198
  }
1900
3199
  } finally { dash.stop(); }
@@ -1926,14 +3225,14 @@ export async function viewQAHistory() {
1926
3225
  if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
1927
3226
 
1928
3227
  console.log('');
1929
- console.log(chalk.hex('#00F5FF').bold(' QA History'));
1930
- console.log(chalk.gray(' ──────────────────────────────────────────────────'));
3228
+ console.log(chalk.hex('#00F5FF').bold(' QA History — v15'));
3229
+ console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
1931
3230
  for (const run of history.runs.slice(0, 15)) {
1932
3231
  const rate = run.summary?.passRate ?? '–';
1933
3232
  const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
1934
3233
  const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
1935
3234
  const pwIcon = run.playwrightMode ? chalk.hex('#BF40FF')('🎭') : chalk.gray('⚡');
1936
- console.log(` ${chalk.gray(run.id.padEnd(16))} ${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(22))} ${col((rate+'%').padStart(7))} ${chalk.cyan((run.bugCount||0)+' bugs')} ${pwIcon} ${chalk.dim(urls.slice(0,40))}`);
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))}`);
1937
3236
  }
1938
3237
  console.log('');
1939
3238
 
@@ -1960,4 +3259,4 @@ export async function viewQAHistory() {
1960
3259
  } else {
1961
3260
  console.log(chalk.yellow(' Report file not found.'));
1962
3261
  }
1963
- }
3262
+ }