create-backlist 10.0.2 → 10.0.4

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,1450 +1,912 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
- // Backlist QA Engine — qa-engine.js v10.0
3
- // Full live QA runtime: manual + automated + URL-based browser QA
2
+ // Backlist Enterprise AI QA Platform — qa-engine.js v12.0
4
3
  // Copyright (c) W.A.H.ISHAN — MIT License
5
4
  //
6
- // NEW in v10.0:
7
- // URL-based QA test localhost AND production simultaneously
8
- // ✦ Real HTTP endpoint probing (fetch-based, no browser needed)
9
- // ✦ Auto route crawler — discovers pages from sitemap / common paths
10
- // ✦ Response time p50/p95/p99 benchmarking per route
11
- // ✦ Security header scanner (CSP, CORS, X-Frame, HSTS, etc.)
12
- // ✦ Auth flow validator (login / protected route / token check)
13
- // ✦ Broken link detector across crawled pages
14
- // ✦ SEO meta tag validator per page
15
- // ✦ Accessibility quick-check (meta viewport, lang attr, alt probes)
16
- // ✦ Mobile responsiveness header check
17
- // ✦ Console error simulation via HTML response parsing
18
- // ✦ Rich HTML report v10 with Chart.js + timeline + per-page cards
19
- // ✦ Dual-URL diff report (localhost vs production)
20
- // ✦ JSON CI output with exit-code propagation
21
- // ✦ All v9.0 features retained
5
+ // REAL RUNTIME TESTING — NO FAKE DATA
6
+ // Every result is collected from actual browser execution
22
7
  // ═══════════════════════════════════════════════════════════════════════════
23
8
 
24
- import * as p from '@clack/prompts';
25
- import chalk from 'chalk';
26
- import fs from 'fs-extra';
27
- import path from 'node:path';
28
- import os from 'node:os';
29
- import http from 'node:http';
30
- import https from 'node:https';
31
- import { URL } from 'node:url';
32
- import { EventEmitter } from 'node:events';
9
+ import * as p from '@clack/prompts';
10
+ import chalk from 'chalk';
11
+ import fs from 'fs-extra';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
33
14
  import { performance } from 'node:perf_hooks';
15
+ import { EventEmitter } from 'node:events';
34
16
 
35
- // ── Constants ─────────────────────────────────────────────────────────────
17
+ import { SmartCrawler } from './browser/crawler.js';
18
+ import { BrowserInteractor } from './browser/interactions.js';
19
+ import { ScreenshotCapture } from './browser/screenshot.js';
20
+ import { RealAPIValidator } from './analyzers/api.js';
21
+ import { SecurityScanner } from './analyzers/security.js';
22
+ import { PerformanceProfiler } from './analyzers/performance.js';
23
+ import { AccessibilityChecker} from './analyzers/accessibility.js';
24
+ import { SEOScanner } from './analyzers/seo.js';
25
+ import { HTMLReporter } from './reporters/html.js';
26
+ import { TerminalDashboard } from './reporters/terminal.js';
27
+ import { JSONReporter } from './reporters/json.js';
28
+ import { AIClassifier } from './utils/ai-classifier.js';
36
29
 
37
- const VERSION = '10.0.0';
38
- const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
39
- const HISTORY_FILE = path.join(QA_DIR, 'history.json');
40
- const REPORT_DIR = path.join(QA_DIR, 'reports');
41
-
42
- const SEVERITY_LEVELS = { P0: 'Critical', P1: 'High', P2: 'Medium', P3: 'Low' };
43
- const TEST_TYPES = ['happy-path', 'validation', 'auth', 'edge-case', 'performance', 'security', 'e2e', 'ui', 'seo', 'a11y', 'links', 'http'];
44
- const DEFAULT_TIMEOUT_MS = 15_000;
45
- const HTTP_TIMEOUT_MS = 8_000;
46
- const FLAKY_RETRY_COUNT = 2;
47
- const WATCH_INTERVAL_MS = 30_000;
48
-
49
- // ── Common routes to probe ────────────────────────────────────────────────
50
- const COMMON_ROUTES = [
51
- '/', '/login', '/register', '/dashboard', '/dashboard/analytics',
52
- '/dashboard/sales', '/profile', '/settings', '/admin', '/about',
53
- '/contact', '/api/health', '/api/status', '/api/v1/health',
54
- '/sitemap.xml', '/robots.txt', '/favicon.ico',
55
- ];
56
-
57
- // ── Security headers to check ─────────────────────────────────────────────
58
- const SECURITY_HEADERS = [
59
- { header: 'content-security-policy', label: 'CSP', sev: 'P1' },
60
- { header: 'x-frame-options', label: 'X-Frame', sev: 'P1' },
61
- { header: 'x-content-type-options', label: 'X-Content', sev: 'P2' },
62
- { header: 'strict-transport-security', label: 'HSTS', sev: 'P1' },
63
- { header: 'referrer-policy', label: 'Referrer', sev: 'P2' },
64
- { header: 'permissions-policy', label: 'Permissions', sev: 'P3' },
65
- { header: 'access-control-allow-origin', label: 'CORS', sev: 'P2' },
66
- ];
67
-
68
- // ── ANSI escape helpers ───────────────────────────────────────────────────
69
- const ESC = '\x1b[';
70
- const CLEAR_LINE = ESC + '2K\r';
71
- const CURSOR_UP = (n) => ESC + `${n}A`;
72
- const CURSOR_HIDE = ESC + '?25l';
73
- const CURSOR_SHOW = ESC + '?25h';
74
- const BOLD = chalk.bold;
75
- const DIM = chalk.dim;
30
+ // ── Constants ─────────────────────────────────────────────────────────────
31
+ export const VERSION = '12.0.0';
32
+ export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
33
+ export const REPORT_DIR = path.join(QA_DIR, 'reports');
34
+ export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
35
+ export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
76
36
 
77
37
  // ── Utilities ─────────────────────────────────────────────────────────────
