create-backlist 10.0.5 → 10.0.7

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/qa/qa-engine.js +221 -204
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-backlist",
3
- "version": "10.0.5",
3
+ "version": "10.0.7",
4
4
  "description": "An advanced, multi-language backend generator based on frontend analysis. Smart Freemium SaaS CLI with Live QA.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import os from 'node:os';
14
14
  import { performance } from 'node:perf_hooks';
15
15
  import { EventEmitter } from 'node:events';
16
+ import readline from 'node:readline';
16
17
 
17
18
  import { SmartCrawler } from './browser/crawler.js';
18
19
  import { BrowserInteractor } from './browser/interactions.js';
@@ -28,43 +29,64 @@ import { JSONReporter } from './reporters/json.js';
28
29
  import { AIClassifier } from './utils/ai-classifier.js';
29
30
 
30
31
  // ── 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');
32
+ export const VERSION = '12.0.0';
33
+ export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
34
+ export const REPORT_DIR = path.join(QA_DIR, 'reports');
35
+ export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
35
36
  export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
36
37
 
37
38
  // ── Utilities ─────────────────────────────────────────────────────────────
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)); }
39
+ export function timestamp() { return new Date().toISOString(); }
40
+ export function shortId() { return Math.random().toString(36).slice(2, 9); }
41
+ export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
41
42
  export function formatDuration(ms) {
42
43
  if (ms < 1000) return `${Math.round(ms)}ms`;
43
44
  return `${(ms / 1000).toFixed(2)}s`;
44
45
  }
45
46
  export function formatBytes(b) {
46
- if (!b || b < 0) return '0B';
47
- if (b < 1024) return `${b}B`;
48
- if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
47
+ if (!b || b < 0) return '0B';
48
+ if (b < 1024) return `${b}B`;
49
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
49
50
  return `${(b / 1024 / 1024).toFixed(1)}MB`;
50
51
  }
51
52
 
53
+ // ── Ask yes/no in terminal without async-inside-Promise issue ─────────────
54
+ function askQuestion(question) {
55
+ return new Promise((resolve) => {
56
+ const rl = readline.createInterface({
57
+ input : process.stdin,
58
+ output: process.stdout,
59
+ });
60
+ // Auto-resolve after 10s if no input
61
+ const timer = setTimeout(() => {
62
+ rl.close();
63
+ resolve(false);
64
+ }, 10_000);
65
+
66
+ rl.question(question, (answer) => {
67
+ clearTimeout(timer);
68
+ rl.close();
69
+ resolve(answer.toLowerCase().trim() === 'y');
70
+ });
71
+ });
72
+ }
73
+
52
74
  // ── QA Session ────────────────────────────────────────────────────────────
