create-backlist 10.0.6 → 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.
- package/package.json +1 -1
- package/src/qa/qa-engine.js +221 -204
package/package.json
CHANGED
package/src/qa/qa-engine.js
CHANGED
|
@@ -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
|
|
32
|
-
export const QA_DIR
|
|
33
|
-
export const REPORT_DIR
|
|
34
|
-
export const HISTORY_FILE
|
|
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()
|
|
39
|
-
export function shortId()
|
|
40
|
-
export function sleep(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)
|
|
47
|
-
if (b < 1024)
|
|
48
|
-
if (b < 1024 * 1024)
|
|
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
|
|
58
|
-
bugs
|
|
59
|
-
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
|
|
115
|
-
this.#terminal
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
246
|
-
maxDepth
|
|
247
|
-
onRoute
|
|
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
|
|
262
|
-
url,
|
|
263
|
-
label,
|
|
292
|
+
data : { routeCount: routes.length },
|
|
293
|
+
url, label,
|
|
264
294
|
});
|
|
265
295
|
}
|
|
266
296
|
}
|
|
267
297
|
|
|
268
|
-
// ── Phase 2:
|
|
298
|
+
// ── Phase 2: API Validation ────────────────────────────────────────────
|
|
269
299
|
async #phaseAPIValidation() {
|
|
270
300
|
const apiRoutes = this.#session.routeMap.filter(r =>
|
|
271
|
-
r.type === 'api' || r.url
|
|
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
|
|
320
|
+
statusCode : result.statusCode,
|
|
291
321
|
responseTime: result.responseTime,
|
|
292
|
-
contentType: result.contentType,
|
|
293
|
-
body
|
|
294
|
-
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({
|
|
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
|
|
389
|
+
status : result.pass
|
|
390
|
+
? (result.consoleErrors.length > 0 ? 'FLAKY' : 'PASS')
|
|
391
|
+
: 'FAIL',
|
|
358
392
|
message : result.message,
|
|
359
393
|
data : {
|
|
360
|
-
loadTime
|
|
361
|
-
consoleErrors
|
|
362
|
-
networkErrors
|
|
394
|
+
loadTime : result.loadTime,
|
|
395
|
+
consoleErrors : result.consoleErrors,
|
|
396
|
+
networkErrors : result.networkErrors,
|
|
363
397
|
interactedElements: result.interactedElements,
|
|
364
|
-
screenshotPath: result.screenshotPath,
|
|
365
|
-
jsErrors
|
|
366
|
-
resourcesFailed: result.resourcesFailed,
|
|
367
|
-
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
|
|
371
|
-
duration: result.loadTime,
|
|
404
|
+
url : route.url,
|
|
405
|
+
duration : result.loadTime,
|
|
372
406
|
screenshotPath: result.screenshotPath,
|
|
373
407
|
});
|
|
374
408
|
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
435
|
-
severity
|
|
436
|
-
type
|
|
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
|
|
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,
|
|
458
|
-
{ name: 'FID', value: metrics.fid, threshold: 100,
|
|
459
|
-
{ name: 'CLS', value: metrics.cls, threshold: 0.1,
|
|
460
|
-
{ name: 'FCP', value: metrics.fcp, threshold: 1800,
|
|
461
|
-
{ name: 'TTFB', value: metrics.ttfb, threshold: 800,
|
|
462
|
-
{ name: '
|
|
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
|
|
468
|
-
const
|
|
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:
|
|
478
|
-
data : { value: vital.value, threshold: vital.threshold
|
|
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
|
|
487
|
-
severity
|
|
488
|
-
type
|
|
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
|
|
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
|
|
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
|
|
549
|
-
severity
|
|
550
|
-
type
|
|
551
|
-
description: `${violation.nodes} element(s): ${violation.help}`,
|
|
552
|
-
url
|
|
553
|
-
evidence
|
|
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
|
|
602
|
-
severity
|
|
603
|
-
type
|
|
604
|
-
description: check.detail,
|
|
605
|
-
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
|
|
620
|
-
bug.aiCategory
|
|
642
|
+
bug.aiSeverity = classification.severity;
|
|
643
|
+
bug.aiCategory = classification.category;
|
|
621
644
|
bug.aiRecommendation = classification.recommendation;
|
|
622
|
-
bug.aiConfidence
|
|
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)
|
|
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',
|
|
677
|
-
{ username: 'invalid@test.com',
|
|
678
|
-
{ username: '',
|
|
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
|
|
742
|
-
startedAt: session.startedAt,
|
|
743
|
-
urls
|
|
763
|
+
id : session.id,
|
|
764
|
+
startedAt : session.startedAt,
|
|
765
|
+
urls : session.urls,
|
|
744
766
|
summary,
|
|
745
|
-
version
|
|
746
|
-
bugCount
|
|
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
|
|
778
|
-
const
|
|
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',
|
|
836
|
-
{ value: 'security',
|
|
837
|
-
{ value: 'perf',
|
|
838
|
-
{ value: 'a11y',
|
|
839
|
-
{ value: 'seo',
|
|
840
|
-
{ value: 'api',
|
|
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
|
|
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
|
|
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'
|