78
- function timestamp() { return new Date().toISOString(); }
79
- function shortId() { return Math.random().toString(36).slice(2, 9); }
80
- function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
81
- function pluralize(n, w) { return `${n} ${n === 1 ? w : w + 's'}`; }
82
-
83
- function colorSeverity(sev) {
84
- return ({ P0: chalk.red.bold, P1: chalk.yellow.bold, P2: chalk.cyan, P3: chalk.gray }[sev] ?? chalk.white)(sev);
85
- }
86
-
87
- function colorStatus(status) {
88
- return ({
89
- PASS : chalk.green('✓ PASS'),
90
- FAIL : chalk.red('✗ FAIL'),
91
- SKIP : chalk.gray('⊘ SKIP'),
92
- FLAKY : chalk.yellow('⚠ FLAKY'),
93
- RUN : chalk.cyan('⟳ RUN'),
94
- })[status] ?? status;
95
- }
96
-
97
- function buildProgressBar(pct, width = 20) {
98
- const filled = Math.min(Math.round((pct / 100) * width), width);
99
- const empty = width - filled;
100
- const color = pct >= 90 ? chalk.green : pct >= 70 ? chalk.yellow : chalk.red;
101
- return color('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
102
- }
103
-
104
- function formatDuration(ms) {
105
- if (ms < 1000) return `${ms}ms`;
38
+ export function timestamp() { return new Date().toISOString(); }
39
+ export function shortId() { return Math.random().toString(36).slice(2, 9); }
40
+ export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
41
+ export function formatDuration(ms) {
42
+ if (ms < 1000) return `${Math.round(ms)}ms`;
106
43
  return `${(ms / 1000).toFixed(2)}s`;
107
44
  }
108
-
109
- function formatBytes(b) {
45
+ export function formatBytes(b) {
46
+ if (!b || b < 0) return '0B';
110
47
  if (b < 1024) return `${b}B`;
111
48
  if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
112
49
  return `${(b / 1024 / 1024).toFixed(1)}MB`;
113
50
  }
114
51
 
115
- function getSystemStats() {
116
- const mem = process.memoryUsage();
117
- const heapMB = (mem.heapUsed / 1024 / 1024).toFixed(1);
118
- const rss = formatBytes(mem.rss);
119
- const uptime = process.uptime().toFixed(1);
120
- return { heapMB, rss, uptime };
121
- }
122
-
123
- // ─────────────────────────────────────────────────────────────────────────
124
- // v10.0: HTTP Probe Engine
125
- // ─────────────────────────────────────────────────────────────────────────
126
-
127
- export class HttpProbe {
128
- #baseUrl;
129
- #agent;
130
-
131
- constructor(baseUrl) {
132
- this.#baseUrl = baseUrl.replace(/\/$/, '');
133
- const isHttps = baseUrl.startsWith('https');
134
- this.#agent = isHttps
135
- ? new https.Agent({ rejectUnauthorized: false, timeout: HTTP_TIMEOUT_MS })
136
- : new http.Agent({ timeout: HTTP_TIMEOUT_MS });
52
+ // ── QA Session ────────────────────────────────────────────────────────────
53
+ export class QASession {
54
+ id;
55
+ startedAt;
56
+ urls = {};
57
+ results = []; // real test results only
58
+ bugs = []; // real detected bugs only
59
+ screenshots = []; // real screenshots
60
+ consoleErrors = [];
61
+ networkLog = [];
62
+ apiLog = [];
63
+ routeMap = [];
64
+ perfMetrics = {};
65
+ secFindings = [];
66
+ a11yResults = [];
67
+ seoResults = [];
68
+
69
+ constructor(urls) {
70
+ this.id = `QA-${shortId()}`;
71
+ this.startedAt = timestamp();
72
+ this.urls = urls;
73
+ }
74
+
75
+ addResult(result) {
76
+ this.results.push(result);
77
+ }
78
+
79
+ addBug(bug) {
80
+ this.bugs.push({ ...bug, id: `BUG-${shortId()}`, createdAt: timestamp() });
81
+ }
82
+
83
+ getSummary() {
84
+ const passed = this.results.filter(r => r.status === 'PASS').length;
85
+ const failed = this.results.filter(r => r.status === 'FAIL').length;
86
+ const skipped = this.results.filter(r => r.status === 'SKIP').length;
87
+ const flaky = this.results.filter(r => r.status === 'FLAKY').length;
88
+ const total = this.results.length;
89
+ return {
90
+ total, passed, failed, skipped, flaky,
91
+ passRate: total > 0 ? ((passed + flaky) / total * 100).toFixed(1) : '0.0',
92
+ duration: Date.now() - new Date(this.startedAt).getTime(),
93
+ };
137
94
  }
95
+ }
138
96
 
139
- async fetch(route = '/', options = {}) {
140
- const url = this.#baseUrl + route;
141
- const t0 = performance.now();
97
+ // ── Main QA Engine ────────────────────────────────────────────────────────
98
+ export class QAEngine extends EventEmitter {
99
+ #session;
100
+ #terminal;
101
+ #crawler;
102
+ #interactor;
103
+ #screenshotter;
104
+ #apiValidator;
105
+ #security;
106
+ #performance;
107
+ #a11y;
108
+ #seo;
109
+ #aiClassifier;
110
+ #aborted = false;
111
+
112
+ constructor(session, options = {}) {
113
+ super();
114
+ this.#session = session;
115
+ this.#terminal = new TerminalDashboard(session);
116
+ this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
117
+ this.#aiClassifier = new AIClassifier();
118
+ }
119
+
120
+ async init() {
121
+ // Dynamically import Playwright — optional peer dependency
122
+ let playwright;
142
123
  try {
143
- const controller = new AbortController();
144
- const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
145
- const res = await fetch(url, {
146
- signal : controller.signal,
147
- headers: { 'User-Agent': 'Backlist-QA/10.0', ...options.headers },
148
- method : options.method || 'GET',
149
- ...(options.body ? { body: options.body } : {}),
150
- });
151
- clearTimeout(timer);
152
- const duration = Math.round(performance.now() - t0);
153
- const headers = {};
154
- res.headers.forEach((v, k) => { headers[k] = v; });
155
- const text = options.readBody ? await res.text().catch(() => '') : '';
156
- return { ok: true, status: res.status, headers, duration, url, text };
157
- } catch (err) {
158
- const duration = Math.round(performance.now() - t0);
159
- return { ok: false, status: 0, headers: {}, duration, url, error: err.message };
124
+ playwright = await import('playwright');
125
+ } catch {
126
+ throw new Error(
127
+ 'Playwright not installed. Run: npm install playwright && npx playwright install chromium'
128
+ );
160
129
  }
161
- }
162
130
 
163
- async probeRoutes(routes = COMMON_ROUTES) {
164
- const results = [];
165
- for (const route of routes) {
166
- const r = await this.fetch(route, { readBody: true });
167
- results.push({ route, ...r });
168
- }
169
- return results;
170
- }
131
+ this.#crawler = new SmartCrawler(playwright);
132
+ this.#interactor = new BrowserInteractor(playwright, this.#session);
133
+ this.#apiValidator = new RealAPIValidator(this.#session);
134
+ this.#security = new SecurityScanner(this.#session);
135
+ this.#performance = new PerformanceProfiler(this.#session);
136
+ this.#a11y = new AccessibilityChecker(playwright, this.#session);
137
+ this.#seo = new SEOScanner(this.#session);
171
138
 
172
- async checkSecurityHeaders(route = '/') {
173
- const r = await this.fetch(route);
174
- if (!r.ok && r.status === 0) return { ok: false, results: [], error: r.error };
175
- const results = SECURITY_HEADERS.map(({ header, label, sev }) => ({
176
- header, label, sev,
177
- present: header in r.headers,
178
- value : r.headers[header] || null,
179
- }));
180
- return { ok: true, results };
181
- }
182
-
183
- async benchmarkRoute(route = '/', samples = 5) {
184
- const timings = [];
185
- for (let i = 0; i < samples; i++) {
186
- const r = await this.fetch(route);
187
- if (r.ok || r.status > 0) timings.push(r.duration);
188
- await sleep(100);
189
- }
190
- if (!timings.length) return { p50: 0, p95: 0, p99: 0, avg: 0, samples: 0 };
191
- timings.sort((a, b) => a - b);
192
- const p = (pct) => timings[Math.min(Math.floor(timings.length * pct / 100), timings.length - 1)];
193
- return {
194
- p50 : p(50),
195
- p95 : p(95),
196
- p99 : p(99),
197
- avg : Math.round(timings.reduce((a, b) => a + b, 0) / timings.length),
198
- samples: timings.length,
199
- };
139
+ await this.#interactor.launch();
140
+ await this.#screenshotter.init();
200
141
  }
201
142
 
202
- async checkSEO(route = '/') {
203
- const r = await this.fetch(route, { readBody: true });
204
- if (!r.ok && r.status === 0) return { ok: false, checks: [] };
205
- const html = r.text || '';
206
- const checks = [
207
- { name: 'Title tag', pass: /<title[^>]*>[^<]+<\/title>/i.test(html), sev: 'P1' },
208
- { name: 'Meta description',pass: /<meta[^>]+name=["']description["'][^>]*>/i.test(html), sev: 'P2' },
209
- { name: 'H1 tag', pass: /<h1[^>]*>[^<]+<\/h1>/i.test(html), sev: 'P1' },
210
- { name: 'Viewport meta', pass: /<meta[^>]+name=["']viewport["'][^>]*>/i.test(html), sev: 'P1' },
211
- { name: 'Lang attribute', pass: /<html[^>]+lang=["'][^"']+["']/i.test(html), sev: 'P2' },
212
- { name: 'Canonical link', pass: /<link[^>]+rel=["']canonical["'][^>]*>/i.test(html), sev: 'P2' },
213
- { name: 'OG meta tags', pass: /<meta[^>]+property=["']og:/i.test(html), sev: 'P3' },
214
- ];
215
- return { ok: true, checks, statusCode: r.status };
216
- }
143
+ async run() {
144
+ this.#terminal.start();
145
+ this.emit('session:start', this.#session);
217
146
 
218
- get baseUrl() { return this.#baseUrl; }
219
- }
147
+ try {
148
+ // Phase 1 — Discovery
149
+ this.#terminal.setPhase('🔍 Phase 1: Route Discovery & Crawling');
150
+ await this.#phaseDiscovery();
220
151
 
221
- // ─────────────────────────────────────────────────────────────────────────
222
- // v10.0: URL-Based QA Test Suite Builder
223
- // ─────────────────────────────────────────────────────────────────────────
152
+ // Phase 2 — API Validation
153
+ this.#terminal.setPhase('📡 Phase 2: Real API Validation');
154
+ await this.#phaseAPIValidation();
224
155
 
225
- function buildUrlTestSuite(probe, label = 'target') {
226
- const tests = [];
227
-
228
- // ── Connectivity ──────────────────────────────────────────────────────
229
- tests.push({ id: shortId(), name: `[${label}] Homepage reachable`, type: 'http', sev: 'P0', fn: async () => {
230
- const r = await probe.fetch('/');
231
- if (!r.ok && r.status === 0) throw new Error(`Connection failed: ${r.error}`);
232
- if (r.status >= 500) throw new Error(`Server error: HTTP ${r.status}`);
233
- }});
234
-
235
- tests.push({ id: shortId(), name: `[${label}] API health endpoint`, type: 'http', sev: 'P0', fn: async () => {
236
- const candidates = ['/api/health', '/api/status', '/api/v1/health', '/health'];
237
- let found = false;
238
- for (const c of candidates) {
239
- const r = await probe.fetch(c);
240
- if (r.status >= 200 && r.status < 400) { found = true; break; }
241
- }
242
- if (!found) throw new Error('No reachable API health endpoint found');
243
- }});
244
-
245
- tests.push({ id: shortId(), name: `[${label}] 404 handler works`, type: 'http', sev: 'P2', fn: async () => {
246
- const r = await probe.fetch('/this-page-does-not-exist-qa-test-' + shortId());
247
- if (r.status !== 404) throw new Error(`Expected 404, got ${r.status}`);
248
- }});
249
-
250
- // ── Security Headers ──────────────────────────────────────────────────
251
- tests.push({ id: shortId(), name: `[${label}] Security headers scan`, type: 'security', sev: 'P1', fn: async () => {
252
- const scan = await probe.checkSecurityHeaders('/');
253
- if (!scan.ok) throw new Error(`Could not reach server: ${scan.error}`);
254
- const critical = scan.results.filter(r => !r.present && (r.sev === 'P0' || r.sev === 'P1'));
255
- if (critical.length > 0) {
256
- throw new Error(`Missing critical headers: ${critical.map(r => r.label).join(', ')}`);
257
- }
258
- }});
156
+ // Phase 3 Browser Interactions
157
+ this.#terminal.setPhase('🖱️ Phase 3: Browser Interaction Testing');
158
+ await this.#phaseBrowserInteractions();
259
159
 
260
- tests.push({ id: shortId(), name: `[${label}] HTTPS redirect check`, type: 'security', sev: 'P1', fn: async () => {
261
- const r = await probe.fetch('/');
262
- if (probe.baseUrl.startsWith('http://') && r.status === 200) {
263
- throw new Error('HTTP serving without redirect — HTTPS not enforced');
264
- }
265
- }});
266
-
267
- // ── Authentication ────────────────────────────────────────────────────
268
- tests.push({ id: shortId(), name: `[${label}] Login page accessible`, type: 'auth', sev: 'P1', fn: async () => {
269
- const candidates = ['/login', '/auth/login', '/signin', '/auth', '/user/login'];
270
- let found = false;
271
- for (const c of candidates) {
272
- const r = await probe.fetch(c);
273
- if (r.status >= 200 && r.status < 400) { found = true; break; }
274
- }
275
- if (!found) throw new Error('No login page found at common paths');
276
- }});
277
-
278
- tests.push({ id: shortId(), name: `[${label}] Protected route redirects`, type: 'auth', sev: 'P0', fn: async () => {
279
- const candidates = ['/dashboard', '/admin', '/profile', '/settings'];
280
- for (const c of candidates) {
281
- const r = await probe.fetch(c);
282
- if (r.status === 200) {
283
- const html = r.text || '';
284
- const hasAuthBlock = /login|signin|unauthorized|forbidden|redirect/i.test(html);
285
- if (!hasAuthBlock) throw new Error(`${c} appears accessible without auth (HTTP ${r.status})`);
286
- }
287
- }
288
- }});
289
-
290
- // ── Performance ───────────────────────────────────────────────────────
291
- tests.push({ id: shortId(), name: `[${label}] Homepage response time < 3s`, type: 'performance', sev: 'P1', fn: async () => {
292
- const bench = await probe.benchmarkRoute('/', 3);
293
- if (bench.avg > 3000) throw new Error(`Avg response ${bench.avg}ms exceeds 3000ms threshold`);
294
- }});
295
-
296
- tests.push({ id: shortId(), name: `[${label}] API latency p95 < 1s`, type: 'performance', sev: 'P2', fn: async () => {
297
- const candidates = ['/api/health', '/api/status', '/api/v1/health'];
298
- for (const c of candidates) {
299
- const r = await probe.fetch(c);
300
- if (r.status > 0) {
301
- const bench = await probe.benchmarkRoute(c, 3);
302
- if (bench.p95 > 1000) throw new Error(`p95 latency ${bench.p95}ms on ${c} exceeds 1000ms`);
303
- return;
304
- }
305
- }
306
- }});
307
-
308
- // ── SEO ───────────────────────────────────────────────────────────────
309
- tests.push({ id: shortId(), name: `[${label}] Homepage SEO tags`, type: 'seo', sev: 'P1', fn: async () => {
310
- const seo = await probe.checkSEO('/');
311
- if (!seo.ok) throw new Error('Could not fetch homepage');
312
- const failing = seo.checks.filter(c => !c.pass && c.sev === 'P1');
313
- if (failing.length > 0) throw new Error(`Missing SEO tags: ${failing.map(c => c.name).join(', ')}`);
314
- }});
315
-
316
- tests.push({ id: shortId(), name: `[${label}] robots.txt accessible`, type: 'seo', sev: 'P2', fn: async () => {
317
- const r = await probe.fetch('/robots.txt');
318
- if (r.status !== 200) throw new Error(`robots.txt returned ${r.status}`);
319
- }});
320
-
321
- // ── Accessibility ─────────────────────────────────────────────────────
322
- tests.push({ id: shortId(), name: `[${label}] Viewport meta tag`, type: 'a11y', sev: 'P1', fn: async () => {
323
- const r = await probe.fetch('/', { readBody: true });
324
- if (!r.ok && r.status === 0) throw new Error('Could not fetch homepage');
325
- if (!/<meta[^>]+name=["']viewport["']/i.test(r.text || '')) {
326
- throw new Error('Missing viewport meta tag — mobile responsiveness broken');
327
- }
328
- }});
160
+ // Phase 4 Security Scan
161
+ this.#terminal.setPhase('🛡️ Phase 4: Security Deep Scan');
162
+ await this.#phaseSecurityScan();
329
163
 
330
- tests.push({ id: shortId(), name: `[${label}] HTML lang attribute`, type: 'a11y', sev: 'P2', fn: async () => {
331
- const r = await probe.fetch('/', { readBody: true });
332
- if (!r.ok && r.status === 0) throw new Error('Could not fetch homepage');
333
- if (!/<html[^>]+lang=["'][^"']+["']/i.test(r.text || '')) {
334
- throw new Error('Missing lang attribute on <html> — screen reader accessibility issue');
335
- }
336
- }});
337
-
338
- // ── Common routes ─────────────────────────────────────────────────────
339
- tests.push({ id: shortId(), name: `[${label}] Core routes return non-500`, type: 'e2e', sev: 'P1', fn: async () => {
340
- const routes = ['/', '/login', '/about', '/contact'];
341
- const errors = [];
342
- for (const route of routes) {
343
- const r = await probe.fetch(route);
344
- if (r.status >= 500) errors.push(`${route} → ${r.status}`);
345
- }
346
- if (errors.length > 0) throw new Error(`Server errors: ${errors.join(', ')}`);
347
- }});
348
-
349
- tests.push({ id: shortId(), name: `[${label}] sitemap.xml or sitemap`, type: 'seo', sev: 'P3', fn: async () => {
350
- const r = await probe.fetch('/sitemap.xml');
351
- if (r.status !== 200) throw new Error(`sitemap.xml returned ${r.status}`);
352
- }});
353
-
354
- // ── Content-Type ──────────────────────────────────────────────────────
355
- tests.push({ id: shortId(), name: `[${label}] HTML content-type correct`, type: 'http', sev: 'P2', fn: async () => {
356
- const r = await probe.fetch('/');
357
- if (!r.ok && r.status === 0) throw new Error('Connection failed');
358
- const ct = r.headers['content-type'] || '';
359
- if (!ct.includes('text/html')) throw new Error(`Expected text/html, got: ${ct}`);
360
- }});
361
-
362
- tests.push({ id: shortId(), name: `[${label}] API returns JSON`, type: 'http', sev: 'P2', fn: async () => {
363
- const candidates = ['/api/health', '/api/status', '/api/v1/health'];
364
- for (const c of candidates) {
365
- const r = await probe.fetch(c);
366
- if (r.status > 0 && r.status < 500) {
367
- const ct = r.headers['content-type'] || '';
368
- if (!ct.includes('json')) throw new Error(`${c} does not return JSON (${ct})`);
369
- return;
370
- }
371
- }
372
- }});
164
+ // Phase 5 Performance
165
+ this.#terminal.setPhase(' Phase 5: Performance Profiling');
166
+ await this.#phasePerformance();
373
167
 
374
- return tests;
375
- }
168
+ // Phase 6 — Accessibility
169
+ this.#terminal.setPhase('♿ Phase 6: Accessibility Testing');
170
+ await this.#phaseAccessibility();
376
171
 
377
- // ─────────────────────────────────────────────────────────────────────────
378
- // v10.0: Run URL-Based QA
379
- // ─────────────────────────────────────────────────────────────────────────
172
+ // Phase 7 — SEO
173
+ this.#terminal.setPhase('🔎 Phase 7: SEO Validation');
174
+ await this.#phaseSEO();
380
175
 
381
- export async function runUrlQA({ localUrl, prodUrl, silent = false } = {}) {
382
- const runId = `UQA-${shortId()}`;
383
- const startedAt = timestamp();
384
- const allTests = [];
385
- const probes = [];
176
+ // Phase 8 AI Bug Classification
177
+ this.#terminal.setPhase('🤖 Phase 8: AI Bug Classification');
178
+ await this.#phaseAIClassification();
386
179
 
387
- if (!localUrl && !prodUrl) {
388
- console.log(chalk.red(' No URLs provided. Use --url=http://localhost:3000 or pass { localUrl, prodUrl }.'));
389
- return null;
390
- }
180
+ } catch (err) {
181
+ this.emit('engine:error', err);
182
+ throw err;
183
+ } finally {
184
+ this.#terminal.stop();
185
+ await this.#interactor.close().catch(() => {});
186
+ }
391
187
 
392
- if (!silent) {
393
- console.log('');
394
- console.log(chalk.hex('#00F5FF').bold(' ── 🌐 URL-Based QA Scan v10.0 ─────────────────────────'));
395
- console.log('');
188
+ return this.#session;
396
189
  }
397
190
 
398
- if (localUrl) {
399
- const probe = new HttpProbe(localUrl);
400
- probes.push({ probe, label: 'localhost', url: localUrl });
401
- if (!silent) console.log(chalk.gray(` → Probing localhost: ${localUrl}`));
402
- }
403
- if (prodUrl) {
404
- const probe = new HttpProbe(prodUrl);
405
- probes.push({ probe, label: 'production', url: prodUrl });
406
- if (!silent) console.log(chalk.gray(` → Probing production: ${prodUrl}`));
191
+ abort() {
192
+ this.#aborted = true;
193
+ this.#terminal.stop();
194
+ this.#interactor.close().catch(() => {});
407
195
  }
408
196
 
409
- for (const { probe, label } of probes) {
410
- allTests.push(...buildUrlTestSuite(probe, label));
411
- }
197
+ // ── Phase 1: Discovery ─────────────────────────────────────────────────
198
+ async #phaseDiscovery() {
199
+ for (const [label, url] of Object.entries(this.#session.urls)) {
200
+ if (!url) continue;
201
+ this.#terminal.log(`Crawling ${label}: ${url}`);
412
202
 
413
- if (!silent) {
414
- console.log(chalk.gray(`\n Building HTTP test suite: ${allTests.length} tests across ${probes.length} target(s)\n`));
415
- }
203
+ const routes = await this.#crawler.crawl(url, {
204
+ maxPages : 60,
205
+ maxDepth : 4,
206
+ onRoute : (route) => {
207
+ this.#session.routeMap.push(route);
208
+ this.#terminal.log(` Found: ${route.url} (${route.type})`);
209
+ },
210
+ });
416
211
 
417
- const dashboard = silent ? null : new LiveDashboard();
418
- const runner = new TestRunner();
419
- const autoBugs = [];
420
-
421
- runner.on('result', r => {
422
- if (r.status === 'FAIL') {
423
- autoBugs.push({
424
- id : `HTTP-${shortId()}`,
425
- title : r.name,
426
- severity : r.sev || (r.type === 'security' || r.type === 'auth' ? 'P0' : 'P2'),
427
- status : 'OPEN',
428
- description: r.error || '',
429
- createdAt : timestamp(),
212
+ this.#addResult({
213
+ name : `[${label}] Route Discovery`,
214
+ type : 'discovery',
215
+ category: 'crawl',
216
+ status : routes.length > 0 ? 'PASS' : 'FAIL',
217
+ message : routes.length > 0
218
+ ? `Discovered ${routes.length} routes`
219
+ : 'No routes discovered — site may be unreachable',
220
+ data : { routeCount: routes.length, routes },
221
+ url,
222
+ label,
430
223
  });
431
224
  }
432
- });
225
+ }
226
+
227
+ // ── Phase 2: Real API Validation ───────────────────────────────────────
228
+ async #phaseAPIValidation() {
229
+ const apiRoutes = this.#session.routeMap.filter(r =>
230
+ r.type === 'api' || r.url.includes('/api/')
231
+ );
433
232
 
434
- if (dashboard) dashboard.start();
435
- const results = await runner.run(allTests, dashboard);
436
- if (dashboard) dashboard.stop();
233
+ this.#terminal.log(`Validating ${apiRoutes.length} API endpoints...`);
437
234
 
438
- // ── Route scan summary ─────────────────────────────────────────────────
439
- const routeScans = [];
440
- for (const { probe, label, url } of probes) {
441
- if (!silent) {
442
- console.log(chalk.gray(`\n Crawling routes for ${label}...`));
235
+ for (const route of apiRoutes) {
236
+ if (this.#aborted) break;
237
+ this.#terminal.setCurrentTest(`API: ${route.url}`);
238
+
239
+ const result = await this.#apiValidator.probe(route.url);
240
+ this.#session.apiLog.push(result);
241
+
242
+ this.#addResult({
243
+ name : `API: ${route.url}`,
244
+ type : 'api',
245
+ category: 'api-validation',
246
+ status : result.pass ? 'PASS' : 'FAIL',
247
+ message : result.message,
248
+ data : {
249
+ statusCode : result.statusCode,
250
+ responseTime: result.responseTime,
251
+ contentType: result.contentType,
252
+ body : result.body?.slice(0, 500),
253
+ headers : result.headers,
254
+ },
255
+ url : route.url,
256
+ duration: result.responseTime,
257
+ });
258
+
259
+ if (!result.pass) {
260
+ this.#session.addBug({
261
+ title : `API Failure: ${route.url}`,
262
+ severity : result.statusCode >= 500 ? 'P0' : 'P1',
263
+ type : 'api',
264
+ description: result.message,
265
+ evidence : result,
266
+ });
267
+ }
443
268
  }
444
- const probeResults = await probe.probeRoutes(COMMON_ROUTES.slice(0, 12));
445
- for (const pr of probeResults) {
446
- routeScans.push({ label, url, ...pr });
269
+
270
+ // Detect APIs from network traffic
271
+ const discoveredAPIs = await this.#apiValidator.discoverFromNetworkLog(
272
+ this.#session.networkLog
273
+ );
274
+ for (const api of discoveredAPIs) {
275
+ if (!apiRoutes.find(r => r.url === api.url)) {
276
+ this.#session.apiLog.push(api);
277
+ }
447
278
  }
448
279
  }
449
280
 
450
- const duration = Date.now() - new Date(startedAt).getTime();
451
- const summary = buildSummary(results);
452
- const coverage = buildCoverageMatrix(results);
281
+ // ── Phase 3: Browser Interactions ─────────────────────────────────────
282
+ async #phaseBrowserInteractions() {
283
+ const pageRoutes = this.#session.routeMap.filter(r =>
284
+ r.type === 'page' || r.type === 'unknown'
285
+ );
453
286
 
454
- if (!silent) printResultsSummary(results);
287
+ for (const route of pageRoutes.slice(0, 25)) {
288
+ if (this.#aborted) break;
289
+ this.#terminal.setCurrentTest(`Browser: ${route.url}`);
290
+
291
+ const result = await this.#interactor.testPage(route.url, {
292
+ onConsoleError: (err) => {
293
+ this.#session.consoleErrors.push({ url: route.url, ...err });
294
+ },
295
+ onNetworkEvent: (event) => {
296
+ this.#session.networkLog.push({ url: route.url, ...event });
297
+ },
298
+ });
455
299
 
456
- const run = {
457
- id: runId, type: 'url-qa', version: VERSION, startedAt, duration,
458
- urls : probes.map(p => ({ label: p.label, url: p.url })),
459
- results, bugReports: autoBugs, summary, coverage, routeScans,
460
- };
300
+ // Real screenshot on failure
301
+ if (!result.pass || result.consoleErrors.length > 0) {
302
+ const screenshot = await this.#screenshotter.capture(
303
+ result.page,
304
+ `fail-${shortId()}`
305
+ );
306
+ if (screenshot) {
307
+ result.screenshotPath = screenshot;
308
+ this.#session.screenshots.push({ url: route.url, path: screenshot, reason: result.failReason });
309
+ }
310
+ }
461
311
 
462
- await saveRun(run);
463
- const reportFile = await exportReport(run);
312
+ this.#addResult({
313
+ name : `Page: ${route.url}`,
314
+ type : 'browser',
315
+ category: 'interaction',
316
+ status : result.pass ? (result.consoleErrors.length > 0 ? 'FLAKY' : 'PASS') : 'FAIL',
317
+ message : result.message,
318
+ data : {
319
+ loadTime : result.loadTime,
320
+ consoleErrors : result.consoleErrors,
321
+ networkErrors : result.networkErrors,
322
+ interactedElements: result.interactedElements,
323
+ screenshotPath: result.screenshotPath,
324
+ jsErrors : result.jsErrors,
325
+ resourcesFailed: result.resourcesFailed,
326
+ renderTime : result.renderTime,
327
+ domContentLoaded: result.domContentLoaded,
328
+ },
329
+ url : route.url,
330
+ duration: result.loadTime,
331
+ screenshotPath: result.screenshotPath,
332
+ });
464
333
 
465
- if (!silent && reportFile) {
466
- console.log(chalk.gray(` 📄 URL QA Report: ${reportFile}`));
467
- }
334
+ // Real console errors → bugs
335
+ for (const err of result.consoleErrors) {
336
+ this.#session.addBug({
337
+ title : `JS Error: ${err.text?.slice(0, 80)}`,
338
+ severity : err.type === 'error' ? 'P1' : 'P2',
339
+ type : 'javascript',
340
+ description: err.text,
341
+ url : route.url,
342
+ evidence : err,
343
+ });
344
+ }
468
345
 
469
- return run;
470
- }
346
+ // Real network failures → bugs
347
+ for (const nErr of result.networkErrors) {
348
+ this.#session.addBug({
349
+ title : `Network Failure: ${nErr.url}`,
350
+ severity : 'P2',
351
+ type : 'network',
352
+ description: `${nErr.method} ${nErr.url} → ${nErr.failure}`,
353
+ url : route.url,
354
+ evidence : nErr,
355
+ });
356
+ }
471
357
 
472
- // ─────────────────────────────────────────────────────────────────────────
473
- // Live Dashboard Renderer (v9.0 retained + v10 label)
474
- // ─────────────────────────────────────────────────────────────────────────
358
+ // Test forms on the page
359
+ if (result.forms && result.forms.length > 0) {
360
+ await this.#testForms(route.url, result.forms, result.page);
361
+ }
475
362
 
476
- class LiveDashboard {
477
- #lines = 0;
478
- #active = false;
479
- #startTime = Date.now();
480
- #lastResults = [];
481
- #runningTest = null;
482
- #bugs = [];
483
- #log = [];
484
-
485
- start() {
486
- this.#active = true;
487
- this.#startTime = Date.now();
488
- process.stdout.write(CURSOR_HIDE);
489
- this.render({});
363
+ // Test auth flows
364
+ if (this.#isAuthPage(route.url)) {
365
+ await this.#testAuthFlow(route.url, result.page);
366
+ }
367
+ }
490
368
  }
491
369
 
492
- stop() {
493
- this.#active = false;
494
- process.stdout.write(CURSOR_SHOW);
495
- this.#clearLines();
496
- }
370
+ // ── Phase 4: Security ─────────────────────────────────────────────────
371
+ async #phaseSecurityScan() {
372
+ for (const [label, url] of Object.entries(this.#session.urls)) {
373
+ if (!url) continue;
497
374
 
498
- updateRunning(name) { this.#runningTest = name; }
499
- addResult(r) { this.#lastResults.push(r); this.#runningTest = null; }
500
- addBug(b) { this.#bugs.push(b); }
501
- addLog(msg) { this.#log.push(`${DIM(new Date().toLocaleTimeString())} ${msg}`); if (this.#log.length > 8) this.#log.shift(); }
502
-
503
- render(summary) {
504
- if (!this.#active) return;
505
- this.#clearLines();
506
- const lines = this.#buildLines(summary);
507
- this.#lines = lines.length;
508
- process.stdout.write(lines.join('\n') + '\n');
509
- }
375
+ const findings = await this.#security.scan(url);
376
+ this.#session.secFindings.push(...findings);
377
+
378
+ for (const finding of findings) {
379
+ this.#addResult({
380
+ name : `Security: ${finding.check}`,
381
+ type : 'security',
382
+ category: finding.category,
383
+ status : finding.pass ? 'PASS' : 'FAIL',
384
+ message : finding.detail,
385
+ data : finding.evidence,
386
+ url,
387
+ label,
388
+ severity: finding.severity,
389
+ });
510
390
 
511
- #clearLines() {
512
- if (this.#lines > 0) {
513
- process.stdout.write(CURSOR_UP(this.#lines) + CLEAR_LINE);
514
- for (let i = 1; i < this.#lines; i++) process.stdout.write('\n' + CLEAR_LINE);
515
- process.stdout.write(CURSOR_UP(this.#lines - 1));
391
+ if (!finding.pass && (finding.severity === 'P0' || finding.severity === 'P1')) {
392
+ this.#session.addBug({
393
+ title : `Security: ${finding.check}`,
394
+ severity : finding.severity,
395
+ type : 'security',
396
+ description: finding.detail,
397
+ url,
398
+ evidence : finding.evidence,
399
+ recommendation: finding.recommendation,
400
+ });
401
+ }
402
+ }
516
403
  }
517
404
  }
518
405
 
519
- #buildLines(summary = {}) {
520
- const elapsed = ((Date.now() - this.#startTime) / 1000).toFixed(1);
521
- const sys = getSystemStats();
522
- const results = this.#lastResults;
523
- const total = results.length;
524
- const passed = results.filter(r => ['PASS','FLAKY'].includes(r.status)).length;
525
- const failed = results.filter(r => r.status === 'FAIL').length;
526
- const flaky = results.filter(r => r.status === 'FLAKY').length;
527
- const passRate = total > 0 ? Math.round((passed / total) * 100) : 0;
528
-
529
- const lines = [];
530
- const w = Math.min(process.stdout.columns || 80, 88);
531
- const bar = ''.repeat(w - 2);
532
-
533
- lines.push(chalk.hex('#00F5FF').bold(`┌${bar}┐`));
534
- lines.push(chalk.hex('#00F5FF').bold('│') + chalk.hex('#BF40FF').bold(` BACKLIST LIVE QA DASHBOARD v${VERSION}`.padEnd(w - 2)) + chalk.hex('#00F5FF').bold('│'));
535
- lines.push(chalk.hex('#00F5FF').bold(`├${bar}┤`));
536
-
537
- const metrics = [
538
- `${chalk.green('✓')} ${chalk.white.bold(passed)} passed`,
539
- `${chalk.red('✗')} ${chalk.white.bold(failed)} failed`,
540
- `${chalk.yellow('⚠')} ${chalk.white.bold(flaky)} flaky`,
541
- `${chalk.cyan('🐛')} ${chalk.white.bold(this.#bugs.length)} bugs`,
542
- `${chalk.gray('⏱')} ${chalk.white(elapsed + 's')}`,
543
- ].map(m => m.padEnd(20)).join(' ');
544
- lines.push(chalk.hex('#00F5FF').bold('│') + ' ' + metrics.slice(0, w - 4) + chalk.hex('#00F5FF').bold('│'));
545
-
546
- const pBar = buildProgressBar(passRate, 30);
547
- lines.push(chalk.hex('#00F5FF').bold('│') + ` Pass rate [${pBar}] ${chalk.white.bold(passRate + '%')} (${total} tests)`.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
548
-
549
- const sysLine = ` ${DIM('Heap')} ${chalk.white(sys.heapMB + 'MB')} ${DIM('RSS')} ${chalk.white(sys.rss)} ${DIM('Uptime')} ${chalk.white(sys.uptime + 's')} ${DIM('Node')} ${chalk.white(process.version)}`;
550
- lines.push(chalk.hex('#00F5FF').bold('│') + sysLine.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
551
- lines.push(chalk.hex('#00F5FF').bold(`├${'─'.repeat(w - 2)}┤`));
552
-
553
- const runLine = this.#runningTest
554
- ? ` ${chalk.cyan('⟳')} ${chalk.cyan('Running:')} ${chalk.white(this.#runningTest.slice(0, w - 16))}`
555
- : ` ${chalk.gray('⊘ Idle...')}`;
556
- lines.push(chalk.hex('#00F5FF').bold('│') + runLine.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
557
- lines.push(chalk.hex('#00F5FF').bold(`├${'─'.repeat(w - 2)}┤`));
558
-
559
- lines.push(chalk.hex('#00F5FF').bold('│') + chalk.gray(' Recent results:').padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
560
- const recentResults = results.slice(-5);
561
- for (const r of recentResults) {
562
- const type = chalk.gray(`[${(r.type || '').padEnd(11)}]`);
563
- const dur = chalk.gray(formatDuration(r.duration));
564
- const name = r.name.slice(0, w - 40);
565
- const row = ` ${colorStatus(r.status)} ${type} ${chalk.white(name)} ${dur}`;
566
- lines.push(chalk.hex('#00F5FF').bold('│') + row.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
567
- }
568
- for (let i = recentResults.length; i < 5; i++) {
569
- lines.push(chalk.hex('#00F5FF').bold('│') + ' '.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
570
- }
406
+ // ── Phase 5: Performance ──────────────────────────────────────────────
407
+ async #phasePerformance() {
408
+ for (const [label, url] of Object.entries(this.#session.urls)) {
409
+ if (!url) continue;
410
+
411
+ const metrics = await this.#performance.profile(url);
412
+ this.#session.perfMetrics[label] = metrics;
413
+
414
+ // Core Web Vitals as real test results
415
+ const vitals = [
416
+ { name: 'LCP', value: metrics.lcp, threshold: 2500, unit: 'ms' },
417
+ { name: 'FID', value: metrics.fid, threshold: 100, unit: 'ms' },
418
+ { name: 'CLS', value: metrics.cls, threshold: 0.1, unit: '' },
419
+ { name: 'FCP', value: metrics.fcp, threshold: 1800, unit: 'ms' },
420
+ { name: 'TTFB', value: metrics.ttfb, threshold: 800, unit: 'ms' },
421
+ { name: 'TTI', value: metrics.tti, threshold: 3800, unit: 'ms' },
422
+ { name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
423
+ ];
424
+
425
+ for (const vital of vitals) {
426
+ const pass = vital.value !== null && vital.value <= vital.threshold;
427
+ const na = vital.value === null;
428
+
429
+ this.#addResult({
430
+ name : `[${label}] ${vital.name} Core Web Vital`,
431
+ type : 'performance',
432
+ category: 'web-vitals',
433
+ status : na ? 'SKIP' : (pass ? 'PASS' : 'FAIL'),
434
+ message : na
435
+ ? `${vital.name} not measurable`
436
+ : `${vital.name}: ${vital.value}${vital.unit} (threshold: ${vital.threshold}${vital.unit})`,
437
+ data : { value: vital.value, threshold: vital.threshold, unit: vital.unit },
438
+ url,
439
+ label,
440
+ duration: vital.value,
441
+ });
571
442
 
572
- lines.push(chalk.hex('#00F5FF').bold(`├${'─'.repeat(w - 2)}┤`));
573
- lines.push(chalk.hex('#00F5FF').bold('│') + chalk.gray(' Active bugs:').padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
574
- const recentBugs = this.#bugs.slice(-3);
575
- for (const b of recentBugs) {
576
- const bugLine = ` ${colorSeverity(b.severity)} ${chalk.white(b.title.slice(0, w - 20))}`;
577
- lines.push(chalk.hex('#00F5FF').bold('│') + bugLine.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
578
- }
579
- for (let i = recentBugs.length; i < 3; i++) {
580
- lines.push(chalk.hex('#00F5FF').bold('│') + ' '.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
581
- }
443
+ if (!na && !pass) {
444
+ this.#session.addBug({
445
+ title : `Poor ${vital.name}: ${vital.value}${vital.unit} (>${vital.threshold}${vital.unit})`,
446
+ severity : vital.name === 'LCP' || vital.name === 'CLS' ? 'P1' : 'P2',
447
+ type : 'performance',
448
+ description: `${vital.name} exceeds threshold on ${label}`,
449
+ url,
450
+ evidence : { value: vital.value, threshold: vital.threshold },
451
+ recommendation: `Optimize ${vital.name} see https://web.dev/vitals`,
452
+ });
453
+ }
454
+ }
582
455
 
583
- lines.push(chalk.hex('#00F5FF').bold(`├${'─'.repeat(w - 2)}┤`));
584
- lines.push(chalk.hex('#00F5FF').bold('│') + chalk.gray(' Event log:').padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
585
- const recentLogs = this.#log.slice(-4);
586
- for (const entry of recentLogs) {
587
- lines.push(chalk.hex('#00F5FF').bold('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
588
- }
589
- for (let i = recentLogs.length; i < 4; i++) {
590
- lines.push(chalk.hex('#00F5FF').bold('│') + ' '.padEnd(w - 2) + chalk.hex('#00F5FF').bold('│'));
456
+ // Real resource analysis
457
+ for (const resource of (metrics.slowResources || [])) {
458
+ this.#addResult({
459
+ name : `[${label}] Slow resource: ${resource.url.split('/').pop()}`,
460
+ type : 'performance',
461
+ category: 'resource',
462
+ status : 'FAIL',
463
+ message : `${resource.url} took ${resource.duration}ms (${formatBytes(resource.size)})`,
464
+ data : resource,
465
+ url,
466
+ label,
467
+ duration: resource.duration,
468
+ });
469
+ }
591
470
  }
592
-
593
- lines.push(chalk.hex('#00F5FF').bold(`└${bar}┘`));
594
- lines.push(DIM(' Press Ctrl+C to stop'));
595
- return lines;
596
471
  }
597
- }
598
-
599
- // ─────────────────────────────────────────────────────────────────────────
600
- // Test Runner (v9 retained + sev field propagation)
601
- // ─────────────────────────────────────────────────────────────────────────
602
472
 
603
- class TestRunner extends EventEmitter {
604
- #results = [];
605
- #running = false;
606
- #aborted = false;
473
+ // ── Phase 6: Accessibility ────────────────────────────────────────────
474
+ async #phaseAccessibility() {
475
+ const pageRoutes = this.#session.routeMap
476
+ .filter(r => r.type === 'page' || r.type === 'unknown')
477
+ .slice(0, 15);
607
478
 
608
- async run(tests, dashboard = null) {
609
- this.#running = true;
610
- this.#aborted = false;
611
- this.#results = [];
612
-
613
- for (const test of tests) {
479
+ for (const route of pageRoutes) {
614
480
  if (this.#aborted) break;
615
- if (dashboard) {
616
- dashboard.updateRunning(test.name);
617
- dashboard.addLog(`Starting: ${test.name}`);
618
- dashboard.render({});
619
- }
481
+ this.#terminal.setCurrentTest(`A11y: ${route.url}`);
482
+
483
+ const result = await this.#a11y.check(route.url);
484
+ this.#session.a11yResults.push({ url: route.url, ...result });
485
+
486
+ for (const violation of result.violations) {
487
+ this.#addResult({
488
+ name : `A11y [${violation.impact}]: ${violation.description}`,
489
+ type : 'accessibility',
490
+ category: violation.category || 'wcag',
491
+ status : 'FAIL',
492
+ message : `${violation.nodes} element(s) affected — ${violation.help}`,
493
+ data : {
494
+ impact : violation.impact,
495
+ wcagTags: violation.tags,
496
+ nodes : violation.affectedNodes,
497
+ helpUrl : violation.helpUrl,
498
+ },
499
+ url : route.url,
500
+ severity: violation.impact === 'critical' ? 'P0'
501
+ : violation.impact === 'serious' ? 'P1'
502
+ : violation.impact === 'moderate' ? 'P2' : 'P3',
503
+ });
620
504
 
621
- const result = await this.#runOne(test);
622
- this.#results.push(result);
623
- this.emit('result', result);
624
-
625
- if (dashboard) {
626
- dashboard.addResult(result);
627
- if (result.status === 'FAIL') {
628
- dashboard.addBug({
629
- id : `AUTO-${shortId()}`,
630
- title : `${test.name}`,
631
- severity: test.sev || this.#classifySeverity(test.type, result.error),
632
- status : 'OPEN',
505
+ if (violation.impact === 'critical' || violation.impact === 'serious') {
506
+ this.#session.addBug({
507
+ title : `A11y: ${violation.description}`,
508
+ severity : violation.impact === 'critical' ? 'P0' : 'P1',
509
+ type : 'accessibility',
510
+ description: `${violation.nodes} element(s): ${violation.help}`,
511
+ url : route.url,
512
+ evidence : violation.affectedNodes,
513
+ recommendation: violation.helpUrl,
633
514
  });
634
- dashboard.addLog(chalk.red(`FAIL: ${test.name} — ${result.error ?? 'unknown'}`));
635
- } else {
636
- dashboard.addLog(chalk.green(`${result.status}: ${test.name} (${formatDuration(result.duration)})`));
637
515
  }
638
- dashboard.render({});
639
- await sleep(60);
640
516
  }
641
- }
642
517
 
643
- this.#running = false;
644
- return [...this.#results];
518
+ // Passes also recorded as real results
519
+ for (const pass of (result.passes || []).slice(0, 5)) {
520
+ this.#addResult({
521
+ name : `A11y Pass: ${pass.description}`,
522
+ type : 'accessibility',
523
+ category: 'wcag',
524
+ status : 'PASS',
525
+ message : `${pass.nodes} element(s) verified`,
526
+ data : pass,
527
+ url : route.url,
528
+ });
529
+ }
530
+ }
645
531
  }
646
532
 
647
- abort() { this.#aborted = true; }
533
+ // ── Phase 7: SEO ──────────────────────────────────────────────────────
534
+ async #phaseSEO() {
535
+ const pageRoutes = this.#session.routeMap
536
+ .filter(r => r.type === 'page' || r.type === 'unknown')
537
+ .slice(0, 20);
648
538
 
649
- #classifySeverity(type, error = '') {
650
- if (type === 'auth' || type === 'security') return 'P0';
651
- if (type === 'e2e' || error?.includes('crash')) return 'P1';
652
- if (type === 'validation' || type === 'performance') return 'P2';
653
- return 'P3';
654
- }
539
+ for (const route of pageRoutes) {
540
+ if (this.#aborted) break;
541
+ this.#terminal.setCurrentTest(`SEO: ${route.url}`);
542
+
543
+ const result = await this.#seo.scan(route.url);
544
+ this.#session.seoResults.push({ url: route.url, ...result });
545
+
546
+ for (const check of result.checks) {
547
+ this.#addResult({
548
+ name : `SEO: ${check.name} — ${new URL(route.url).pathname}`,
549
+ type : 'seo',
550
+ category: check.category,
551
+ status : check.pass ? 'PASS' : 'FAIL',
552
+ message : check.detail,
553
+ data : check.data,
554
+ url : route.url,
555
+ severity: check.severity,
556
+ });
655
557
 
656
- async #runOne(test) {
657
- const { id, name, type, sev, fn, timeout = DEFAULT_TIMEOUT_MS } = test;
658
- const start = Date.now();
659
- let retries = 0;
660
- let lastError = null;
661
-
662
- for (let attempt = 0; attempt <= FLAKY_RETRY_COUNT; attempt++) {
663
- try {
664
- await Promise.race([
665
- fn(),
666
- sleep(timeout).then(() => { throw new Error(`Timed out after ${timeout}ms`); }),
667
- ]);
668
- const status = attempt > 0 ? 'FLAKY' : 'PASS';
669
- return { id, name, type, sev, status, duration: Date.now() - start, retries: attempt, error: null };
670
- } catch (err) {
671
- lastError = err.message;
672
- retries = attempt;
673
- if (attempt < FLAKY_RETRY_COUNT) await sleep(200);
558
+ if (!check.pass && (check.severity === 'P0' || check.severity === 'P1')) {
559
+ this.#session.addBug({
560
+ title : `SEO: ${check.name}`,
561
+ severity : check.severity,
562
+ type : 'seo',
563
+ description: check.detail,
564
+ url : route.url,
565
+ recommendation: check.recommendation,
566
+ });
567
+ }
674
568
  }
675
569
  }
676
-
677
- return { id, name, type, sev, status: 'FAIL', duration: Date.now() - start, retries, error: lastError };
678
570
  }
679
- }
680
571
 
681
- // ─────────────────────────────────────────────────────────────────────────
682
- // File-System Test Suites (v9 retained)
683
- // ─────────────────────────────────────────────────────────────────────────
572
+ // ── Phase 8: AI Classification ────────────────────────────────────────
573
+ async #phaseAIClassification() {
574
+ this.#terminal.log(`AI classifying ${this.#session.bugs.length} bugs...`);
684
575
 
685
- function buildEndpointTests(endpoints) {
686
- const tests = [];
687
- for (const ep of endpoints) {
688
- const label = `${ep.method} ${ep.route}`;
689
- tests.push({ id: shortId(), name: `Happy path: ${label}`, type: 'happy-path', sev: 'P2', fn: async () => {
690
- await sleep(30 + Math.random() * 80);
691
- if (!ep.route || !ep.method) throw new Error('Endpoint missing route or method');
692
- }});
693
- if (ep.schemaFields && Object.keys(ep.schemaFields).length > 0) {
694
- tests.push({ id: shortId(), name: `Validation: ${label}`, type: 'validation', sev: 'P2', fn: async () => {
695
- await sleep(25);
696
- const missing = Object.entries(ep.schemaFields).filter(([, t]) => !t);
697
- if (missing.length) throw new Error(`Fields missing types: ${missing.map(([k]) => k).join(', ')}`);
698
- }});
699
- }
700
- if (/\/admin|\/user|\/auth|\/profile|\/dashboard|\/private/i.test(ep.route)) {
701
- tests.push({ id: shortId(), name: `Auth guard: ${label}`, type: 'auth', sev: 'P0', fn: async () => {
702
- await sleep(40);
703
- }});
576
+ for (const bug of this.#session.bugs) {
577
+ const classification = await this.#aiClassifier.classify(bug, this.#session);
578
+ bug.aiSeverity = classification.severity;
579
+ bug.aiCategory = classification.category;
580
+ bug.aiRecommendation = classification.recommendation;
581
+ bug.aiConfidence = classification.confidence;
704
582
  }
583
+
584
+ // Sort bugs by AI-determined severity
585
+ this.#session.bugs.sort((a, b) => {
586
+ const order = { P0: 0, P1: 1, P2: 2, P3: 3 };
587
+ return (order[a.aiSeverity || a.severity] || 3) - (order[b.aiSeverity || b.severity] || 3);
588
+ });
705
589
  }
706
- return tests;
707
- }
708
590
 
709
- function buildFullSystemTests(projectDir = process.cwd()) {
710
- const tests = [];
711
-
712
- tests.push({ id: shortId(), name: 'Project structure integrity', type: 'e2e', sev: 'P1', fn: async () => {
713
- if (!(await fs.pathExists(projectDir))) throw new Error('Project directory not found');
714
- }});
715
- tests.push({ id: shortId(), name: 'Package.json valid', type: 'validation', sev: 'P1', fn: async () => {
716
- const pkgPath = path.join(projectDir, 'package.json');
717
- if (!(await fs.pathExists(pkgPath))) throw new Error('package.json missing');
718
- const pkg = await fs.readJson(pkgPath);
719
- if (!pkg.name) throw new Error('package.json has no name field');
720
- }});
721
- tests.push({ id: shortId(), name: 'Dependencies declared', type: 'validation', sev: 'P2', fn: async () => {
722
- const pkgPath = path.join(projectDir, 'package.json');
723
- if (!(await fs.pathExists(pkgPath))) return;
724
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
725
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
726
- if (Object.keys(deps).length === 0) throw new Error('No dependencies declared');
727
- }});
728
- tests.push({ id: shortId(), name: 'API routes file exists', type: 'happy-path', sev: 'P1', fn: async () => {
729
- const candidates = ['src/routes', 'routes', 'src/api', 'api', 'src/controllers', 'controllers'];
730
- for (const c of candidates) { if (await fs.pathExists(path.join(projectDir, c))) return; }
731
- throw new Error('No routes/api directory found');
732
- }});
733
- tests.push({ id: shortId(), name: 'Entry point reachable', type: 'happy-path', sev: 'P0', fn: async () => {
734
- const candidates = ['src/index.ts', 'src/index.js', 'index.ts', 'index.js', 'main.py', 'main.go', 'Program.cs'];
735
- for (const c of candidates) { if (await fs.pathExists(path.join(projectDir, c))) return; }
736
- throw new Error('No recognisable entry point found');
737
- }});
738
- tests.push({ id: shortId(), name: 'Environment config present', type: 'validation', sev: 'P1', fn: async () => {
739
- const candidates = ['.env', '.env.example', '.env.sample', 'config.js', 'config.ts', 'appsettings.json'];
740
- for (const c of candidates) { if (await fs.pathExists(path.join(projectDir, c))) return; }
741
- throw new Error('No environment config file found');
742
- }});
743
- tests.push({ id: shortId(), name: 'Password hashing library', type: 'security', sev: 'P0', fn: async () => {
744
- const pkgPath = path.join(projectDir, 'package.json');
745
- if (!(await fs.pathExists(pkgPath))) return;
746
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
747
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
748
- if (!['bcrypt', 'bcryptjs', 'argon2', 'argon2d'].some(d => deps[d]))
749
- throw new Error('No password hashing library (bcrypt/argon2) found');
750
- }});
751
- tests.push({ id: shortId(), name: 'Database schema defined', type: 'validation', sev: 'P1', fn: async () => {
752
- const candidates = ['prisma/schema.prisma', 'schema.prisma', 'src/models', 'models', 'src/entities'];
753
- for (const c of candidates) { if (await fs.pathExists(path.join(projectDir, c))) return; }
754
- throw new Error('No database schema/models directory found');
755
- }});
756
- tests.push({ id: shortId(), name: 'CORS config found', type: 'security', sev: 'P1', fn: async () => {
757
- const candidates = ['src/index.ts', 'src/index.js', 'src/app.ts', 'src/app.js', 'index.js', 'index.ts'];
758
- for (const c of candidates) {
759
- const filePath = path.join(projectDir, c);
760
- if (await fs.pathExists(filePath)) {
761
- const content = await fs.readFile(filePath, 'utf8').catch(() => '');
762
- if (/cors|CORS/i.test(content)) return;
763
- }
764
- }
765
- throw new Error('No CORS configuration detected');
766
- }});
767
- tests.push({ id: shortId(), name: 'Rate limiting configured', type: 'security', sev: 'P1', fn: async () => {
768
- const pkgPath = path.join(projectDir, 'package.json');
769
- if (!(await fs.pathExists(pkgPath))) return;
770
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
771
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
772
- if (!['express-rate-limit', 'rate-limiter-flexible', 'fastapi-limiter', 'throttler'].some(d => deps[d]))
773
- throw new Error('No rate-limiting library found');
774
- }});
775
- tests.push({ id: shortId(), name: 'Secrets not hardcoded', type: 'security', sev: 'P0', fn: async () => {
776
- const pattern = /(?:password|secret|apikey|api_key)\s*=\s*['"][^'"]{6,}['"]/i;
777
- const targets = ['src/index.ts', 'src/index.js', 'src/app.ts', 'src/app.js'];
778
- for (const t of targets) {
779
- const fp = path.join(projectDir, t);
780
- if (await fs.pathExists(fp)) {
781
- const content = await fs.readFile(fp, 'utf8').catch(() => '');
782
- if (pattern.test(content)) throw new Error(`Hardcoded secret detected in ${t}`);
783
- }
784
- }
785
- }});
786
- tests.push({ id: shortId(), name: 'Heap memory acceptable', type: 'performance', sev: 'P2', fn: async () => {
787
- const heapMB = process.memoryUsage().heapUsed / 1024 / 1024;
788
- if (heapMB > 512) throw new Error(`Heap ${heapMB.toFixed(0)}MB exceeds 512MB limit`);
789
- }});
790
- tests.push({ id: shortId(), name: 'Dockerfile present', type: 'e2e', sev: 'P2', fn: async () => {
791
- const candidates = ['Dockerfile', 'Dockerfile.dev', 'docker-compose.yml', 'docker-compose.yaml'];
792
- for (const c of candidates) { if (await fs.pathExists(path.join(projectDir, c))) return; }
793
- throw new Error('No Docker configuration found');
794
- }});
795
- tests.push({ id: shortId(), name: 'CI/CD pipeline configured', type: 'e2e', sev: 'P2', fn: async () => {
796
- const ciPaths = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'Jenkinsfile'];
797
- for (const c of ciPaths) { if (await fs.pathExists(path.join(projectDir, c))) return; }
798
- throw new Error('No CI/CD pipeline detected');
799
- }});
800
- tests.push({ id: shortId(), name: 'Test files exist', type: 'e2e', sev: 'P2', fn: async () => {
801
- const testDirs = ['tests', 'test', '__tests__', 'spec'];
802
- for (const d of testDirs) {
803
- if (await fs.pathExists(path.join(projectDir, d))) {
804
- const files = await fs.readdir(path.join(projectDir, d)).catch(() => []);
805
- if (files.length > 0) return;
591
+ // ── Form Testing ──────────────────────────────────────────────────────
592
+ async #testForms(url, forms, page) {
593
+ for (const form of forms.slice(0, 3)) {
594
+ this.#terminal.setCurrentTest(`Form: ${url} ${form.action || 'unknown'}`);
595
+
596
+ const result = await this.#interactor.testForm(page, form);
597
+
598
+ this.#addResult({
599
+ name : `Form test: ${url} ${form.action || 'inline'}`,
600
+ type : 'form',
601
+ category: 'user-flow',
602
+ status : result.pass ? 'PASS' : 'FAIL',
603
+ message : result.message,
604
+ data : {
605
+ fields : form.fields,
606
+ action : form.action,
607
+ method : form.method,
608
+ validationOk: result.validationOk,
609
+ submissionOk: result.submissionOk,
610
+ errors : result.errors,
611
+ },
612
+ url,
613
+ duration: result.duration,
614
+ });
615
+
616
+ if (!result.pass) {
617
+ this.#session.addBug({
618
+ title : `Form broken: ${form.action || url}`,
619
+ severity : 'P1',
620
+ type : 'form',
621
+ description: result.message,
622
+ url,
623
+ evidence : result.errors,
624
+ });
806
625
  }
807
626
  }
808
- throw new Error('No test files found');
809
- }});
810
- tests.push({ id: shortId(), name: 'API documentation configured', type: 'happy-path', sev: 'P3', fn: async () => {
811
- const pkgPath = path.join(projectDir, 'package.json');
812
- if (!(await fs.pathExists(pkgPath))) return;
813
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
814
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
815
- if (!['swagger-ui-express', 'swagger-jsdoc', '@nestjs/swagger', 'fastapi', 'springdoc-openapi'].some(d => deps[d]))
816
- throw new Error('No API documentation library found');
817
- }});
818
- tests.push({ id: shortId(), name: 'Logging library present', type: 'validation', sev: 'P2', fn: async () => {
819
- const pkgPath = path.join(projectDir, 'package.json');
820
- if (!(await fs.pathExists(pkgPath))) return;
821
- const pkg = await fs.readJson(pkgPath).catch(() => ({}));
822
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
823
- if (!['winston', 'pino', 'morgan', 'log4j', 'structlog'].some(d => deps[d]))
824
- throw new Error('No structured logging library found');
825
- }});
826
- tests.push({ id: shortId(), name: 'QA system operational', type: 'happy-path', sev: 'P3', fn: async () => {
827
- await fs.ensureDir(QA_DIR);
828
- }});
829
- tests.push({ id: shortId(), name: 'Report directory writable', type: 'happy-path', sev: 'P3', fn: async () => {
830
- await fs.ensureDir(REPORT_DIR);
831
- const testFile = path.join(REPORT_DIR, `.write-test-${shortId()}`);
832
- await fs.writeFile(testFile, 'ok');
833
- await fs.remove(testFile);
834
- }});
835
-
836
- return tests;
837
- }
838
-
839
- function buildUITests(srcDir = path.join(process.cwd(), 'src')) {
840
- return [
841
- { id: shortId(), name: 'Frontend src directory exists', type: 'ui', sev: 'P1', fn: async () => {
842
- if (!(await fs.pathExists(srcDir))) throw new Error(`src not found: ${srcDir}`);
843
- }},
844
- { id: shortId(), name: 'Component files present', type: 'ui', sev: 'P1', fn: async () => {
845
- const exts = ['.tsx', '.jsx', '.vue', '.svelte'];
846
- let found = false;
847
- const walk = async (dir) => {
848
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
849
- for (const e of entries) {
850
- if (e.isDirectory() && e.name !== 'node_modules') await walk(path.join(dir, e.name));
851
- else if (exts.some(x => e.name.endsWith(x))) { found = true; return; }
852
- }
853
- };
854
- await walk(srcDir);
855
- if (!found) throw new Error('No component files found');
856
- }},
857
- { id: shortId(), name: 'Styles configured', type: 'ui', sev: 'P2', fn: async () => {
858
- const patterns = ['tailwind.config', 'postcss.config', 'vite.config', 'styles', 'css', 'scss'];
859
- const entries = await fs.readdir(process.cwd()).catch(() => []);
860
- if (!entries.some(f => patterns.some(p => f.includes(p)))) throw new Error('No styling configuration found');
861
- }},
862
- { id: shortId(), name: 'API client configuration', type: 'ui', sev: 'P2', fn: async () => {
863
- const apiFiles = ['src/api', 'src/services', 'src/lib', 'src/utils'];
864
- for (const f of apiFiles) { if (await fs.pathExists(path.join(process.cwd(), f))) return; }
865
- throw new Error('No API client/services directory found');
866
- }},
867
- ];
868
- }
869
-
870
- // ── Helpers ────────────────────────────────────────────────────────────────
871
- function buildCoverageMatrix(results) {
872
- const matrix = {};
873
- for (const r of results) {
874
- if (!matrix[r.type]) matrix[r.type] = { total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0 };
875
- matrix[r.type].total++;
876
- if (r.status === 'PASS') matrix[r.type].passed++;
877
- if (r.status === 'FAIL') matrix[r.type].failed++;
878
- if (r.status === 'SKIP') matrix[r.type].skipped++;
879
- if (r.status === 'FLAKY') { matrix[r.type].flaky++; matrix[r.type].passed++; }
880
627
  }
881
- return matrix;
882
- }
883
628
 
884
- function buildSummary(results) {
885
- return {
886
- total : results.length,
887
- passed : results.filter(r => ['PASS','FLAKY'].includes(r.status)).length,
888
- failed : results.filter(r => r.status === 'FAIL').length,
889
- skipped: results.filter(r => r.status === 'SKIP').length,
890
- flaky : results.filter(r => r.status === 'FLAKY').length,
891
- };
892
- }
893
-
894
- // ─────────────────────────────────────────────────────────────────────────
895
- // HTML Report v10.0 — with route cards + dual-URL diff
896
- // ─────────────────────────────────────────────────────────────────────────
629
+ // ── Auth Flow Testing ─────────────────────────────────────────────────
630
+ async #testAuthFlow(url, page) {
631
+ this.#terminal.setCurrentTest(`Auth flow: ${url}`);
897
632
 
898
- function buildHTMLReport(runData) {
899
- const { id, startedAt, duration, results, bugReports, coverage, summary, urls = [], routeScans = [] } = runData;
900
- const passRate = summary.total > 0 ? ((summary.passed / summary.total) * 100).toFixed(1) : 0;
901
- const statusColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
902
-
903
- const typeColors = {
904
- 'happy-path' : ['#064e3b','#34d399'], 'validation' : ['#1e3a5f','#60a5fa'],
905
- 'auth' : ['#3b1f5e','#c084fc'], 'edge-case' : ['#3b2a1a','#f59e0b'],
906
- 'performance': ['#1a2a3b','#38bdf8'], 'security' : ['#450a0a','#f87171'],
907
- 'e2e' : ['#1a3b2a','#4ade80'], 'ui' : ['#2a1a3b','#a78bfa'],
908
- 'http' : ['#0f2a3b','#38bdf8'], 'seo' : ['#1a2e0f','#86efac'],
909
- 'a11y' : ['#2e1a0f','#fca5a5'], 'links' : ['#0f1a2e','#93c5fd'],
910
- };
633
+ const result = await this.#interactor.testAuthFlow(page, url, {
634
+ testCredentials: [
635
+ { username: 'test@example.com', password: 'wrong-password-test', expectFail: true },
636
+ { username: 'invalid@test.com', password: 'wrong123', expectFail: true },
637
+ { username: '', password: '', expectFail: true },
638
+ ],
639
+ });
911
640
 
912
- const badgeStyle = (type) => {
913
- const [bg, fg] = typeColors[type] ?? ['#1e293b','#94a3b8'];
914
- return `background:${bg};color:${fg};padding:2px 8px;border-radius:4px;font-size:.75rem;font-weight:500`;
915
- };
641
+ this.#addResult({
642
+ name : `Auth flow: ${url}`,
643
+ type : 'auth',
644
+ category: 'authentication',
645
+ status : result.pass ? 'PASS' : 'FAIL',
646
+ message : result.message,
647
+ data : result.details,
648
+ url,
649
+ duration: result.duration,
650
+ });
916
651
 
917
- const covBars = Object.entries(coverage).map(([type, d]) => {
918
- const pct = d.total ? ((d.passed / d.total) * 100).toFixed(0) : 0;
919
- const [, fg] = typeColors[type] ?? ['','#94a3b8'];
920
- return `<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.75rem">
921
- <div style="width:110px;font-size:.8rem;color:#94a3b8">${type}</div>
922
- <div style="flex:1;background:#2d2d4e;border-radius:4px;height:8px;overflow:hidden">
923
- <div style="height:100%;width:${pct}%;background:${fg};border-radius:4px"></div>
924
- </div>
925
- <div style="width:60px;text-align:right;font-size:.8rem;color:#64748b">${d.passed}/${d.total}</div>
926
- </div>`;
927
- }).join('');
928
-
929
- const rows = results.map(r => `<tr class="${r.status.toLowerCase()}">
930
- <td>${r.name}</td>
931
- <td><span style="${badgeStyle(r.type)}">${r.type}</span></td>
932
- <td><span class="status status-${r.status.toLowerCase()}">${r.status}</span></td>
933
- <td>${r.sev ? `<span class="sev-${(r.sev||'').toLowerCase()}">${r.sev}</span>` : '—'}</td>
934
- <td>${r.duration}ms</td>
935
- <td>${r.retries > 0 ? `<span style="background:#422006;color:#fb923c;padding:2px 8px;border-radius:4px;font-size:.75rem">${r.retries}x retry</span>` : '—'}</td>
936
- <td class="err">${r.error ? `<code>${r.error}</code>` : '—'}</td>
937
- </tr>`).join('');
938
-
939
- const bugCards = bugReports.length ? bugReports.map(b => `
940
- <div class="bug-card bug-${(b.severity||'p3').toLowerCase()}">
941
- <div class="bug-header">
942
- <span class="bug-id">${b.id}</span>
943
- <span class="bug-sev">${b.severity}</span>
944
- <span class="bug-st">${b.status}</span>
945
- </div>
946
- <div class="bug-title">${b.title}</div>
947
- ${b.description ? `<div class="bug-desc">${b.description}</div>` : ''}
948
- </div>`).join('') : '<p style="color:#34d399;text-align:center;padding:1rem">No bug reports 🎉</p>';
949
-
950
- const urlCards = urls.length ? urls.map(u => `
951
- <div style="background:#1e1e30;border:1px solid #2d2d4e;border-radius:8px;padding:1rem;margin-bottom:.75rem">
952
- <div style="display:flex;justify-content:space-between;align-items:center">
953
- <span style="font-size:.8rem;color:#64748b;text-transform:uppercase">${u.label}</span>
954
- <a href="${u.url}" target="_blank" style="font-size:.8rem;color:#60a5fa">${u.url}</a>
955
- </div>
956
- </div>`).join('') : '';
957
-
958
- const routeCards = routeScans.length ? routeScans.map(r => `
959
- <div style="display:flex;align-items:center;gap:.75rem;padding:.5rem .75rem;border-bottom:1px solid #1a1a2e;font-size:.8rem">
960
- <span style="font-family:monospace;color:${r.status >= 200 && r.status < 400 ? '#34d399' : r.status >= 500 ? '#f87171' : '#f59e0b'}">${r.status || 'ERR'}</span>
961
- <span style="flex:1;color:#94a3b8;font-family:monospace">${r.route}</span>
962
- <span style="color:#64748b">${r.duration}ms</span>
963
- <span style="font-size:.7rem;padding:2px 6px;background:#1e293b;color:#64748b;border-radius:3px">${r.label}</span>
964
- </div>`).join('') : '<p style="color:#64748b;font-size:.85rem;padding:.5rem">No route scans recorded.</p>';
965
-
966
- const chartLabels = JSON.stringify(Object.keys(coverage));
967
- const chartPassed = JSON.stringify(Object.values(coverage).map(d => d.passed));
968
- const chartFailed = JSON.stringify(Object.values(coverage).map(d => d.failed));
969
-
970
- const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
971
- bugReports.forEach(b => { if (sevCounts[b.severity] !== undefined) sevCounts[b.severity]++; });
972
-
973
- return `<!DOCTYPE html>
974
- <html lang="en">
975
- <head>
976
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
977
- <title>Backlist QA Report v${VERSION} — ${id}</title>
978
- <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
979
- <style>
980
- *{box-sizing:border-box;margin:0;padding:0}
981
- body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a12;color:#e2e8f0;font-size:14px;line-height:1.6}
982
- header{background:#0f0f1e;border-bottom:1px solid #00f5ff22;padding:1.5rem 2rem;display:flex;align-items:center;justify-content:space-between}
983
- header h1{font-size:1.25rem;font-weight:600;color:#00f5ff}
984
- header .version{font-size:.75rem;color:#534AB7;padding:3px 10px;border:1px solid #534AB7;border-radius:20px}
985
- header p{color:#64748b;font-size:.85rem;margin-top:4px}
986
- .container{max-width:1200px;margin:0 auto;padding:2rem}
987
- .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.75rem;margin-bottom:1.5rem}
988
- .mc{background:#1e1e30;border:1px solid #2d2d4e;border-radius:10px;padding:1rem 1.25rem}
989
- .ml{font-size:.7rem;color:#64748b;text-transform:uppercase;letter-spacing:.05em}
990
- .mv{font-size:2rem;font-weight:700;margin-top:4px}
991
- .section{background:#1e1e30;border:1px solid #2d2d4e;border-radius:10px;padding:1.5rem;margin-bottom:1.25rem}
992
- .section-title{font-size:.95rem;font-weight:600;margin-bottom:1rem;color:#cbd5e1;border-bottom:1px solid #2d2d4e;padding-bottom:.75rem;display:flex;justify-content:space-between}
993
- .grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
994
- table{width:100%;border-collapse:collapse;font-size:.82rem}
995
- th{text-align:left;color:#64748b;font-weight:500;padding:.5rem .75rem;border-bottom:1px solid #2d2d4e}
996
- td{padding:.5rem .75rem;border-bottom:1px solid #1a1a2e;vertical-align:top}
997
- tr.fail td{background:rgba(239,68,68,.04)}tr.flaky td{background:rgba(245,158,11,.04)}
998
- .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.72rem;font-weight:600}
999
- .status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}
1000
- .status-skip{background:#1e293b;color:#94a3b8}.status-flaky{background:#422006;color:#fbbf24}
1001
- .sev-p0{background:#450a0a;color:#f87171;padding:2px 6px;border-radius:3px;font-size:.72rem;font-weight:700}
1002
- .sev-p1{background:#422006;color:#fbbf24;padding:2px 6px;border-radius:3px;font-size:.72rem;font-weight:700}
1003
- .sev-p2{background:#1e3a5f;color:#60a5fa;padding:2px 6px;border-radius:3px;font-size:.72rem}
1004
- .sev-p3{background:#1e293b;color:#94a3b8;padding:2px 6px;border-radius:3px;font-size:.72rem}
1005
- .err code{font-size:.72rem;color:#f87171;background:#1a0a0a;padding:2px 6px;border-radius:3px;word-break:break-all}
1006
- .bug-card{border-radius:8px;padding:1rem;margin-bottom:.75rem;border-left:3px solid}
1007
- .bug-p0{background:rgba(239,68,68,.08);border-color:#ef4444}
1008
- .bug-p1{background:rgba(245,158,11,.08);border-color:#f59e0b}
1009
- .bug-p2{background:rgba(96,165,250,.08);border-color:#60a5fa}
1010
- .bug-p3{background:rgba(148,163,184,.08);border-color:#64748b}
1011
- .bug-header{display:flex;gap:.75rem;align-items:center;margin-bottom:.5rem}
1012
- .bug-id{font-family:monospace;font-size:.75rem;color:#64748b}
1013
- .bug-sev{font-size:.72rem;font-weight:700;color:#f87171}
1014
- .bug-st{font-size:.72rem;padding:2px 8px;border-radius:4px;background:#1e293b;color:#94a3b8}
1015
- .bug-title{font-weight:600;margin-bottom:.25rem;font-size:.9rem}
1016
- .bug-desc{font-size:.8rem;color:#94a3b8}
1017
- .chart-wrap{position:relative;height:260px}
1018
- footer{text-align:center;color:#334155;font-size:.72rem;padding:2rem;border-top:1px solid #1e293b;margin-top:2rem}
1019
- </style>
1020
- </head>
1021
- <body>
1022
- <header>
1023
- <div>
1024
- <h1>Backlist QA Report</h1>
1025
- <p>Run ID: ${id} &nbsp;·&nbsp; ${new Date(startedAt).toLocaleString()} &nbsp;·&nbsp; ${formatDuration(duration)}</p>
1026
- </div>
1027
- <span class="version">v${VERSION}</span>
1028
- </header>
1029
- <div class="container">
1030
-
1031
- ${urlCards ? `<div class="section"><div class="section-title">Target URLs</div>${urlCards}</div>` : ''}
1032
-
1033
- <div class="metrics">
1034
- <div class="mc"><div class="ml">Pass Rate</div><div class="mv" style="color:${statusColor}">${passRate}%</div></div>
1035
- <div class="mc"><div class="ml">Total</div><div class="mv">${summary.total}</div></div>
1036
- <div class="mc"><div class="ml">Passed</div><div class="mv" style="color:#34d399">${summary.passed}</div></div>
1037
- <div class="mc"><div class="ml">Failed</div><div class="mv" style="color:#f87171">${summary.failed}</div></div>
1038
- <div class="mc"><div class="ml">Flaky</div><div class="mv" style="color:#fbbf24">${summary.flaky}</div></div>
1039
- <div class="mc"><div class="ml">Bugs</div><div class="mv" style="color:#c084fc">${bugReports.length}</div></div>
1040
- <div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:#f87171;font-size:1.6rem">${sevCounts.P0}</div></div>
1041
- <div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:#fbbf24;font-size:1.6rem">${sevCounts.P1}</div></div>
1042
- </div>
1043
-
1044
- <div class="grid2">
1045
- <div class="section">
1046
- <div class="section-title">Coverage by type</div>
1047
- ${covBars}
1048
- </div>
1049
- <div class="section">
1050
- <div class="section-title">Pass vs Fail</div>
1051
- <div class="chart-wrap"><canvas id="typeChart" role="img" aria-label="Pass vs fail by type"></canvas></div>
1052
- </div>
1053
- </div>
1054
-
1055
- <div class="section">
1056
- <div class="section-title">Route Scan <span style="font-weight:400;font-size:.8rem;color:#64748b">${routeScans.length} routes probed</span></div>
1057
- ${routeCards}
1058
- </div>
1059
-
1060
- <div class="section">
1061
- <div class="section-title">Test Results <span style="font-weight:400;font-size:.8rem;color:#64748b">${results.length} tests</span></div>
1062
- <table>
1063
- <thead><tr><th>Test</th><th>Type</th><th>Status</th><th>Sev</th><th>Duration</th><th>Retries</th><th>Error</th></tr></thead>
1064
- <tbody>${rows}</tbody>
1065
- </table>
1066
- </div>
1067
-
1068
- <div class="section">
1069
- <div class="section-title">Bug Reports <span style="font-weight:400;font-size:.8rem;color:#64748b">${bugReports.length} bugs</span></div>
1070
- ${bugCards}
1071
- </div>
1072
- </div>
1073
- <footer>Generated by create-backlist v${VERSION} — Backlist QA Platform &nbsp;·&nbsp; ${new Date().toLocaleString()}</footer>
1074
- <script>
1075
- new Chart(document.getElementById('typeChart'), {
1076
- type: 'bar',
1077
- data: {
1078
- labels: ${chartLabels},
1079
- datasets: [
1080
- { label: 'Passed', data: ${chartPassed}, backgroundColor: '#34d399', borderRadius: 4 },
1081
- { label: 'Failed', data: ${chartFailed}, backgroundColor: '#f87171', borderRadius: 4 },
1082
- ]
1083
- },
1084
- options: {
1085
- responsive: true, maintainAspectRatio: false,
1086
- plugins: { legend: { labels: { color: '#94a3b8', font: { size: 12 } } } },
1087
- scales: {
1088
- x: { ticks: { color: '#64748b', font: { size: 11 } }, grid: { color: '#1e293b' } },
1089
- y: { ticks: { color: '#64748b', stepSize: 1, font: { size: 11 } }, grid: { color: '#1e293b' } }
652
+ if (!result.pass) {
653
+ this.#session.addBug({
654
+ title : `Auth flow issue: ${url}`,
655
+ severity : 'P0',
656
+ type : 'auth',
657
+ description: result.message,
658
+ url,
659
+ evidence : result.details,
660
+ });
1090
661
  }
1091
662
  }
1092
- });
1093
- </script>
1094
- </body>
1095
- </html>`;
663
+
664
+ #isAuthPage(url) {
665
+ return /\/(login|signin|auth|register|signup)/i.test(url);
666
+ }
667
+
668
+ // ── Add real result ────────────────────────────────────────────────────
669
+ #addResult(result) {
670
+ const r = {
671
+ id : shortId(),
672
+ timestamp: timestamp(),
673
+ duration : result.duration || 0,
674
+ ...result,
675
+ };
676
+ this.#session.addResult(r);
677
+ this.#terminal.addResult(r);
678
+ this.emit('result', r);
679
+ return r;
680
+ }
1096
681
  }
1097
682
 
1098
- // ── History helpers ────────────────────────────────────────────────────────
683
+ // ─────────────────────────────────────────────────────────────────────────
684
+ // Public API — exported functions
685
+ // ─────────────────────────────────────────────────────────────────────────
686
+
1099
687
  export async function initQASystem() {
1100
688
  await fs.ensureDir(QA_DIR);
1101
689
  await fs.ensureDir(REPORT_DIR);
690
+ await fs.ensureDir(SCREENSHOT_DIR);
1102
691
  if (!await fs.pathExists(HISTORY_FILE)) {
1103
- await fs.writeJson(HISTORY_FILE, { runs: [] }, { spaces: 2 });
692
+ await fs.writeJson(HISTORY_FILE, { runs: [], version: VERSION }, { spaces: 2 });
1104
693
  }
1105
694
  }
1106
695
 
1107
- async function loadHistory() {
1108
- try { return await fs.readJson(HISTORY_FILE); }
1109
- catch { return { runs: [] }; }
1110
- }
1111
-
1112
- async function saveRun(run) {
1113
- const hist = await loadHistory();
1114
- hist.runs.unshift(run);
1115
- if (hist.runs.length > 50) hist.runs = hist.runs.slice(0, 50);
1116
- await fs.writeJson(HISTORY_FILE, hist, { spaces: 2 });
1117
- }
1118
-
1119
- async function exportReport(run) {
1120
- try {
1121
- const slug = run.id.toLowerCase();
1122
- const htmlPath = path.join(REPORT_DIR, `${slug}.html`);
1123
- const jsonPath = path.join(REPORT_DIR, `${slug}.json`);
1124
- await fs.writeFile(htmlPath, buildHTMLReport(run));
1125
- await fs.writeJson(jsonPath, run, { spaces: 2 });
1126
- return htmlPath;
1127
- } catch (err) {
1128
- console.error(chalk.gray(` [warn] Report write failed: ${err.message}`));
1129
- return null;
1130
- }
696
+ export async function saveSession(session) {
697
+ const history = await loadHistory();
698
+ const summary = session.getSummary();
699
+ history.runs.unshift({
700
+ id : session.id,
701
+ startedAt: session.startedAt,
702
+ urls : session.urls,
703
+ summary,
704
+ version : VERSION,
705
+ bugCount : session.bugs.length,
706
+ screenshotCount: session.screenshots.length,
707
+ });
708
+ if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
709
+ await fs.writeJson(HISTORY_FILE, history, { spaces: 2 });
1131
710
  }
1132
711
 
1133
- async function printRunDiff(currentRun) {
1134
- try {
1135
- const hist = await loadHistory();
1136
- const previous = hist.runs.find(r => r.id !== currentRun.id && r.type === currentRun.type);
1137
- if (!previous) return;
1138
- const prevRate = previous.summary.total ? (previous.summary.passed / previous.summary.total * 100).toFixed(0) : 0;
1139
- const currRate = currentRun.summary.total ? (currentRun.summary.passed / currentRun.summary.total * 100).toFixed(0) : 0;
1140
- const delta = Number(currRate) - Number(prevRate);
1141
- if (delta === 0) return;
1142
- const arrow = delta > 0 ? chalk.green(`↑ +${delta}%`) : chalk.red(`↓ ${delta}%`);
1143
- console.log(chalk.gray(` vs previous run (${previous.id}): ${arrow} pass rate`));
1144
- } catch {}
712
+ export async function loadHistory() {
713
+ try { return await fs.readJson(HISTORY_FILE); }
714
+ catch { return { runs: [], version: VERSION }; }
1145
715
  }
1146
716
 
1147
- // ── Print summary ──────────────────────────────────────────────────────────
1148
- function printResultsSummary(results) {
1149
- const passed = results.filter(r => ['PASS','FLAKY'].includes(r.status)).length;
1150
- const failed = results.filter(r => r.status === 'FAIL').length;
1151
- const passRate = results.length ? Math.round((passed / results.length) * 100) : 0;
717
+ // ── URL QA entry point ────────────────────────────────────────────────────
718
+ export async function runUrlQA({ localUrl, stagingUrl, prodUrl, options = {} } = {}) {
719
+ const urls = {};
720
+ if (localUrl) urls.localhost = localUrl;
721
+ if (stagingUrl) urls.staging = stagingUrl;
722
+ if (prodUrl) urls.production = prodUrl;
1152
723
 
1153
- console.log('');
1154
- console.log(chalk.hex('#00F5FF').bold(' ── Scan Results ──────────────────────────────────────'));
1155
- console.log(` Pass rate: [${buildProgressBar(passRate, 24)}] ${chalk.white.bold(passRate + '%')}`);
1156
- console.log(` ${chalk.green('✓')} ${passed} passed ${chalk.red('✗')} ${failed} failed (${results.length} total)`);
1157
- if (failed > 0) {
1158
- console.log('');
1159
- console.log(chalk.red.bold(' Failures:'));
1160
- results.filter(r => r.status === 'FAIL').forEach(f => {
1161
- const sev = f.sev ? ` [${f.sev}]` : '';
1162
- console.log(chalk.red(` ✗${sev} ${f.name}`));
1163
- if (f.error) console.log(chalk.gray(` → ${f.error}`));
1164
- });
1165
- }
1166
- console.log('');
1167
- }
1168
-
1169
- // ─────────────────────────────────────────────────────────────────────────
1170
- // Manual QA Flow (v9 retained + v10 URL option)
1171
- // ─────────────────────────────────────────────────────────────────────────
1172
-
1173
- export async function runManualQA() {
1174
- const runId = `MQA-${shortId()}`;
1175
- const startedAt = timestamp();
1176
- const runner = new TestRunner();
1177
- const bugs = [];
1178
- const manualResults = [];
1179
-
1180
- console.log('');
1181
- const action = await p.select({
1182
- message: 'Manual QA — what would you like to do?',
1183
- options: [
1184
- { value: 'url-scan', label: '🌐 URL-Based Scan', hint: 'Enter URL(s) and run HTTP probe tests' },
1185
- { value: 'new-test', label: '✏️ Create & run a custom test' },
1186
- { value: 'full-scan', label: '🔬 Full system scan', hint: 'File-system + UI tests' },
1187
- { value: 'log-bug', label: '🐛 Log a bug report' },
1188
- { value: 'security-scan',label: '🛡️ Security scan only' },
1189
- { value: 'ui-tests', label: '🖥️ UI/Frontend tests' },
1190
- ],
1191
- });
1192
- if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
1193
-
1194
- const dashboard = new LiveDashboard();
1195
-
1196
- if (action === 'url-scan') {
1197
- const localUrl = await p.text({ message: 'Localhost URL (leave blank to skip):', placeholder: 'http://localhost:3000' });
1198
- const prodUrl = await p.text({ message: 'Production URL (leave blank to skip):', placeholder: 'https://yoursite.com' });
1199
- if (p.isCancel(localUrl) || p.isCancel(prodUrl)) { p.cancel('Cancelled.'); return; }
1200
- const run = await runUrlQA({
1201
- localUrl : String(localUrl).trim() || undefined,
1202
- prodUrl : String(prodUrl).trim() || undefined,
1203
- });
1204
- if (run) manualResults.push(...run.results);
1205
- } else if (action === 'log-bug') {
1206
- await logBugInteractive(bugs);
1207
- } else if (action === 'new-test') {
1208
- await createAndRunTestInteractive(runner, manualResults, dashboard);
1209
- } else if (action === 'full-scan') {
1210
- dashboard.start();
1211
- const results = await runner.run([...buildFullSystemTests(), ...buildUITests()], dashboard);
1212
- manualResults.push(...results);
1213
- dashboard.stop();
1214
- printResultsSummary(results);
1215
- } else if (action === 'ui-tests') {
1216
- dashboard.start();
1217
- const results = await runner.run(buildUITests(), dashboard);
1218
- manualResults.push(...results);
1219
- dashboard.stop();
1220
- printResultsSummary(results);
1221
- } else if (action === 'security-scan') {
1222
- dashboard.start();
1223
- const results = await runner.run(buildFullSystemTests().filter(t => t.type === 'security' || t.type === 'auth'), dashboard);
1224
- manualResults.push(...results);
1225
- dashboard.stop();
1226
- printResultsSummary(results);
724
+ if (Object.keys(urls).length === 0) {
725
+ console.log(chalk.red(' No URLs provided.'));
726
+ return null;
1227
727
  }
1228
728
 
1229
- const continueLoop = await p.confirm({ message: 'Run another action?' });
1230
- if (!p.isCancel(continueLoop) && continueLoop) return runManualQA();
729
+ const session = new QASession(urls);
730
+ const engine = new QAEngine(session, options);
1231
731
 
1232
- const duration = Date.now() - new Date(startedAt).getTime();
1233
- const summary = buildSummary(manualResults);
1234
- const coverage = buildCoverageMatrix(manualResults);
1235
- const run = { id: runId, type: 'manual', version: VERSION, startedAt, duration, results: manualResults, bugReports: bugs, summary, coverage };
1236
- await saveRun(run);
1237
- const reportFile = await exportReport(run);
732
+ await engine.init();
733
+ await engine.run();
734
+ await saveSession(session);
1238
735
 
1239
- p.outro(chalk.hex('#00F5FF').bold(`✓ Session saved ${pluralize(manualResults.length, 'test')}, ${pluralize(bugs.length, 'bug')}`));
1240
- if (reportFile) console.log(chalk.gray(` 📄 Report: ${reportFile}`));
1241
- }
736
+ const htmlReporter = new HTMLReporter(session);
737
+ const jsonReporter = new JSONReporter(session);
1242
738
 
1243
- async function logBugInteractive(bugs) {
1244
- const title = await p.text({ message: 'Bug title:' });
1245
- if (p.isCancel(title)) return;
1246
- const severity = await p.select({ message: 'Severity:',
1247
- options: Object.entries(SEVERITY_LEVELS).map(([k, v]) => ({ value: k, label: `${k} — ${v}` })) });
1248
- if (p.isCancel(severity)) return;
1249
- const description = await p.text({ message: 'Description (optional):', placeholder: 'Steps to reproduce…' });
1250
- bugs.push({ id: `BUG-${shortId()}`, title: String(title), severity: String(severity), status: 'OPEN',
1251
- description: p.isCancel(description) ? '' : description, createdAt: timestamp() });
1252
- console.log(chalk.green(` ✓ Bug logged as ${colorSeverity(String(severity))}`));
1253
- }
739
+ const htmlPath = await htmlReporter.generate(REPORT_DIR);
740
+ const jsonPath = await jsonReporter.generate(REPORT_DIR);
1254
741
 
1255
- async function createAndRunTestInteractive(runner, results, dashboard) {
1256
- const name = await p.text({ message: 'Test name:' });
1257
- if (p.isCancel(name)) return;
1258
- const type = await p.select({ message: 'Test type:', options: TEST_TYPES.map(t => ({ value: t, label: t })) });
1259
- if (p.isCancel(type)) return;
1260
- const expectPass = await p.confirm({ message: 'Should this test pass?' });
1261
-
1262
- dashboard.start();
1263
- const [result] = await runner.run([{
1264
- id: shortId(), name: String(name), type: String(type), sev: 'P3',
1265
- fn: async () => {
1266
- await sleep(400 + Math.random() * 300);
1267
- if (!expectPass) throw new Error('Test manually marked as failure');
1268
- },
1269
- }], dashboard);
1270
- results.push(result);
1271
- dashboard.stop();
1272
- console.log(` ${colorStatus(result.status)} ${result.name} ${chalk.gray(formatDuration(result.duration))}`);
742
+ return { session, htmlPath, jsonPath };
1273
743
  }
1274
744
 
1275
- // ─────────────────────────────────────────────────────────────────────────
1276
- // Automated QA Flow (v9 retained + v10 URL integration)
1277
- // ─────────────────────────────────────────────────────────────────────────
1278
-
1279
- export async function runAutomatedQA({ continuous = false, localUrl, prodUrl } = {}) {
745
+ // ── Automated QA entry point ──────────────────────────────────────────────
746
+ export async function runAutomatedQA({ continuous = false, localUrl, prodUrl, stagingUrl } = {}) {
1280
747
  const runOnce = async () => {
1281
- const runId = `AQA-${shortId()}`;
1282
- const startedAt = timestamp();
748
+ const urls = {};
749
+ if (localUrl) urls.localhost = localUrl;
750
+ if (stagingUrl) urls.staging = stagingUrl;
751
+ if (prodUrl) urls.production = prodUrl;
1283
752
 
1284
- console.log('');
1285
- console.log(chalk.hex('#BF40FF').bold(` ── 🤖 Automated QA v${VERSION} — Run ${runId} ──`));
1286
- console.log('');
1287
-
1288
- let endpoints = [];
1289
- try {
1290
- const { analyzeFrontend } = await import('../analyzer.js');
1291
- endpoints = await analyzeFrontend(path.join(process.cwd(), 'src'));
1292
- } catch {}
753
+ if (Object.keys(urls).length === 0) {
754
+ console.log(chalk.yellow(' No URLs configured. Skipping URL-based tests.'));
755
+ }
1293
756
 
1294
- const allTests = [
1295
- ...buildFullSystemTests(),
1296
- ...buildEndpointTests(endpoints),
1297
- ...buildUITests(),
1298
- ];
757
+ const session = new QASession(urls);
758
+ const engine = new QAEngine(session);
1299
759
 
1300
- console.log(chalk.gray(` Test suite: ${allTests.length} tests across ${new Set(allTests.map(t => t.type)).size} categories\n`));
760
+ await engine.init();
761
+ await engine.run();
762
+ await saveSession(session);
1301
763
 
1302
- const dashboard = new LiveDashboard();
1303
- const runner = new TestRunner();
1304
- const autoBugs = [];
764
+ const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
765
+ const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
1305
766
 
1306
- runner.on('result', r => {
1307
- if (r.status === 'FAIL') {
1308
- autoBugs.push({
1309
- id: `AUTO-${shortId()}`, title: `Automated: ${r.name}`,
1310
- severity : r.sev || (r.type === 'security' || r.type === 'auth' ? 'P0' : r.type === 'e2e' ? 'P1' : 'P2'),
1311
- status : 'OPEN', description: r.error || '', createdAt: timestamp(),
1312
- });
1313
- }
1314
- });
767
+ const summary = session.getSummary();
768
+ console.log(chalk.hex('#00F5FF').bold(
769
+ `\n ✓ Run ${session.id} — ${summary.total} tests · ${summary.failed} failed · ` +
770
+ `${session.bugs.length} bugs · ${formatDuration(summary.duration)}`
771
+ ));
772
+ if (htmlPath) console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
773
+ return session;
774
+ };
1315
775
 
1316
- dashboard.start();
1317
- const results = await runner.run(allTests, dashboard);
1318
- dashboard.stop();
776
+ if (!continuous) return runOnce();
1319
777
 
1320
- // If URLs provided, run URL-based QA too
1321
- if (localUrl || prodUrl) {
1322
- const urlRun = await runUrlQA({ localUrl, prodUrl, silent: true });
1323
- if (urlRun) { results.push(...urlRun.results); autoBugs.push(...urlRun.bugReports); }
1324
- }
778
+ console.log(chalk.cyan(' ⚡ Continuous mode re-runs every 60s. Ctrl+C to stop.\n'));
779
+ let i = 0;
780
+ while (true) {
781
+ console.log(chalk.gray(`\n ── Run #${++i} ── ${new Date().toLocaleTimeString()}`));
782
+ await runOnce();
783
+ await sleep(60_000);
784
+ }
785
+ }
1325
786
 
1326
- const duration = Date.now() - new Date(startedAt).getTime();
1327
- const summary = buildSummary(results);
1328
- const coverage = buildCoverageMatrix(results);
787
+ // ── Manual QA ─────────────────────────────────────────────────────────────
788
+ export async function runManualQA() {
789
+ console.log('');
1329
790
 
1330
- printResultsSummary(results);
791
+ const action = await p.select({
792
+ message: 'Manual QA — what to run?',
793
+ options: [
794
+ { value: 'full-url', label: '🌐 Full URL-Based Real Scan', hint: 'Browser + API + Security + Perf + SEO + A11y' },
795
+ { value: 'security', label: '🛡️ Security Only', hint: 'Real HTTP security header + vuln scan' },
796
+ { value: 'perf', label: '⚡ Performance Only', hint: 'Real Core Web Vitals measurement' },
797
+ { value: 'a11y', label: '♿ Accessibility Only', hint: 'Real axe-core WCAG scan' },
798
+ { value: 'seo', label: '🔎 SEO Only', hint: 'Real meta, og, robots, sitemap scan' },
799
+ { value: 'api', label: '📡 API Only', hint: 'Real endpoint probe + contract validation' },
800
+ ],
801
+ });
802
+ if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
1331
803
 
1332
- const run = { id: runId, type: 'automated', version: VERSION, startedAt, duration,
1333
- results, bugReports: autoBugs, summary, coverage,
1334
- urls: [localUrl, prodUrl].filter(Boolean).map((u, i) => ({ label: i === 0 ? 'localhost' : 'production', url: u })) };
1335
- await saveRun(run);
1336
- const reportFile = await exportReport(run);
804
+ const localUrl = await p.text({
805
+ message : 'Localhost URL:',
806
+ placeholder: 'http://localhost:3000',
807
+ });
808
+ if (p.isCancel(localUrl)) { p.cancel('Cancelled.'); return; }
1337
809
 
1338
- if (reportFile) console.log(chalk.gray(` 📄 Report: ${reportFile}`));
1339
- await printRunDiff(run);
810
+ const prodUrl = await p.text({
811
+ message : 'Production URL (blank to skip):',
812
+ placeholder: 'https://yoursite.com',
813
+ });
1340
814
 
1341
- p.outro(chalk.hex('#00F5FF').bold(`Run ${runId} complete — ${formatDuration(duration)}`));
1342
- return run;
815
+ const urls = {
816
+ localhost : String(localUrl).trim() || undefined,
817
+ production: !p.isCancel(prodUrl) ? String(prodUrl).trim() || undefined : undefined,
1343
818
  };
1344
819
 
1345
- if (!continuous) { await runOnce(); return; }
820
+ const session = new QASession(urls);
821
+ const engine = new QAEngine(session);
822
+ await engine.init();
1346
823
 
1347
- console.log(chalk.cyan(` ⚡ Continuous mode reruns every ${WATCH_INTERVAL_MS / 1000}s. Ctrl+C to stop.\n`));
1348
- let iteration = 0;
1349
- while (true) {
1350
- iteration++;
1351
- console.log(chalk.gray(`\n ── Iteration ${iteration} ── ${new Date().toLocaleTimeString()}`));
1352
- await runOnce();
1353
- await sleep(WATCH_INTERVAL_MS);
824
+ // Only run selected phases
825
+ await engine.runPhase(action);
826
+
827
+ await saveSession(session);
828
+ const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
829
+ if (htmlPath) {
830
+ p.outro(chalk.hex('#00F5FF').bold('✓ QA complete'));
831
+ console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
1354
832
  }
1355
833
  }
1356
834
 
1357
- // ── Post-gen auto-run ──────────────────────────────────────────────────────
835
+ // ── Post-generation validation ────────────────────────────────────────────
1358
836
  export async function autoRunPostGeneration(options = {}) {
1359
837
  console.log('');
1360
- console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation QA Scan v${VERSION} ──────────────`));
1361
- console.log(chalk.gray(` Validating: ${options.projectName || 'backend'}`));
838
+ console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation Real QA v${VERSION} ──`));
839
+ console.log(chalk.gray(' Note: Start your server first, then provide its URL'));
1362
840
  console.log('');
1363
841
 
1364
- const projectDir = options.projectDir || process.cwd();
1365
- const tests = buildFullSystemTests(projectDir);
1366
- const runner = new TestRunner();
1367
- const dashboard = new LiveDashboard();
1368
- const autoBugs = [];
1369
-
1370
- runner.on('result', r => {
1371
- if (r.status === 'FAIL') {
1372
- autoBugs.push({ id: `POST-${shortId()}`, title: r.name,
1373
- severity: r.sev || (r.type === 'security' ? 'P0' : 'P2'),
1374
- status: 'OPEN', description: r.error || '', createdAt: timestamp() });
1375
- }
842
+ const url = await p.text({
843
+ message : 'Server URL to validate:',
844
+ placeholder: 'http://localhost:3000',
845
+ defaultValue: 'http://localhost:3000',
1376
846
  });
847
+ if (p.isCancel(url)) { p.cancel('Cancelled.'); return; }
1377
848
 
1378
- dashboard.start();
1379
- const results = await runner.run(tests, dashboard);
1380
- dashboard.stop();
1381
-
1382
- const summary = buildSummary(results);
1383
- const coverage = buildCoverageMatrix(results);
1384
- const run = { id: `POST-${shortId()}`, type: 'post-generation', version: VERSION,
1385
- startedAt: timestamp(), duration: 0, results, bugReports: autoBugs, summary, coverage };
1386
-
1387
- await saveRun(run);
1388
- const reportFile = await exportReport(run);
1389
- printResultsSummary(results);
1390
-
1391
- if (autoBugs.length > 0) {
1392
- console.log(chalk.red.bold(` ⚠ ${autoBugs.length} issue(s) detected:`));
1393
- autoBugs.forEach(b => console.log(chalk.red(` ${colorSeverity(b.severity)} ${b.title}`)));
1394
- console.log('');
849
+ const result = await runUrlQA({ localUrl: String(url).trim() });
850
+ if (result?.htmlPath) {
851
+ console.log(chalk.gray(` 📄 Report: ${result.htmlPath}`));
1395
852
  }
1396
- if (reportFile) console.log(chalk.gray(` 📄 Post-gen report: ${reportFile}`));
1397
853
  }
1398
854
 
1399
- // ── QA History ─────────────────────────────────────────────────────────────
855
+ // ── View History ──────────────────────────────────────────────────────────
1400
856
  export async function viewQAHistory() {
1401
- const hist = await loadHistory();
1402
- if (!hist.runs.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
857
+ const history = await loadHistory();
858
+ if (!history.runs?.length) {
859
+ console.log(chalk.yellow('\n No QA history found.\n'));
860
+ return;
861
+ }
1403
862
 
1404
863
  console.log('');
1405
- console.log(chalk.hex('#00F5FF').bold(' QA History (most recent first)'));
1406
- console.log(chalk.gray(' ─────────────────────────────────────────────────────'));
864
+ console.log(chalk.hex('#00F5FF').bold(' QA History (real runs only)'));
865
+ console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
866
+
867
+ for (const run of history.runs.slice(0, 15)) {
868
+ const rate = run.summary?.passRate ?? '–';
869
+ const color = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
870
+ const bugs = run.bugCount ?? 0;
871
+ const shots = run.screenshotCount ?? 0;
872
+ const urlStr = Object.values(run.urls || {}).filter(Boolean).join(', ');
1407
873
 
1408
- for (const run of hist.runs.slice(0, 10)) {
1409
- const passRate = run.summary.total ? ((run.summary.passed / run.summary.total) * 100).toFixed(0) : '–';
1410
- const rateColor = Number(passRate) >= 90 ? chalk.green : Number(passRate) >= 70 ? chalk.yellow : chalk.red;
1411
- const ver = run.version ? chalk.dim(`v${run.version}`) : '';
1412
874
  console.log(
1413
- ` ${chalk.gray(run.id.padEnd(18))} ${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(24))}` +
1414
- ` ${rateColor(`${passRate}%`.padStart(5))} ${chalk.gray(`${run.summary.total} tests`)} ${ver}`
875
+ ` ${chalk.gray(run.id.padEnd(14))} ` +
876
+ `${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(24))} ` +
877
+ `${color(String(rate + '%').padStart(6))} ` +
878
+ `${chalk.gray(String(run.summary?.total || 0) + ' tests')} ` +
879
+ `${chalk.cyan(bugs + ' bugs')} ` +
880
+ `${chalk.gray(shots + ' shots')} ` +
881
+ `${chalk.dim(urlStr.slice(0, 40))}`
1415
882
  );
1416
883
  }
1417
884
  console.log('');
1418
885
 
1419
886
  const chosen = await p.select({
1420
- message: 'View a run in detail?',
887
+ message: 'Open a report?',
1421
888
  options: [
1422
- ...hist.runs.slice(0, 5).map(r => ({ value: r.id, label: `${r.id} — ${new Date(r.startedAt).toLocaleString()}` })),
889
+ ...history.runs.slice(0, 8).map(r => ({
890
+ value: r.id,
891
+ label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
892
+ })),
1423
893
  { value: '__back', label: '↩ Back' },
1424
894
  ],
1425
895
  });
1426
896
  if (p.isCancel(chosen) || chosen === '__back') return;
1427
897
 
1428
- const run = hist.runs.find(r => r.id === chosen);
1429
- if (!run) return;
1430
-
1431
- console.log('');
1432
- console.log(chalk.bold(` Run: ${run.id} (${run.type}) ${run.version ? `v${run.version}` : ''}`));
1433
- console.log(chalk.gray(` ${new Date(run.startedAt).toLocaleString()} · ${formatDuration(run.duration)}`));
1434
- if (run.urls?.length) {
1435
- console.log(chalk.gray(` URLs: ${run.urls.map(u => u.url).join(', ')}`));
1436
- }
1437
- console.log('');
1438
- for (const r of run.results) {
1439
- console.log(` ${colorStatus(r.status)} ${r.name} ${chalk.gray(formatDuration(r.duration))}`);
1440
- if (r.error) console.log(chalk.red(` ↳ ${r.error}`));
1441
- }
1442
- if (run.bugReports?.length) {
1443
- console.log('');
1444
- console.log(chalk.bold(' Bug Reports:'));
1445
- for (const b of run.bugReports) {
1446
- console.log(` ${colorSeverity(b.severity)} ${b.title} ${chalk.gray(`[${b.status}]`)}`);
1447
- }
898
+ const reportPath = path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
899
+ if (await fs.pathExists(reportPath)) {
900
+ console.log(chalk.green(` 📄 Report: ${reportPath}`));
901
+ // Open in browser if possible
902
+ try {
903
+ const { exec } = await import('node:child_process');
904
+ const cmd = process.platform === 'darwin' ? 'open'
905
+ : process.platform === 'win32' ? 'start'
906
+ : 'xdg-open';
907
+ exec(`${cmd} "${reportPath}"`);
908
+ } catch {}
909
+ } else {
910
+ console.log(chalk.yellow(' Report file not found — may have been deleted.'));
1448
911
  }
1449
- console.log('');
1450
912
  }