53
75
  export class QASession {
54
76
  id;
55
77
  startedAt;
56
- urls = {};
57
- results = []; // real test results only
58
- bugs = []; // real detected bugs only
59
- screenshots = []; // real screenshots
78
+ urls = {};
79
+ results = [];
80
+ bugs = [];
81
+ screenshots = [];
60
82
  consoleErrors = [];
61
- networkLog = [];
62
- apiLog = [];
63
- routeMap = [];
64
- perfMetrics = {};
65
- secFindings = [];
66
- a11yResults = [];
67
- seoResults = [];
83
+ networkLog = [];
84
+ apiLog = [];
85
+ routeMap = [];
86
+ perfMetrics = {};
87
+ secFindings = [];
88
+ a11yResults = [];
89
+ seoResults = [];
68
90
 
69
91
  constructor(urls) {
70
92
  this.id = `QA-${shortId()}`;
@@ -72,9 +94,7 @@ export class QASession {
72
94
  this.urls = urls;
73
95
  }
74
96
 
75
- addResult(result) {
76
- this.results.push(result);
77
- }
97
+ addResult(result) { this.results.push(result); }
78
98
 
79
99
  addBug(bug) {
80
100
  this.bugs.push({ ...bug, id: `BUG-${shortId()}`, createdAt: timestamp() });
@@ -111,110 +131,91 @@ export class QAEngine extends EventEmitter {
111
131
 
112
132
  constructor(session, options = {}) {
113
133
  super();
114
- this.#session = session;
115
- this.#terminal = new TerminalDashboard(session);
134
+ this.#session = session;
135
+ this.#terminal = new TerminalDashboard(session);
116
136
  this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
117
137
  this.#aiClassifier = new AIClassifier();
118
138
  }
119
139
 
120
- // Replace the init() method in QAEngine class
121
-
122
- async init() {
123
- // Dynamic import Playwright — optional
124
- let playwright = null;
125
- try {
126
- playwright = await import('playwright');
127
- } catch {
128
- // Will use HTTP fallback throughout
129
- }
130
-
131
- // Always import installer — handles all browser detection
132
- const { getBrowserLaunchOptions, ensureBrowser } = await import('./browser/installer.js');
133
-
134
- // Check browser availability once — share result
135
- const launchOpts = await getBrowserLaunchOptions();
136
-
137
- if (!launchOpts.available) {
138
- console.log(chalk.yellow('\n ⚠ Playwright browser not found.'));
139
- console.log(chalk.gray(' The QA engine will run in HTTP-only mode.'));
140
- console.log(chalk.gray(' Browser-based tests (JS errors, screenshots, a11y'));
141
- console.log(chalk.gray(' via axe-core, real Web Vitals) will be skipped.\n'));
142
- console.log(chalk.dim(' To enable full browser testing:'));
143
- console.log(chalk.white(' npx playwright install chromium\n'));
144
-
145
- // Offer to install now
146
- const shouldInstall = await new Promise(resolve => {
147
- const rl = (await import('node:readline')).createInterface({
148
- input : process.stdin,
149
- output: process.stdout,
150
- });
151
- rl.question(chalk.cyan(' Install Playwright browser now? (y/N): '), ans => {
152
- rl.close();
153
- resolve(ans.toLowerCase() === 'y');
154
- });
155
- // Auto-timeout after 10s
156
- setTimeout(() => { rl.close(); resolve(false); }, 10_000);
157
- });
140
+ // ── FIX: init() no await inside non-async callbacks ─────────────────
141
+ async init() {
142
+ // Dynamic import Playwright — optional dependency
143
+ let playwright = null;
144
+ try {
145
+ playwright = await import('playwright');
146
+ } catch {
147
+ // Will use HTTP fallback throughout — playwright is optional
148
+ }
158
149
 
159
- if (shouldInstall) {
160
- const { installPlaywrightBrowsers } = await import('./browser/installer.js');
161
- const result = await installPlaywrightBrowsers();
162
- if (!result.success) {
163
- console.log(chalk.yellow(' Auto-install failed. Continuing in HTTP-only mode.\n'));
150
+ // Resolve browser launch options (handles all detection logic)
151
+ const { getBrowserLaunchOptions, installPlaywrightBrowsers } = await import('./browser/installer.js');
152
+ const launchOpts = await getBrowserLaunchOptions();
153
+
154
+ if (!launchOpts.available) {
155
+ console.log(chalk.yellow('\n ⚠ Playwright browser not found.'));
156
+ console.log(chalk.gray(' The QA engine will run in HTTP-only mode.'));
157
+ console.log(chalk.gray(' Browser-based tests (JS errors, screenshots, real Web Vitals)'));
158
+ console.log(chalk.gray(' will be skipped. All HTTP-based tests will still run.\n'));
159
+ console.log(chalk.dim(' To enable full browser testing:'));
160
+ console.log(chalk.white(' npx playwright install chromium\n'));
161
+
162
+ // ── FIX: use the extracted askQuestion() helper — no await in Promise ──
163
+ const shouldInstall = await askQuestion(
164
+ chalk.cyan(' Install Playwright browser now? (y/N): ')
165
+ );
166
+
167
+ if (shouldInstall) {
168
+ const result = await installPlaywrightBrowsers();
169
+ if (!result.success) {
170
+ console.log(chalk.yellow(' Auto-install failed. Continuing in HTTP-only mode.\n'));
171
+ }
164
172
  }
173
+ } else {
174
+ const exeName = launchOpts.executablePath?.split(/[/\\]/).pop() ?? 'chromium';
175
+ console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${exeName})`));
165
176
  }
166
- } else {
167
- console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${launchOpts.executablePath?.split(/[/\\]/).pop()})`));
168
- }
169
177
 
170
- this.#crawler = new SmartCrawler(playwright);
171
- this.#interactor = new BrowserInteractor(playwright, this.#session);
172
- this.#apiValidator = new RealAPIValidator(this.#session);
173
- this.#security = new SecurityScanner(this.#session);
174
- this.#performance = new PerformanceProfiler(this.#session);
175
- this.#a11y = new AccessibilityChecker(playwright, this.#session);
176
- this.#seo = new SEOScanner(this.#session);
177
- this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
178
- this.#aiClassifier = new AIClassifier();
179
-
180
- await this.#interactor.launch();
181
- await this.#screenshotter.init();
182
- }
178
+ // Initialise all subsystems
179
+ this.#crawler = new SmartCrawler(playwright);
180
+ this.#interactor = new BrowserInteractor(playwright, this.#session);
181
+ this.#apiValidator = new RealAPIValidator(this.#session);
182
+ this.#security = new SecurityScanner(this.#session);
183
+ this.#performance = new PerformanceProfiler(this.#session);
184
+ this.#a11y = new AccessibilityChecker(playwright, this.#session);
185
+ this.#seo = new SEOScanner(this.#session);
186
+ this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
187
+ this.#aiClassifier = new AIClassifier();
188
+
189
+ await this.#interactor.launch();
190
+ await this.#screenshotter.init();
191
+ }
183
192
 
184
193
  async run() {
185
194
  this.#terminal.start();
186
195
  this.emit('session:start', this.#session);
187
196
 
188
197
  try {
189
- // Phase 1 — Discovery
190
198
  this.#terminal.setPhase('🔍 Phase 1: Route Discovery & Crawling');
191
199
  await this.#phaseDiscovery();
192
200
 
193
- // Phase 2 — API Validation
194
201
  this.#terminal.setPhase('📡 Phase 2: Real API Validation');
195
202
  await this.#phaseAPIValidation();
196
203
 
197
- // Phase 3 — Browser Interactions
198
204
  this.#terminal.setPhase('🖱️ Phase 3: Browser Interaction Testing');
199
205
  await this.#phaseBrowserInteractions();
200
206
 
201
- // Phase 4 — Security Scan
202
207
  this.#terminal.setPhase('🛡️ Phase 4: Security Deep Scan');
203
208
  await this.#phaseSecurityScan();
204
209
 
205
- // Phase 5 — Performance
206
210
  this.#terminal.setPhase('⚡ Phase 5: Performance Profiling');
207
211
  await this.#phasePerformance();
208
212
 
209
- // Phase 6 — Accessibility
210
213
  this.#terminal.setPhase('♿ Phase 6: Accessibility Testing');
211
214
  await this.#phaseAccessibility();
212
215
 
213
- // Phase 7 — SEO
214
216
  this.#terminal.setPhase('🔎 Phase 7: SEO Validation');
215
217
  await this.#phaseSEO();
216
218
 
217
- // Phase 8 — AI Bug Classification
218
219
  this.#terminal.setPhase('🤖 Phase 8: AI Bug Classification');
219
220
  await this.#phaseAIClassification();
220
221
 
@@ -229,6 +230,36 @@ async init() {
229
230
  return this.#session;
230
231
  }
231
232
 
233
+ // Run a single named phase (used by manual QA)
234
+ async runPhase(name) {
235
+ this.#terminal.start();
236
+ try {
237
+ switch (name) {
238
+ case 'full-url':
239
+ await this.#phaseDiscovery();
240
+ await this.#phaseAPIValidation();
241
+ await this.#phaseBrowserInteractions();
242
+ await this.#phaseSecurityScan();
243
+ await this.#phasePerformance();
244
+ await this.#phaseAccessibility();
245
+ await this.#phaseSEO();
246
+ await this.#phaseAIClassification();
247
+ break;
248
+ case 'security': await this.#phaseSecurityScan(); break;
249
+ case 'perf': await this.#phasePerformance(); break;
250
+ case 'a11y': await this.#phaseAccessibility(); break;
251
+ case 'seo': await this.#phaseSEO(); break;
252
+ case 'api': await this.#phaseAPIValidation(); break;
253
+ default:
254
+ await this.run();
255
+ }
256
+ } finally {
257
+ this.#terminal.stop();
258
+ await this.#interactor.close().catch(() => {});
259
+ }
260
+ return this.#session;
261
+ }
262
+
232
263
  abort() {
233
264
  this.#aborted = true;
234
265
  this.#terminal.stop();
@@ -242,9 +273,9 @@ async init() {
242
273
  this.#terminal.log(`Crawling ${label}: ${url}`);
243
274
 
244
275
  const routes = await this.#crawler.crawl(url, {
245
- maxPages : 60,
246
- maxDepth : 4,
247
- onRoute : (route) => {
276
+ maxPages: 60,
277
+ maxDepth: 4,
278
+ onRoute : (route) => {
248
279
  this.#session.routeMap.push(route);
249
280
  this.#terminal.log(` Found: ${route.url} (${route.type})`);
250
281
  },
@@ -258,17 +289,16 @@ async init() {
258
289
  message : routes.length > 0
259
290
  ? `Discovered ${routes.length} routes`
260
291
  : 'No routes discovered — site may be unreachable',
261
- data : { routeCount: routes.length, routes },
262
- url,
263
- label,
292
+ data : { routeCount: routes.length },
293
+ url, label,
264
294
  });
265
295
  }
266
296
  }
267
297
 
268
- // ── Phase 2: Real API Validation ───────────────────────────────────────
298
+ // ── Phase 2: API Validation ────────────────────────────────────────────
269
299
  async #phaseAPIValidation() {
270
300
  const apiRoutes = this.#session.routeMap.filter(r =>
271
- r.type === 'api' || r.url.includes('/api/')
301
+ r.type === 'api' || r.url?.includes('/api/')
272
302
  );
273
303
 
274
304
  this.#terminal.log(`Validating ${apiRoutes.length} API endpoints...`);
@@ -287,11 +317,11 @@ async init() {
287
317
  status : result.pass ? 'PASS' : 'FAIL',
288
318
  message : result.message,
289
319
  data : {
290
- statusCode : result.statusCode,
320
+ statusCode : result.statusCode,
291
321
  responseTime: result.responseTime,
292
- contentType: result.contentType,
293
- body : result.body?.slice(0, 500),
294
- headers : result.headers,
322
+ contentType : result.contentType,
323
+ body : result.body?.slice(0, 500),
324
+ headers : result.headers,
295
325
  },
296
326
  url : route.url,
297
327
  duration: result.responseTime,
@@ -308,7 +338,6 @@ async init() {
308
338
  }
309
339
  }
310
340
 
311
- // Detect APIs from network traffic
312
341
  const discoveredAPIs = await this.#apiValidator.discoverFromNetworkLog(
313
342
  this.#session.networkLog
314
343
  );
@@ -338,7 +367,6 @@ async init() {
338
367
  },
339
368
  });
340
369
 
341
- // Real screenshot on failure
342
370
  if (!result.pass || result.consoleErrors.length > 0) {
343
371
  const screenshot = await this.#screenshotter.capture(
344
372
  result.page,
@@ -346,7 +374,11 @@ async init() {
346
374
  );
347
375
  if (screenshot) {
348
376
  result.screenshotPath = screenshot;
349
- this.#session.screenshots.push({ url: route.url, path: screenshot, reason: result.failReason });
377
+ this.#session.screenshots.push({
378
+ url : route.url,
379
+ path : screenshot,
380
+ reason: result.failReason,
381
+ });
350
382
  }
351
383
  }
352
384
 
@@ -354,26 +386,27 @@ async init() {
354
386
  name : `Page: ${route.url}`,
355
387
  type : 'browser',
356
388
  category: 'interaction',
357
- status : result.pass ? (result.consoleErrors.length > 0 ? 'FLAKY' : 'PASS') : 'FAIL',
389
+ status : result.pass
390
+ ? (result.consoleErrors.length > 0 ? 'FLAKY' : 'PASS')
391
+ : 'FAIL',
358
392
  message : result.message,
359
393
  data : {
360
- loadTime : result.loadTime,
361
- consoleErrors : result.consoleErrors,
362
- networkErrors : result.networkErrors,
394
+ loadTime : result.loadTime,
395
+ consoleErrors : result.consoleErrors,
396
+ networkErrors : result.networkErrors,
363
397
  interactedElements: result.interactedElements,
364
- screenshotPath: result.screenshotPath,
365
- jsErrors : result.jsErrors,
366
- resourcesFailed: result.resourcesFailed,
367
- renderTime : result.renderTime,
368
- domContentLoaded: result.domContentLoaded,
398
+ screenshotPath : result.screenshotPath,
399
+ jsErrors : result.jsErrors,
400
+ resourcesFailed : result.resourcesFailed,
401
+ renderTime : result.renderTime,
402
+ domContentLoaded : result.domContentLoaded,
369
403
  },
370
- url : route.url,
371
- duration: result.loadTime,
404
+ url : route.url,
405
+ duration : result.loadTime,
372
406
  screenshotPath: result.screenshotPath,
373
407
  });
