create-backlist 10.0.6 → 10.0.8

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.
package/bin/index.js CHANGED
@@ -272,7 +272,7 @@ async function printAnimatedBanner() {
272
272
  ' ║ / /_/ / / ___ |/ /___/ /| | / /____/ / ___/ / ║',
273
273
  ' ║/_____/ /_/ |_|\\____/_/ |_| /_____/___//____/ ║',
274
274
  ' ║ ║',
275
- ' ║ ⚡ v9.0-ULTRA — Animated Enterprise CLI ⚡ ║',
275
+ ' ║ ⚡ v10.0-ULTRA — BACKLIST NEXTOG ENTERPRICE CLI ⚡ ║',
276
276
  ' ║ Real Testing · AI Powered · Zero Fake Data · Playwright ║',
277
277
  ' ╚══════════════════════════════════════════════════════════════╝',
278
278
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-backlist",
3
- "version": "10.0.6",
3
+ "version": "10.0.8",
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": {
@@ -1,4 +1,5 @@
1
1
  // Smart crawler with HTTP fallback when browser unavailable
2
+ import chalk from 'chalk';
2
3
  import { URL } from 'node:url';
3
4
  import { shortId } from '../qa-engine.js';
4
5
  import { getBrowserLaunchOptions } from './installer.js';
@@ -19,9 +20,9 @@ export class HTTPCrawler {
19
20
  while (queue.length > 0 && routes.length < maxPages) {
20
21
  const { url, depth } = queue.shift();
21
22
  const norm = this.#norm(url);
22
- if (!norm || this.#visited.has(norm)) continue;
23
- if (!this.#sameOrigin(norm, baseUrl)) continue;
24
- if (depth > 3) continue;
23
+ if (!norm || this.#visited.has(norm)) continue;
24
+ if (!this.#sameOrigin(norm, baseUrl)) continue;
25
+ if (depth > 3) continue;
25
26
 
26
27
  this.#visited.add(norm);
27
28
 
@@ -29,18 +30,15 @@ export class HTTPCrawler {
29
30
  routes.push(route);
30
31
  if (onRoute) onRoute(route);
31
32
 
32
- // Extract links from HTML response
33
- if (route.links) {
34
- for (const link of route.links) {
35
- const ln = this.#norm(link);
36
- if (ln && !this.#visited.has(ln) && this.#sameOrigin(ln, baseUrl)) {
37
- queue.push({ url: ln, depth: depth + 1 });
38
- }
33
+ for (const link of (route.links || [])) {
34
+ const ln = this.#norm(link);
35
+ if (ln && !this.#visited.has(ln) && this.#sameOrigin(ln, baseUrl)) {
36
+ queue.push({ url: ln, depth: depth + 1 });
39
37
  }
40
38
  }
41
39
  }
42
40
 
43
- // Also probe common API paths
41
+ // Probe common API paths
44
42
  const apiPaths = [
45
43
  '/api/health', '/api/status', '/api/v1/health',
46
44
  '/api/v1/users', '/api/v1/products', '/health',
@@ -48,15 +46,17 @@ export class HTTPCrawler {
48
46
  ];
49
47
 
50
48
  for (const p of apiPaths) {
51
- const url = new URL(p, baseUrl).toString();
52
- const norm = this.#norm(url);
53
- if (this.#visited.has(norm)) continue;
54
- this.#visited.add(norm);
55
- const route = await this.#probeURL(url, 0);
56
- if (route.status > 0 && route.status < 500) {
57
- routes.push(route);
58
- if (onRoute) onRoute(route);
59
- }
49
+ try {
50
+ const url = new URL(p, baseUrl).toString();
51
+ const norm = this.#norm(url);
52
+ if (this.#visited.has(norm)) continue;
53
+ this.#visited.add(norm);
54
+ const route = await this.#probeURL(url, 0);
55
+ if (route.status > 0 && route.status < 500) {
56
+ routes.push(route);
57
+ if (onRoute) onRoute(route);
58
+ }
59
+ } catch {}
60
60
  }
61
61
 
62
62
  return routes;
@@ -75,7 +75,7 @@ export class HTTPCrawler {
75
75
  });
76
76
  clearTimeout(timer);
77
77
 
78
- const ct = res.headers.get('content-type') || '';
78
+ const ct = res.headers.get('content-type') || '';
79
79
  const duration = Date.now() - t0;
80
80
  const headers = {};
81
81
  res.headers.forEach((v, k) => { headers[k] = v; });
@@ -151,25 +151,24 @@ export class HTTPCrawler {
151
151
  }
152
152
 
153
153
  #detectType(url, ct, status) {
154
- if (status >= 400) return 'error-page';
154
+ if (status >= 400) return 'error-page';
155
155
  if (ct.includes('json') || url.includes('/api/')) return 'api';
156
156
  if (url.endsWith('.xml') || url.endsWith('.txt')) return 'resource';
157
- if (/\/(login|signin|auth)/i.test(url)) return 'auth';
158
- if (/\/(admin)/i.test(url)) return 'admin';
159
- if (/\/(dashboard)/i.test(url)) return 'dashboard';
157
+ if (/\/(login|signin|auth)/i.test(url)) return 'auth';
158
+ if (/\/(admin)/i.test(url)) return 'admin';
159
+ if (/\/(dashboard)/i.test(url)) return 'dashboard';
160
160
  return 'page';
161
161
  }
162
162
  }
163
163
 
164
164
  // ── Browser-powered crawler (Playwright) ──────────────────────────────────
165
165
  export class SmartCrawler {
166
- #visited = new Set();
166
+ #visited = new Set();
167
167
  #launchOpts = null;
168
168
 
169
- constructor(_playwright) {} // playwright passed but we resolve it ourselves
169
+ constructor(_playwright) {}
170
170
 
171
171
  async crawl(baseUrl, { maxPages = 60, maxDepth = 4, onRoute } = {}) {
172
- // Resolve launch options including auto-install
173
172
  if (!this.#launchOpts) {
174
173
  this.#launchOpts = await getBrowserLaunchOptions();
175
174
  }
@@ -213,9 +212,9 @@ export class SmartCrawler {
213
212
  while (queue.length > 0 && routes.length < maxPages) {
214
213
  const { url, depth } = queue.shift();
215
214
  const norm = this.#norm(url);
216
- if (!norm || this.#visited.has(norm)) continue;
217
- if (!this.#sameOrigin(norm, baseUrl)) continue;
218
- if (depth > maxDepth) continue;
215
+ if (!norm || this.#visited.has(norm)) continue;
216
+ if (!this.#sameOrigin(norm, baseUrl)) continue;
217
+ if (depth > maxDepth) continue;
219
218
  this.#visited.add(norm);
220
219
 
221
220
  const route = await this.#probePage(context, norm, depth, baseUrl);
@@ -240,9 +239,9 @@ export class SmartCrawler {
240
239
  }
241
240
 
242
241
  async #probePage(context, url, depth, baseUrl) {
243
- const page = await context.newPage();
244
- const networkRequests = [];
245
- const links = new Set();
242
+ const page = await context.newPage();
243
+ const networkRequests = [];
244
+ const links = new Set();
246
245
 
247
246
  page.on('request', req => {
248
247
  const u = req.url();
@@ -257,27 +256,44 @@ export class SmartCrawler {
257
256
  const ct = response?.headers()['content-type'] || '';
258
257
 
259
258
  if (!ct.includes('text/html') && !ct.includes('application/xhtml')) {
260
- return { id: shortId(), url, type: this.#detectType(url, ct, status), status, depth, links: [], forms: [] };
259
+ return {
260
+ id: shortId(), url,
261
+ type : this.#detectType(url, ct, status),
262
+ status, depth, links: [], forms: [],
263
+ };
261
264
  }
262
265
 
263
- const hrefs = await page.$$eval('a[href]', els => els.map(e => e.href).filter(Boolean)).catch(() => []);
266
+ const hrefs = await page.$$eval('a[href]', els => els.map(e => e.href).filter(Boolean))
267
+ .catch(() => []);
264
268
  hrefs.forEach(h => links.add(h));
265
269
 
266
270
  const forms = await page.$$eval('form', els => els.map(f => ({
267
- action: f.action, method: f.method || 'GET',
271
+ action: f.action,
272
+ method: f.method || 'GET',
268
273
  fields: Array.from(f.elements).map(el => ({
269
- name: el.name, type: el.type, required: el.required,
274
+ name : el.name,
275
+ type : el.type,
276
+ required: el.required,
270
277
  })).filter(f => f.name),
271
278
  }))).catch(() => []);
272
279
 
273
- networkRequests.forEach(r => { if (this.#sameOrigin(r.url, baseUrl)) links.add(r.url); });
280
+ networkRequests.forEach(r => {
281
+ if (this.#sameOrigin(r.url, baseUrl)) links.add(r.url);
282
+ });
274
283
 
275
284
  return {
276
- id: shortId(), url, type: this.#detectType(url, ct, status),
277
- status, depth, links: [...links], forms, contentType: ct,
285
+ id: shortId(), url,
286
+ type : this.#detectType(url, ct, status),
287
+ status, depth,
288
+ links : [...links],
289
+ forms,
290
+ contentType: ct,
278
291
  };
279
292
  } catch (err) {
280
- return { id: shortId(), url, type: 'error', status: 0, depth, links: [], forms: [], error: err.message };
293
+ return {
294
+ id: shortId(), url, type: 'error', status: 0,
295
+ depth, links: [], forms: [], error: err.message,
296
+ };
281
297
  } finally {
282
298
  await page.close().catch(() => {});
283
299
  }
@@ -294,12 +310,12 @@ export class SmartCrawler {
294
310
  }
295
311
 
296
312
  #detectType(url, ct, status) {
297
- if (status >= 400) return 'error-page';
313
+ if (status >= 400) return 'error-page';
298
314
  if (ct.includes('json') || url.includes('/api/')) return 'api';
299
315
  if (url.endsWith('.xml') || url.endsWith('.txt')) return 'resource';
300
- if (/\/(login|signin|auth)/i.test(url)) return 'auth';
301
- if (/\/(admin)/i.test(url)) return 'admin';
302
- if (/\/(dashboard)/i.test(url)) return 'dashboard';
316
+ if (/\/(login|signin|auth)/i.test(url)) return 'auth';
317
+ if (/\/(admin)/i.test(url)) return 'admin';
318
+ if (/\/(dashboard)/i.test(url)) return 'dashboard';
303
319
  return 'page';
304
320
  }
305
- }
321
+ }
@@ -1,5 +1,6 @@
1
1
  // Real browser interactions with HTTP fallback
2
- import { shortId, sleep } from '../qa-engine.js';
2
+ import chalk from 'chalk';
3
+ import { shortId, sleep } from '../qa-engine.js';
3
4
  import { getBrowserLaunchOptions } from './installer.js';
4
5
 
5
6
  // ── HTTP-only page tester (fallback) ──────────────────────────────────────
@@ -27,30 +28,28 @@ export class HTTPPageTester {
27
28
  try { text = await res.text(); } catch {}
28
29
  }
29
30
 
30
- const pass = status >= 200 && status < 400;
31
-
32
- // Extract forms from HTML
31
+ const pass = status >= 200 && status < 400;
33
32
  const forms = this.#extractForms(text);
34
33
 
35
34
  onNetworkEvent?.({ type: 'response', url, status, headers });
36
35
 
37
36
  return {
38
37
  pass,
39
- failReason : pass ? null : `HTTP ${status}`,
40
- page : null, // no real browser page
41
- loadTime : duration,
42
- consoleErrors : [], // HTTP mode — no JS execution
43
- networkErrors : [],
44
- jsErrors : [],
45
- resourcesFailed : [],
38
+ failReason : pass ? null : `HTTP ${status}`,
39
+ page : null,
40
+ loadTime : duration,
41
+ consoleErrors : [],
42
+ networkErrors : [],
43
+ jsErrors : [],
44
+ resourcesFailed : [],
46
45
  interactedElements: [],
47
- renderTime : null,
48
- domContentLoaded: null,
46
+ renderTime : null,
47
+ domContentLoaded : null,
49
48
  forms,
50
49
  status,
51
50
  headers,
52
51
  message: pass ? `HTTP ${status} in ${duration}ms` : `HTTP ${status}`,
53
- mode : 'http-fallback',
52
+ mode : 'http-fallback',
54
53
  };
55
54
  } catch (err) {
56
55
  return {
@@ -63,13 +62,22 @@ export class HTTPPageTester {
63
62
  }
64
63
 
65
64
  async testForm(page, form) {
66
- return { pass: true, validationOk: true, submissionOk: true, errors: [], duration: 0, message: 'HTTP mode — form structure detected' };
65
+ return {
66
+ pass : true,
67
+ validationOk: true,
68
+ submissionOk: true,
69
+ errors : [],
70
+ duration : 0,
71
+ message : 'HTTP mode — form structure detected',
72
+ };
67
73
  }
68
74
 
69
75
  async testAuthFlow(page, url, opts) {
70
- // HTTP-mode auth check: verify login page exists + returns 200
71
76
  try {
72
- const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
77
+ const res = await fetch(url, {
78
+ redirect: 'follow',
79
+ signal : AbortSignal.timeout(8000),
80
+ });
73
81
  return {
74
82
  pass : res.status === 200,
75
83
  details: [{ url, status: res.status, note: 'Login page reachable (HTTP mode)' }],
@@ -93,7 +101,7 @@ export class HTTPPageTester {
93
101
  const method = (attrs.match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
94
102
  const fields = [];
95
103
  const inpRe = /<input([^>]*)>/gi;
96
- let inp;
104
+ let inp;
97
105
  while ((inp = inpRe.exec(body)) !== null) {
98
106
  const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
99
107
  const type = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
@@ -110,9 +118,9 @@ export class HTTPPageTester {
110
118
  export class BrowserInteractor {
111
119
  #playwright;
112
120
  #session;
113
- #browser = null;
114
- #context = null;
115
- #launchOpts = null;
121
+ #browser = null;
122
+ #context = null;
123
+ #launchOpts = null;
116
124
  #httpFallback;
117
125
  #useFallback = false;
118
126
 
@@ -133,9 +141,20 @@ export class BrowserInteractor {
133
141
  return;
134
142
  }
135
143
 
144
+ // If playwright module wasn't loaded, fall back
145
+ if (!this.#playwright) {
146
+ this.#useFallback = true;
147
+ console.log(chalk.yellow('\n ⚠ Playwright module not available — using HTTP-only mode\n'));
148
+ return;
149
+ }
150
+
136
151
  try {
137
152
  const { executablePath, headless, args } = this.#launchOpts;
138
- this.#browser = await this.#playwright.chromium.launch({ executablePath, headless, args });
153
+ this.#browser = await this.#playwright.chromium.launch({
154
+ executablePath,
155
+ headless,
156
+ args,
157
+ });
139
158
  this.#context = await this.#browser.newContext({
140
159
  viewport : { width: 1280, height: 800 },
141
160
  ignoreHTTPSErrors: true,
@@ -160,11 +179,11 @@ export class BrowserInteractor {
160
179
  return this.#httpFallback.testPage(url, opts);
161
180
  }
162
181
 
163
- const page = await this.#context.newPage();
164
- const consoleErrors = [];
165
- const networkErrors = [];
166
- const jsErrors = [];
167
- const resourcesFailed = [];
182
+ const page = await this.#context.newPage();
183
+ const consoleErrors = [];
184
+ const networkErrors = [];
185
+ const jsErrors = [];
186
+ const resourcesFailed = [];
168
187
  const interactedElements = [];
169
188
 
170
189
  page.on('console', msg => {
@@ -182,7 +201,12 @@ export class BrowserInteractor {
182
201
  });
183
202
 
184
203
  page.on('requestfailed', req => {
185
- const entry = { url: req.url(), method: req.method(), failure: req.failure()?.errorText || 'unknown', timestamp: Date.now() };
204
+ const entry = {
205
+ url : req.url(),
206
+ method : req.method(),
207
+ failure : req.failure()?.errorText || 'unknown',
208
+ timestamp: Date.now(),
209
+ };
186
210
  networkErrors.push(entry);
187
211
  resourcesFailed.push({ url: req.url(), type: req.resourceType(), failure: entry.failure });
188
212
  opts.onNetworkEvent?.({ type: 'failed', ...entry });
@@ -199,27 +223,33 @@ export class BrowserInteractor {
199
223
  const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
200
224
  const status = response?.status() || 0;
201
225
 
202
- if (status >= 500) { pass = false; failReason = `Server error: HTTP ${status}`; }
226
+ if (status >= 500) { pass = false; failReason = `Server error: HTTP ${status}`; }
203
227
  else if (status >= 400) { pass = false; failReason = `Client error: HTTP ${status}`; }
204
228
 
205
229
  const timing = await page.evaluate(() => {
206
230
  const t = window.performance?.timing;
207
231
  if (!t) return null;
208
- return { renderTime: t.domComplete - t.navigationStart, domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart };
232
+ return {
233
+ renderTime : t.domComplete - t.navigationStart,
234
+ domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
235
+ };
209
236
  }).catch(() => null);
210
237
 
211
238
  renderTime = timing?.renderTime;
212
239
  domContentLoaded = timing?.domContentLoaded;
213
240
 
214
241
  const forms = await page.$$eval('form', els => els.map(f => ({
215
- action: f.action, method: f.method || 'GET',
242
+ action: f.action,
243
+ method: f.method || 'GET',
216
244
  fields: Array.from(f.elements).map(el => ({
217
- name: el.name, type: el.type, required: el.required,
245
+ name : el.name,
246
+ type : el.type,
247
+ required: el.required,
218
248
  tagName: el.tagName?.toLowerCase(),
219
249
  })).filter(f => f.name),
220
250
  }))).catch(() => []);
221
251
 
222
- // Real interactions
252
+ // Real hover interactions on nav links
223
253
  const navLinks = await page.$$('nav a, header a').catch(() => []);
224
254
  for (const link of navLinks.slice(0, 5)) {
225
255
  try {
@@ -243,8 +273,10 @@ export class BrowserInteractor {
243
273
 
244
274
  } catch (err) {
245
275
  return {
246
- pass: false, failReason: err.message, page,
247
- loadTime: Date.now() - t0,
276
+ pass : false,
277
+ failReason: err.message,
278
+ page,
279
+ loadTime : Date.now() - t0,
248
280
  consoleErrors, networkErrors, jsErrors,
249
281
  resourcesFailed, interactedElements,
250
282
  forms: [], message: err.message, mode: 'browser',
@@ -270,10 +302,24 @@ export class BrowserInteractor {
270
302
  els => els.map(e => e.textContent?.trim()).filter(Boolean)
271
303
  ).catch(() => []);
272
304
 
273
- return { pass: true, validationOk: msgs.length > 0, submissionOk: true, errors, duration: Date.now() - t0, message: 'Form tested' };
305
+ return {
306
+ pass : true,
307
+ validationOk: msgs.length > 0,
308
+ submissionOk: true,
309
+ errors,
310
+ duration : Date.now() - t0,
311
+ message : 'Form tested',
312
+ };
274
313
  } catch (err) {
275
314
  errors.push(err.message);
276
- return { pass: false, validationOk: false, submissionOk: false, errors, duration: Date.now() - t0, message: err.message };
315
+ return {
316
+ pass : false,
317
+ validationOk: false,
318
+ submissionOk: false,
319
+ errors,
320
+ duration : Date.now() - t0,
321
+ message : err.message,
322
+ };
277
323
  }
278
324
  }
279
325
 
@@ -286,35 +332,51 @@ export class BrowserInteractor {
286
332
  for (const cred of (opts.testCredentials || [])) {
287
333
  try {
288
334
  await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
335
+
289
336
  const uf = await page.$('input[type="email"],input[name="email"],input[name="username"]');
290
337
  const pf = await page.$('input[type="password"]');
291
338
  if (!uf || !pf) { details.push({ note: 'Login fields not found' }); continue; }
339
+
292
340
  await uf.fill(cred.username);
293
341
  await pf.fill(cred.password);
342
+
294
343
  const btn = await page.$('[type="submit"]');
295
344
  if (btn) { await btn.click(); await page.waitForTimeout(2000); }
296
345
 
297
346
  const body = await page.textContent('body').catch(() => '');
298
347
  const curUrl = page.url();
299
- const rejected = /invalid|incorrect|error|wrong|fail/i.test(body) || curUrl.includes('login');
348
+ const rejected = /invalid|incorrect|error|wrong|fail/i.test(body)
349
+ || curUrl.includes('login');
350
+
351
+ details.push({
352
+ credentials: cred.username.slice(0, 8) + '...',
353
+ expectFail : cred.expectFail,
354
+ wasRejected: rejected,
355
+ currentUrl : curUrl,
356
+ });
300
357
 
301
- details.push({ credentials: cred.username.slice(0, 8) + '...', expectFail: cred.expectFail, wasRejected: rejected, currentUrl: curUrl });
302
358
  if (cred.expectFail && !rejected) { pass = false; }
303
359
  } catch (err) {
304
360
  details.push({ error: err.message });
305
361
  }
306
362
  }
307
- return { pass, details, duration: Date.now() - t0, message: pass ? 'Auth flow validated' : 'Auth issues detected' };
363
+
364
+ return {
365
+ pass,
366
+ details,
367
+ duration: Date.now() - t0,
368
+ message : pass ? 'Auth flow validated' : 'Auth issues detected',
369
+ };
308
370
  }
309
371
 
310
372
  #testValue(field) {
311
373
  const t = (field.type || 'text').toLowerCase();
312
374
  const n = (field.name || '').toLowerCase();
313
- if (t === 'email' || n.includes('email')) return 'test@backlist-qa.dev';
314
- if (t === 'password' || n.includes('pass')) return 'TestPass123!';
315
- if (t === 'tel' || n.includes('phone')) return '+1-555-000-0000';
316
- if (t === 'number') return '42';
317
- if (n.includes('name')) return 'QA Test User';
375
+ if (t === 'email' || n.includes('email')) return 'test@backlist-qa.dev';
376
+ if (t === 'password' || n.includes('pass')) return 'TestPass123!';
377
+ if (t === 'tel' || n.includes('phone')) return '+1-555-000-0000';
378
+ if (t === 'number') return '42';
379
+ if (n.includes('name')) return 'QA Test User';
318
380
  return 'backlist-qa-test';
319
381
  }
320
- }
382
+ }
@@ -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'