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