374
408
 
375
- // Real console errors bugs
376
- for (const err of result.consoleErrors) {
409
+ for (const err of (result.consoleErrors || [])) {
377
410
  this.#session.addBug({
378
411
  title : `JS Error: ${err.text?.slice(0, 80)}`,
379
412
  severity : err.type === 'error' ? 'P1' : 'P2',
@@ -384,8 +417,7 @@ async init() {
384
417
  });
385
418
  }
386
419
 
387
- // Real network failures bugs
388
- for (const nErr of result.networkErrors) {
420
+ for (const nErr of (result.networkErrors || [])) {
389
421
  this.#session.addBug({
390
422
  title : `Network Failure: ${nErr.url}`,
391
423
  severity : 'P2',
@@ -396,12 +428,10 @@ async init() {
396
428
  });
397
429
  }
398
430
 
399
- // Test forms on the page
400
- if (result.forms && result.forms.length > 0) {
431
+ if (result.forms?.length > 0) {
401
432
  await this.#testForms(route.url, result.forms, result.page);
402
433
  }
403
434
 
404
- // Test auth flows
405
435
  if (this.#isAuthPage(route.url)) {
406
436
  await this.#testAuthFlow(route.url, result.page);
407
437
  }
@@ -424,19 +454,18 @@ async init() {
424
454
  status : finding.pass ? 'PASS' : 'FAIL',
425
455
  message : finding.detail,
426
456
  data : finding.evidence,
427
- url,
428
- label,
457
+ url, label,
429
458
  severity: finding.severity,
430
459
  });
431
460
 
432
461
  if (!finding.pass && (finding.severity === 'P0' || finding.severity === 'P1')) {
433
462
  this.#session.addBug({
434
- title : `Security: ${finding.check}`,
435
- severity : finding.severity,
436
- type : 'security',
437
- description: finding.detail,
463
+ title : `Security: ${finding.check}`,
464
+ severity : finding.severity,
465
+ type : 'security',
466
+ description : finding.detail,
438
467
  url,
439
- evidence : finding.evidence,
468
+ evidence : finding.evidence,
440
469
  recommendation: finding.recommendation,
441
470
  });
442
471
  }
@@ -452,20 +481,18 @@ async init() {
452
481
  const metrics = await this.#performance.profile(url);
453
482
  this.#session.perfMetrics[label] = metrics;
454
483
 
455
- // Core Web Vitals as real test results
456
484
  const vitals = [
457
- { name: 'LCP', value: metrics.lcp, threshold: 2500, unit: 'ms' },
458
- { name: 'FID', value: metrics.fid, threshold: 100, unit: 'ms' },
459
- { name: 'CLS', value: metrics.cls, threshold: 0.1, unit: '' },
460
- { name: 'FCP', value: metrics.fcp, threshold: 1800, unit: 'ms' },
461
- { name: 'TTFB', value: metrics.ttfb, threshold: 800, unit: 'ms' },
462
- { name: 'TTI', value: metrics.tti, threshold: 3800, unit: 'ms' },
463
- { name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
485
+ { name: 'LCP', value: metrics.lcp, threshold: 2500, unit: 'ms' },
486
+ { name: 'FID', value: metrics.fid, threshold: 100, unit: 'ms' },
487
+ { name: 'CLS', value: metrics.cls, threshold: 0.1, unit: '' },
488
+ { name: 'FCP', value: metrics.fcp, threshold: 1800, unit: 'ms' },
489
+ { name: 'TTFB', value: metrics.ttfb, threshold: 800, unit: 'ms' },
490
+ { name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
464
491
  ];
465
492
 
466
493
  for (const vital of vitals) {
467
- const pass = vital.value !== null && vital.value <= vital.threshold;
468
- const na = vital.value === null;
494
+ const na = vital.value === null || vital.value === undefined;
495
+ const pass = !na && vital.value <= vital.threshold;
469
496
 
470
497
  this.#addResult({
471
498
  name : `[${label}] ${vital.name} — Core Web Vital`,
@@ -473,38 +500,35 @@ async init() {
473
500
  category: 'web-vitals',
474
501
  status : na ? 'SKIP' : (pass ? 'PASS' : 'FAIL'),
475
502
  message : na
476
- ? `${vital.name} not measurable`
477
- : `${vital.name}: ${vital.value}${vital.unit} (threshold: ${vital.threshold}${vital.unit})`,
478
- data : { value: vital.value, threshold: vital.threshold, unit: vital.unit },
479
- url,
480
- label,
503
+ ? `${vital.name} not measurable (HTTP-only mode)`
504
+ : `${vital.name}: ${vital.value}${vital.unit} (threshold: ≤${vital.threshold}${vital.unit})`,
505
+ data : { value: vital.value, threshold: vital.threshold },
506
+ url, label,
481
507
  duration: vital.value,
482
508
  });
483
509
 
484
510
  if (!na && !pass) {
485
511
  this.#session.addBug({
486
- title : `Poor ${vital.name}: ${vital.value}${vital.unit} (>${vital.threshold}${vital.unit})`,
487
- severity : vital.name === 'LCP' || vital.name === 'CLS' ? 'P1' : 'P2',
488
- type : 'performance',
489
- description: `${vital.name} exceeds threshold on ${label}`,
512
+ title : `Poor ${vital.name}: ${vital.value}${vital.unit} (>${vital.threshold}${vital.unit})`,
513
+ severity : (vital.name === 'LCP' || vital.name === 'CLS') ? 'P1' : 'P2',
514
+ type : 'performance',
515
+ description : `${vital.name} exceeds threshold on ${label}`,
490
516
  url,
491
- evidence : { value: vital.value, threshold: vital.threshold },
517
+ evidence : { value: vital.value, threshold: vital.threshold },
492
518
  recommendation: `Optimize ${vital.name} — see https://web.dev/vitals`,
493
519
  });
494
520
  }
495
521
  }
496
522
 
497
- // Real resource analysis
498
523
  for (const resource of (metrics.slowResources || [])) {
499
524
  this.#addResult({
500
- name : `[${label}] Slow resource: ${resource.url.split('/').pop()}`,
525
+ name : `[${label}] Slow resource: ${resource.url?.split('/').pop()}`,
501
526
  type : 'performance',
502
527
  category: 'resource',
503
528
  status : 'FAIL',
504
529
  message : `${resource.url} took ${resource.duration}ms (${formatBytes(resource.size)})`,
505
530
  data : resource,
506
- url,
507
- label,
531
+ url, label,
508
532
  duration: resource.duration,
509
533
  });
510
534
  }
@@ -524,7 +548,7 @@ async init() {
524
548
  const result = await this.#a11y.check(route.url);
525
549
  this.#session.a11yResults.push({ url: route.url, ...result });
526
550
 
527
- for (const violation of result.violations) {
551
+ for (const violation of (result.violations || [])) {
528
552
  this.#addResult({
529
553
  name : `A11y [${violation.impact}]: ${violation.description}`,
530
554
  type : 'accessibility',
@@ -545,18 +569,17 @@ async init() {
545
569
 
546
570
  if (violation.impact === 'critical' || violation.impact === 'serious') {
547
571
  this.#session.addBug({
548
- title : `A11y: ${violation.description}`,
549
- severity : violation.impact === 'critical' ? 'P0' : 'P1',
550
- type : 'accessibility',
551
- description: `${violation.nodes} element(s): ${violation.help}`,
552
- url : route.url,
553
- evidence : violation.affectedNodes,
572
+ title : `A11y: ${violation.description}`,
573
+ severity : violation.impact === 'critical' ? 'P0' : 'P1',
574
+ type : 'accessibility',
575
+ description : `${violation.nodes} element(s): ${violation.help}`,
576
+ url : route.url,
577
+ evidence : violation.affectedNodes,
554
578
  recommendation: violation.helpUrl,
555
579
  });
556
580
  }
557
581
  }
558
582
 
559
- // Passes also recorded as real results
560
583
  for (const pass of (result.passes || []).slice(0, 5)) {
561
584
  this.#addResult({
562
585
  name : `A11y Pass: ${pass.description}`,
@@ -584,7 +607,7 @@ async init() {
584
607
  const result = await this.#seo.scan(route.url);
585
608
  this.#session.seoResults.push({ url: route.url, ...result });
586
609
 
587
- for (const check of result.checks) {
610
+ for (const check of (result.checks || [])) {
588
611
  this.#addResult({
589
612
  name : `SEO: ${check.name} — ${new URL(route.url).pathname}`,
590
613
  type : 'seo',
@@ -598,11 +621,11 @@ async init() {
598
621
 
599
622
  if (!check.pass && (check.severity === 'P0' || check.severity === 'P1')) {
600
623
  this.#session.addBug({
601
- title : `SEO: ${check.name}`,
602
- severity : check.severity,
603
- type : 'seo',
604
- description: check.detail,
605
- url : route.url,
624
+ title : `SEO: ${check.name}`,
625
+ severity : check.severity,
626
+ type : 'seo',
627
+ description : check.detail,
628
+ url : route.url,
606
629
  recommendation: check.recommendation,
607
630
  });
608
631
  }
@@ -616,16 +639,16 @@ async init() {
616
639
 
617
640
  for (const bug of this.#session.bugs) {
618
641
  const classification = await this.#aiClassifier.classify(bug, this.#session);
619
- bug.aiSeverity = classification.severity;
620
- bug.aiCategory = classification.category;
642
+ bug.aiSeverity = classification.severity;
643
+ bug.aiCategory = classification.category;
621
644
  bug.aiRecommendation = classification.recommendation;
622
- bug.aiConfidence = classification.confidence;
645
+ bug.aiConfidence = classification.confidence;
623
646
  }
624
647
 
625
- // Sort bugs by AI-determined severity
626
648
  this.#session.bugs.sort((a, b) => {
627
649
  const order = { P0: 0, P1: 1, P2: 2, P3: 3 };
628
- return (order[a.aiSeverity || a.severity] || 3) - (order[b.aiSeverity || b.severity] || 3);
650
+ return (order[a.aiSeverity || a.severity] || 3)
651
+ - (order[b.aiSeverity || b.severity] || 3);
629
652
  });
630
653
  }
631
654
 
@@ -673,9 +696,9 @@ async init() {
673
696
 
674
697
  const result = await this.#interactor.testAuthFlow(page, url, {
675
698
  testCredentials: [
676
- { username: 'test@example.com', password: 'wrong-password-test', expectFail: true },
677
- { username: 'invalid@test.com', password: 'wrong123', expectFail: true },
678
- { username: '', password: '', expectFail: true },
699
+ { username: 'test@example.com', password: 'wrong-password-test', expectFail: true },
700
+ { username: 'invalid@test.com', password: 'wrong123', expectFail: true },
701
+ { username: '', password: '', expectFail: true },
679
702
  ],
680
703
  });
681
704
 
@@ -706,7 +729,6 @@ async init() {
706
729
  return /\/(login|signin|auth|register|signup)/i.test(url);
707
730
  }
708
731
 
709
- // ── Add real result ────────────────────────────────────────────────────
710
732
  #addResult(result) {
711
733
  const r = {
712
734
  id : shortId(),
@@ -721,9 +743,9 @@ async init() {
721
743
  }
722
744
  }
723
745
 
724
- // ─────────────────────────────────────────────────────────────────────────
746
+ // ═══════════════════════════════════════════════════════════════════════════
725
747
  // Public API — exported functions
726
- // ─────────────────────────────────────────────────────────────────────────
748
+ // ═══════════════════════════════════════════════════════════════════════════
727
749
 
728
750
  export async function initQASystem() {
729
751
  await fs.ensureDir(QA_DIR);
@@ -738,12 +760,12 @@ export async function saveSession(session) {
738
760
  const history = await loadHistory();
739
761
  const summary = session.getSummary();
740
762
  history.runs.unshift({
741
- id : session.id,
742
- startedAt: session.startedAt,
743
- urls : session.urls,
763
+ id : session.id,
764
+ startedAt : session.startedAt,
765
+ urls : session.urls,
744
766
  summary,
745
- version : VERSION,
746
- bugCount : session.bugs.length,
767
+ version : VERSION,
768
+ bugCount : session.bugs.length,
747
769
  screenshotCount: session.screenshots.length,
748
770
  });
749
771
  if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
@@ -774,11 +796,8 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl, options = {} } =
774
796
  await engine.run();
775
797
  await saveSession(session);
776
798
 
777
- const htmlReporter = new HTMLReporter(session);
778
- const jsonReporter = new JSONReporter(session);
779
-
780
- const htmlPath = await htmlReporter.generate(REPORT_DIR);
781
- const jsonPath = await jsonReporter.generate(REPORT_DIR);
799
+ const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
800
+ const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
782
801
 
783
802
  return { session, htmlPath, jsonPath };
784
803
  }
@@ -832,12 +851,12 @@ export async function runManualQA() {
832
851
  const action = await p.select({
833
852
  message: 'Manual QA — what to run?',
834
853
  options: [
835
- { value: 'full-url', label: '🌐 Full URL-Based Real Scan', hint: 'Browser + API + Security + Perf + SEO + A11y' },
836
- { value: 'security', label: '🛡️ Security Only', hint: 'Real HTTP security header + vuln scan' },
837
- { value: 'perf', label: '⚡ Performance Only', hint: 'Real Core Web Vitals measurement' },
838
- { value: 'a11y', label: '♿ Accessibility Only', hint: 'Real axe-core WCAG scan' },
839
- { value: 'seo', label: '🔎 SEO Only', hint: 'Real meta, og, robots, sitemap scan' },
840
- { value: 'api', label: '📡 API Only', hint: 'Real endpoint probe + contract validation' },
854
+ { value: 'full-url', label: '🌐 Full URL-Based Real Scan', hint: 'Browser + API + Security + Perf + SEO + A11y' },
855
+ { value: 'security', label: '🛡️ Security Only', hint: 'Real HTTP security header + vuln scan' },
856
+ { value: 'perf', label: '⚡ Performance Only', hint: 'Real Core Web Vitals measurement' },
857
+ { value: 'a11y', label: '♿ Accessibility Only', hint: 'Real axe-core WCAG scan' },
858
+ { value: 'seo', label: '🔎 SEO Only', hint: 'Real meta, og, robots, sitemap scan' },
859
+ { value: 'api', label: '📡 API Only', hint: 'Real endpoint probe + contract validation' },
841
860
  ],
842
861
  });
843
862
  if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
@@ -861,8 +880,6 @@ export async function runManualQA() {
861
880
  const session = new QASession(urls);
862
881
  const engine = new QAEngine(session);
863
882
  await engine.init();
864
-
865
- // Only run selected phases
866
883
  await engine.runPhase(action);
867
884
 
868
885
  await saveSession(session);
@@ -881,8 +898,8 @@ export async function autoRunPostGeneration(options = {}) {
881
898
  console.log('');
882
899
 
883
900
  const url = await p.text({
884
- message : 'Server URL to validate:',
885
- placeholder: 'http://localhost:3000',
901
+ message : 'Server URL to validate:',
902
+ placeholder : 'http://localhost:3000',
886
903
  defaultValue: 'http://localhost:3000',
887
904
  });
888
905
  if (p.isCancel(url)) { p.cancel('Cancelled.'); return; }
@@ -907,7 +924,8 @@ export async function viewQAHistory() {
907
924
 
908
925
  for (const run of history.runs.slice(0, 15)) {
909
926
  const rate = run.summary?.passRate ?? '–';
910
- const color = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
927
+ const color = Number(rate) >= 90 ? chalk.green
928
+ : Number(rate) >= 70 ? chalk.yellow : chalk.red;
911
929
  const bugs = run.bugCount ?? 0;
912
930
  const shots = run.screenshotCount ?? 0;
913
931
  const urlStr = Object.values(run.urls || {}).filter(Boolean).join(', ');
@@ -939,7 +957,6 @@ export async function viewQAHistory() {
939
957
  const reportPath = path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
940
958
  if (await fs.pathExists(reportPath)) {
941
959
  console.log(chalk.green(` 📄 Report: ${reportPath}`));
942
- // Open in browser if possible
943
960
  try {
944
961
  const { exec } = await import('node:child_process');
945
962
  const cmd = process.platform === 'darwin' ? 'open'