create-backlist 10.0.8 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +0 -3
- package/package.json +1 -1
- package/src/qa/qa-engine.js +1732 -739
package/src/qa/qa-engine.js
CHANGED
|
@@ -1,77 +1,66 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// Backlist Enterprise
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// REAL RUNTIME TESTING — NO FAKE DATA
|
|
6
|
-
// Every result is collected from actual browser execution
|
|
2
|
+
// Backlist Enterprise QA Engine v13.0 — PLAYWRIGHT REAL BROWSER EDITION
|
|
3
|
+
// 100% Real Runtime Testing · Live Playwright Tests · Rich HTML Reports
|
|
7
4
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
5
|
|
|
9
|
-
import * as p
|
|
10
|
-
import chalk
|
|
11
|
-
import fs
|
|
12
|
-
import path
|
|
13
|
-
import os
|
|
14
|
-
import
|
|
6
|
+
import * as p from '@clack/prompts';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import fs from 'fs-extra';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import readline from 'node:readline';
|
|
12
|
+
import { performance } from 'node:perf_hooks';
|
|
15
13
|
import { EventEmitter } from 'node:events';
|
|
16
|
-
import readline from 'node:readline';
|
|
17
|
-
|
|
18
|
-
import { SmartCrawler } from './browser/crawler.js';
|
|
19
|
-
import { BrowserInteractor } from './browser/interactions.js';
|
|
20
|
-
import { ScreenshotCapture } from './browser/screenshot.js';
|
|
21
|
-
import { RealAPIValidator } from './analyzers/api.js';
|
|
22
|
-
import { SecurityScanner } from './analyzers/security.js';
|
|
23
|
-
import { PerformanceProfiler } from './analyzers/performance.js';
|
|
24
|
-
import { AccessibilityChecker} from './analyzers/accessibility.js';
|
|
25
|
-
import { SEOScanner } from './analyzers/seo.js';
|
|
26
|
-
import { HTMLReporter } from './reporters/html.js';
|
|
27
|
-
import { TerminalDashboard } from './reporters/terminal.js';
|
|
28
|
-
import { JSONReporter } from './reporters/json.js';
|
|
29
|
-
import { AIClassifier } from './utils/ai-classifier.js';
|
|
30
14
|
|
|
31
15
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
32
|
-
export const VERSION = '
|
|
16
|
+
export const VERSION = '13.0.0';
|
|
33
17
|
export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
|
|
34
18
|
export const REPORT_DIR = path.join(QA_DIR, 'reports');
|
|
35
19
|
export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
|
|
36
20
|
export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
|
|
37
21
|
|
|
38
22
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
39
|
-
export
|
|
40
|
-
export
|
|
41
|
-
export
|
|
42
|
-
export
|
|
23
|
+
export const timestamp = () => new Date().toISOString();
|
|
24
|
+
export const shortId = () => Math.random().toString(36).slice(2, 9);
|
|
25
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
26
|
+
export const formatDuration = (ms) => {
|
|
27
|
+
if (!ms || ms < 0) return '0ms';
|
|
43
28
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
44
29
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
45
|
-
}
|
|
46
|
-
export
|
|
47
|
-
if (!b || b < 0)
|
|
48
|
-
if (b < 1024)
|
|
49
|
-
if (b < 1024 * 1024)
|
|
30
|
+
};
|
|
31
|
+
export const formatBytes = (b) => {
|
|
32
|
+
if (!b || b < 0) return '0B';
|
|
33
|
+
if (b < 1024) return `${b}B`;
|
|
34
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
|
|
50
35
|
return `${(b / 1024 / 1024).toFixed(1)}MB`;
|
|
51
|
-
}
|
|
36
|
+
};
|
|
52
37
|
|
|
53
|
-
// ──
|
|
54
|
-
function
|
|
38
|
+
// ── readline helper ───────────────────────────────────────────────────────
|
|
39
|
+
function askYesNo(question) {
|
|
55
40
|
return new Promise((resolve) => {
|
|
56
|
-
const rl
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
// Auto-resolve after 10s if no input
|
|
61
|
-
const timer = setTimeout(() => {
|
|
62
|
-
rl.close();
|
|
63
|
-
resolve(false);
|
|
64
|
-
}, 10_000);
|
|
65
|
-
|
|
66
|
-
rl.question(question, (answer) => {
|
|
41
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
const timer = setTimeout(() => { rl.close(); resolve(false); }, 10_000);
|
|
43
|
+
rl.question(question, (ans) => {
|
|
67
44
|
clearTimeout(timer);
|
|
68
45
|
rl.close();
|
|
69
|
-
resolve(
|
|
46
|
+
resolve(ans.toLowerCase().trim() === 'y');
|
|
70
47
|
});
|
|
71
48
|
});
|
|
72
49
|
}
|
|
73
50
|
|
|
74
|
-
// ──
|
|
51
|
+
// ── Playwright availability check ────────────────────────────────────────
|
|
52
|
+
async function getPlaywright() {
|
|
53
|
+
try {
|
|
54
|
+
const pw = await import('playwright');
|
|
55
|
+
return pw.chromium || pw.default?.chromium || null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
+
// QA Session
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
75
64
|
export class QASession {
|
|
76
65
|
id;
|
|
77
66
|
startedAt;
|
|
@@ -87,17 +76,21 @@ export class QASession {
|
|
|
87
76
|
secFindings = [];
|
|
88
77
|
a11yResults = [];
|
|
89
78
|
seoResults = [];
|
|
79
|
+
playwrightMode = false;
|
|
90
80
|
|
|
91
|
-
constructor(urls) {
|
|
92
|
-
this.id = `QA-${shortId()}`;
|
|
81
|
+
constructor(urls = {}) {
|
|
82
|
+
this.id = `QA-${shortId().toUpperCase()}`;
|
|
93
83
|
this.startedAt = timestamp();
|
|
94
84
|
this.urls = urls;
|
|
95
85
|
}
|
|
96
86
|
|
|
97
|
-
addResult(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
addResult(r) { this.results.push(r); }
|
|
88
|
+
addBug(bug) {
|
|
89
|
+
this.bugs.push({
|
|
90
|
+
...bug,
|
|
91
|
+
id: `BUG-${shortId().toUpperCase()}`,
|
|
92
|
+
createdAt: timestamp(),
|
|
93
|
+
});
|
|
101
94
|
}
|
|
102
95
|
|
|
103
96
|
getSummary() {
|
|
@@ -114,639 +107,1637 @@ export class QASession {
|
|
|
114
107
|
}
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
constructor(session, options = {}) {
|
|
133
|
-
super();
|
|
134
|
-
this.#session = session;
|
|
135
|
-
this.#terminal = new TerminalDashboard(session);
|
|
136
|
-
this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
|
|
137
|
-
this.#aiClassifier = new AIClassifier();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ── FIX: init() — no await inside non-async callbacks ─────────────────
|
|
141
|
-
async init() {
|
|
142
|
-
// Dynamic import Playwright — optional dependency
|
|
143
|
-
let playwright = null;
|
|
144
|
-
try {
|
|
145
|
-
playwright = await import('playwright');
|
|
146
|
-
} catch {
|
|
147
|
-
// Will use HTTP fallback throughout — playwright is optional
|
|
148
|
-
}
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// HTTP Probe — real HTTP requests
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
|
|
114
|
+
const t0 = Date.now();
|
|
115
|
+
try {
|
|
116
|
+
const ctrl = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
118
|
+
const res = await fetch(url, {
|
|
119
|
+
method,
|
|
120
|
+
signal : ctrl.signal,
|
|
121
|
+
headers : { 'User-Agent': 'Backlist-QA/13.0', Accept: '*/*', ...headers },
|
|
122
|
+
redirect: 'follow',
|
|
123
|
+
});
|
|
124
|
+
clearTimeout(timer);
|
|
149
125
|
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
console.log(chalk.gray(' Browser-based tests (JS errors, screenshots, real Web Vitals)'));
|
|
158
|
-
console.log(chalk.gray(' will be skipped. All HTTP-based tests will still run.\n'));
|
|
159
|
-
console.log(chalk.dim(' To enable full browser testing:'));
|
|
160
|
-
console.log(chalk.white(' npx playwright install chromium\n'));
|
|
161
|
-
|
|
162
|
-
// ── FIX: use the extracted askQuestion() helper — no await in Promise ──
|
|
163
|
-
const shouldInstall = await askQuestion(
|
|
164
|
-
chalk.cyan(' Install Playwright browser now? (y/N): ')
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
if (shouldInstall) {
|
|
168
|
-
const result = await installPlaywrightBrowsers();
|
|
169
|
-
if (!result.success) {
|
|
170
|
-
console.log(chalk.yellow(' Auto-install failed. Continuing in HTTP-only mode.\n'));
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
} else {
|
|
174
|
-
const exeName = launchOpts.executablePath?.split(/[/\\]/).pop() ?? 'chromium';
|
|
175
|
-
console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${exeName})`));
|
|
176
|
-
}
|
|
126
|
+
const rt = Date.now() - t0;
|
|
127
|
+
const contentType = res.headers.get('content-type') || '';
|
|
128
|
+
const hdrs = {};
|
|
129
|
+
res.headers.forEach((v, k) => { hdrs[k] = v; });
|
|
130
|
+
|
|
131
|
+
let body = '', bodySize = 0;
|
|
132
|
+
try { body = await res.text(); bodySize = new TextEncoder().encode(body).length; } catch {}
|
|
177
133
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.#interactor = new BrowserInteractor(playwright, this.#session);
|
|
181
|
-
this.#apiValidator = new RealAPIValidator(this.#session);
|
|
182
|
-
this.#security = new SecurityScanner(this.#session);
|
|
183
|
-
this.#performance = new PerformanceProfiler(this.#session);
|
|
184
|
-
this.#a11y = new AccessibilityChecker(playwright, this.#session);
|
|
185
|
-
this.#seo = new SEOScanner(this.#session);
|
|
186
|
-
this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
|
|
187
|
-
this.#aiClassifier = new AIClassifier();
|
|
134
|
+
let parsed = null;
|
|
135
|
+
if (contentType.includes('json')) { try { parsed = JSON.parse(body); } catch {} }
|
|
188
136
|
|
|
189
|
-
|
|
190
|
-
|
|
137
|
+
return {
|
|
138
|
+
ok: res.status >= 200 && res.status < 400,
|
|
139
|
+
status: res.status, contentType, headers: hdrs,
|
|
140
|
+
body: body.slice(0, 3000), parsed, bodySize,
|
|
141
|
+
responseTime: rt, url, method, error: null,
|
|
142
|
+
};
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false, status: 0, contentType: '', headers: {},
|
|
146
|
+
body: '', parsed: null, bodySize: 0,
|
|
147
|
+
responseTime: Date.now() - t0, url, method,
|
|
148
|
+
error: err.message,
|
|
149
|
+
};
|
|
191
150
|
}
|
|
151
|
+
}
|
|
192
152
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
154
|
+
// PLAYWRIGHT REAL BROWSER ENGINE
|
|
155
|
+
// - Real browser rendering (Chromium)
|
|
156
|
+
// - Console error capture
|
|
157
|
+
// - Network request interception
|
|
158
|
+
// - Real Web Vitals (LCP, FCP, CLS, TBT)
|
|
159
|
+
// - Screenshot capture
|
|
160
|
+
// - DOM interaction tests
|
|
161
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
162
|
+
async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
163
|
+
const chromium = await getPlaywright();
|
|
164
|
+
if (!chromium) {
|
|
165
|
+
dash?.log(chalk.yellow(' ⚠ Playwright not found. Run: npm install playwright && npx playwright install chromium'));
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
196
168
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
169
|
+
dash?.log(chalk.cyan(` 🎭 Playwright browser launching for ${url}...`));
|
|
170
|
+
|
|
171
|
+
let browser, context, page;
|
|
172
|
+
const results = {
|
|
173
|
+
consoleErrors : [],
|
|
174
|
+
networkFails : [],
|
|
175
|
+
screenshots : [],
|
|
176
|
+
vitals : {},
|
|
177
|
+
interactions : [],
|
|
178
|
+
domChecks : [],
|
|
179
|
+
jsErrors : [],
|
|
180
|
+
networkRequests: [],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
browser = await chromium.launch({
|
|
185
|
+
headless: options.headless !== false,
|
|
186
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
|
187
|
+
});
|
|
200
188
|
|
|
201
|
-
|
|
202
|
-
|
|
189
|
+
context = await browser.newContext({
|
|
190
|
+
viewport: { width: 1280, height: 900 },
|
|
191
|
+
userAgent: 'Backlist-QA/13.0 (Playwright)',
|
|
192
|
+
ignoreHTTPSErrors: true,
|
|
193
|
+
recordVideo: options.recordVideo ? { dir: SCREENSHOT_DIR } : undefined,
|
|
194
|
+
});
|
|
203
195
|
|
|
204
|
-
|
|
205
|
-
await this.#phaseBrowserInteractions();
|
|
196
|
+
page = await context.newPage();
|
|
206
197
|
|
|
207
|
-
|
|
208
|
-
|
|
198
|
+
// ── Capture console messages ─────────────────────────────────────────
|
|
199
|
+
page.on('console', (msg) => {
|
|
200
|
+
const type = msg.type();
|
|
201
|
+
const text = msg.text();
|
|
202
|
+
if (['error', 'warning'].includes(type)) {
|
|
203
|
+
const entry = { type, text, timestamp: Date.now(), url: page.url() };
|
|
204
|
+
results.consoleErrors.push(entry);
|
|
205
|
+
session.consoleErrors.push(entry);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
209
208
|
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
// ── Capture JS errors ────────────────────────────────────────────────
|
|
210
|
+
page.on('pageerror', (err) => {
|
|
211
|
+
const entry = { message: err.message, stack: err.stack, url: page.url(), timestamp: Date.now() };
|
|
212
|
+
results.jsErrors.push(entry);
|
|
213
|
+
session.consoleErrors.push({ type: 'pageerror', text: err.message, url: page.url() });
|
|
214
|
+
});
|
|
212
215
|
|
|
213
|
-
|
|
214
|
-
|
|
216
|
+
// ── Network monitoring ───────────────────────────────────────────────
|
|
217
|
+
const requestTimings = new Map();
|
|
218
|
+
page.on('request', (req) => {
|
|
219
|
+
requestTimings.set(req.url(), Date.now());
|
|
220
|
+
});
|
|
221
|
+
page.on('requestfailed', (req) => {
|
|
222
|
+
const entry = {
|
|
223
|
+
url : req.url(),
|
|
224
|
+
method : req.method(),
|
|
225
|
+
failure : req.failure()?.errorText || 'unknown',
|
|
226
|
+
timestamp: Date.now(),
|
|
227
|
+
};
|
|
228
|
+
results.networkFails.push(entry);
|
|
229
|
+
session.networkLog.push(entry);
|
|
230
|
+
});
|
|
231
|
+
page.on('response', (res) => {
|
|
232
|
+
const start = requestTimings.get(res.url()) || Date.now();
|
|
233
|
+
const duration = Date.now() - start;
|
|
234
|
+
const entry = {
|
|
235
|
+
url : res.url(),
|
|
236
|
+
status : res.status(),
|
|
237
|
+
duration,
|
|
238
|
+
size : parseInt(res.headers()['content-length'] || '0'),
|
|
239
|
+
type : res.headers()['content-type'] || '',
|
|
240
|
+
};
|
|
241
|
+
results.networkRequests.push(entry);
|
|
242
|
+
if (res.status() >= 400) {
|
|
243
|
+
results.networkFails.push({ url: res.url(), status: res.status(), duration });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
215
246
|
|
|
216
|
-
|
|
217
|
-
|
|
247
|
+
// ── Navigate ─────────────────────────────────────────────────────────
|
|
248
|
+
const navStart = Date.now();
|
|
249
|
+
const response = await page.goto(url, {
|
|
250
|
+
waitUntil: 'networkidle',
|
|
251
|
+
timeout : 30000,
|
|
252
|
+
}).catch(err => ({ error: err.message }));
|
|
253
|
+
const navDuration = Date.now() - navStart;
|
|
254
|
+
|
|
255
|
+
if (response?.error) {
|
|
256
|
+
dash?.log(chalk.red(` ✗ Navigation failed: ${response.error}`));
|
|
257
|
+
return { error: response.error, results };
|
|
258
|
+
}
|
|
218
259
|
|
|
219
|
-
|
|
220
|
-
|
|
260
|
+
// ── Screenshot: Desktop ──────────────────────────────────────────────
|
|
261
|
+
await fs.ensureDir(SCREENSHOT_DIR);
|
|
262
|
+
const screenshotName = `${session.id}-desktop-${shortId()}.png`;
|
|
263
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, screenshotName);
|
|
264
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
265
|
+
results.screenshots.push({ path: screenshotPath, name: screenshotName, type: 'desktop', url });
|
|
266
|
+
session.screenshots.push({ path: screenshotPath, name: screenshotName, type: 'desktop', url });
|
|
267
|
+
dash?.log(chalk.green(` 📸 Desktop screenshot: ${screenshotName}`));
|
|
268
|
+
|
|
269
|
+
// ── Screenshot: Mobile (viewport switch) ─────────────────────────────
|
|
270
|
+
await page.setViewportSize({ width: 390, height: 844 });
|
|
271
|
+
await page.waitForTimeout(500);
|
|
272
|
+
const mobileScreenshotName = `${session.id}-mobile-${shortId()}.png`;
|
|
273
|
+
const mobileScreenshotPath = path.join(SCREENSHOT_DIR, mobileScreenshotName);
|
|
274
|
+
await page.screenshot({ path: mobileScreenshotPath, fullPage: false });
|
|
275
|
+
results.screenshots.push({ path: mobileScreenshotPath, name: mobileScreenshotName, type: 'mobile', url });
|
|
276
|
+
session.screenshots.push({ path: mobileScreenshotPath, name: mobileScreenshotName, type: 'mobile', url });
|
|
277
|
+
dash?.log(chalk.green(` 📸 Mobile screenshot: ${mobileScreenshotName}`));
|
|
278
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
279
|
+
|
|
280
|
+
// ── Real Web Vitals via PerformanceObserver ───────────────────────────
|
|
281
|
+
dash?.log(chalk.cyan(' ⚡ Measuring real Web Vitals...'));
|
|
282
|
+
const vitals = await page.evaluate(() => {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
|
|
285
|
+
let clsVal = 0;
|
|
286
|
+
|
|
287
|
+
// Navigation timing (TTFB)
|
|
288
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
289
|
+
if (navEntry) v.ttfb = Math.round(navEntry.responseStart - navEntry.requestStart);
|
|
290
|
+
|
|
291
|
+
// FCP
|
|
292
|
+
const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
|
|
293
|
+
if (fcpEntry) v.fcp = Math.round(fcpEntry.startTime);
|
|
294
|
+
|
|
295
|
+
// Paint entries
|
|
296
|
+
const paintEntries = performance.getEntriesByType('paint');
|
|
297
|
+
paintEntries.forEach(entry => {
|
|
298
|
+
if (entry.name === 'first-contentful-paint') v.fcp = Math.round(entry.startTime);
|
|
299
|
+
});
|
|
221
300
|
|
|
301
|
+
// LCP Observer
|
|
302
|
+
try {
|
|
303
|
+
new PerformanceObserver((list) => {
|
|
304
|
+
const entries = list.getEntries();
|
|
305
|
+
const last = entries[entries.length - 1];
|
|
306
|
+
if (last) v.lcp = Math.round(last.startTime);
|
|
307
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
308
|
+
} catch {}
|
|
309
|
+
|
|
310
|
+
// CLS Observer
|
|
311
|
+
try {
|
|
312
|
+
new PerformanceObserver((list) => {
|
|
313
|
+
for (const entry of list.getEntries()) {
|
|
314
|
+
if (!entry.hadRecentInput) clsVal += entry.value;
|
|
315
|
+
}
|
|
316
|
+
v.cls = parseFloat(clsVal.toFixed(4));
|
|
317
|
+
}).observe({ type: 'layout-shift', buffered: true });
|
|
318
|
+
} catch {}
|
|
319
|
+
|
|
320
|
+
// Long tasks (TBT estimation)
|
|
321
|
+
try {
|
|
322
|
+
new PerformanceObserver((list) => {
|
|
323
|
+
for (const entry of list.getEntries()) {
|
|
324
|
+
if (entry.duration > 50) v.tbt += Math.round(entry.duration - 50);
|
|
325
|
+
}
|
|
326
|
+
}).observe({ type: 'longtask', buffered: true });
|
|
327
|
+
} catch {}
|
|
328
|
+
|
|
329
|
+
// Wait for all observers
|
|
330
|
+
setTimeout(() => {
|
|
331
|
+
v.cls = parseFloat(clsVal.toFixed(4));
|
|
332
|
+
resolve(v);
|
|
333
|
+
}, 2000);
|
|
334
|
+
});
|
|
335
|
+
}).catch(() => ({}));
|
|
336
|
+
|
|
337
|
+
// Merge with navigation timing
|
|
338
|
+
const navTiming = await page.evaluate(() => {
|
|
339
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
340
|
+
if (!nav) return {};
|
|
341
|
+
return {
|
|
342
|
+
ttfb : Math.round(nav.responseStart - nav.requestStart),
|
|
343
|
+
domLoad : Math.round(nav.domContentLoadedEventEnd),
|
|
344
|
+
fullLoad : Math.round(nav.loadEventEnd),
|
|
345
|
+
dnsLookup : Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
|
346
|
+
tcpConnect : Math.round(nav.connectEnd - nav.connectStart),
|
|
347
|
+
transferSize: nav.transferSize,
|
|
348
|
+
};
|
|
349
|
+
}).catch(() => ({}));
|
|
350
|
+
|
|
351
|
+
results.vitals = { ...vitals, ...navTiming, navDuration };
|
|
352
|
+
dash?.log(chalk.green(` ✓ Vitals: TTFB=${navTiming.ttfb||'?'}ms LCP=${vitals.lcp||'?'}ms FCP=${vitals.fcp||'?'}ms CLS=${vitals.cls??'?'}`));
|
|
353
|
+
|
|
354
|
+
// ── DOM Checks ───────────────────────────────────────────────────────
|
|
355
|
+
dash?.log(chalk.cyan(' 🔍 Running DOM checks...'));
|
|
356
|
+
const domChecks = await page.evaluate(() => {
|
|
357
|
+
const checks = [];
|
|
358
|
+
|
|
359
|
+
// Title
|
|
360
|
+
const title = document.title;
|
|
361
|
+
checks.push({ name: 'Page title', pass: !!title && title.length > 0, value: title?.slice(0, 80) });
|
|
362
|
+
|
|
363
|
+
// H1
|
|
364
|
+
const h1s = document.querySelectorAll('h1');
|
|
365
|
+
checks.push({ name: 'Single H1', pass: h1s.length === 1, value: `${h1s.length} H1 tags` });
|
|
366
|
+
|
|
367
|
+
// Images without alt
|
|
368
|
+
const imgs = document.querySelectorAll('img');
|
|
369
|
+
const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
|
|
370
|
+
checks.push({ name: 'Images alt text', pass: noAlt === 0, value: `${noAlt}/${imgs.length} missing alt` });
|
|
371
|
+
|
|
372
|
+
// Buttons accessible
|
|
373
|
+
const btns = document.querySelectorAll('button');
|
|
374
|
+
const noText = [...btns].filter(b => !b.textContent?.trim() && !b.getAttribute('aria-label')).length;
|
|
375
|
+
checks.push({ name: 'Buttons accessible', pass: noText === 0, value: `${noText} buttons missing label` });
|
|
376
|
+
|
|
377
|
+
// Links with href
|
|
378
|
+
const links = document.querySelectorAll('a');
|
|
379
|
+
const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
|
|
380
|
+
checks.push({ name: 'Links have href', pass: noHref === 0, value: `${noHref}/${links.length} empty links` });
|
|
381
|
+
|
|
382
|
+
// Forms with submit
|
|
383
|
+
const forms = document.querySelectorAll('form');
|
|
384
|
+
const noSubmit = [...forms].filter(f => !f.querySelector('[type="submit"], button')).length;
|
|
385
|
+
checks.push({ name: 'Forms have submit', pass: noSubmit === 0 || forms.length === 0, value: `${forms.length} forms` });
|
|
386
|
+
|
|
387
|
+
// Meta viewport
|
|
388
|
+
const vp = document.querySelector('meta[name="viewport"]');
|
|
389
|
+
checks.push({ name: 'Viewport meta', pass: !!vp, value: vp?.content || 'missing' });
|
|
390
|
+
|
|
391
|
+
// Color contrast check (heuristic)
|
|
392
|
+
const body = document.body;
|
|
393
|
+
const bodyStyle = window.getComputedStyle(body);
|
|
394
|
+
checks.push({ name: 'Body has styles', pass: !!bodyStyle.backgroundColor || !!bodyStyle.color, value: 'CSS applied' });
|
|
395
|
+
|
|
396
|
+
// Broken internal links check
|
|
397
|
+
const internalLinks = [...links].filter(l => {
|
|
398
|
+
try { return new URL(l.href).origin === window.location.origin; } catch { return false; }
|
|
399
|
+
});
|
|
400
|
+
checks.push({ name: 'Internal links count', pass: true, value: `${internalLinks.length} internal links` });
|
|
401
|
+
|
|
402
|
+
return checks;
|
|
403
|
+
}).catch(() => []);
|
|
404
|
+
|
|
405
|
+
results.domChecks = domChecks;
|
|
406
|
+
dash?.log(chalk.green(` ✓ DOM: ${domChecks.filter(c => c.pass).length}/${domChecks.length} checks passed`));
|
|
407
|
+
|
|
408
|
+
// ── Interaction Tests ────────────────────────────────────────────────
|
|
409
|
+
dash?.log(chalk.cyan(' 🖱️ Testing interactions...'));
|
|
410
|
+
const interactions = [];
|
|
411
|
+
|
|
412
|
+
// Test all clickable buttons
|
|
413
|
+
const buttonCount = await page.locator('button:visible').count().catch(() => 0);
|
|
414
|
+
interactions.push({ name: 'Visible buttons found', pass: true, value: `${buttonCount} buttons` });
|
|
415
|
+
|
|
416
|
+
// Test form inputs exist
|
|
417
|
+
const inputCount = await page.locator('input:visible').count().catch(() => 0);
|
|
418
|
+
interactions.push({ name: 'Form inputs found', pass: true, value: `${inputCount} inputs` });
|
|
419
|
+
|
|
420
|
+
// Test scroll behavior
|
|
421
|
+
try {
|
|
422
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
423
|
+
await page.waitForTimeout(300);
|
|
424
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
425
|
+
interactions.push({ name: 'Page scroll', pass: true, value: 'Scroll works' });
|
|
222
426
|
} catch (err) {
|
|
223
|
-
|
|
224
|
-
throw err;
|
|
225
|
-
} finally {
|
|
226
|
-
this.#terminal.stop();
|
|
227
|
-
await this.#interactor.close().catch(() => {});
|
|
427
|
+
interactions.push({ name: 'Page scroll', pass: false, value: err.message });
|
|
228
428
|
}
|
|
229
429
|
|
|
230
|
-
|
|
231
|
-
|
|
430
|
+
// Test keyboard navigation (Tab key)
|
|
431
|
+
try {
|
|
432
|
+
await page.keyboard.press('Tab');
|
|
433
|
+
await page.waitForTimeout(100);
|
|
434
|
+
const focused = await page.evaluate(() => document.activeElement?.tagName || 'none');
|
|
435
|
+
interactions.push({ name: 'Keyboard navigation', pass: focused !== 'BODY', value: `Focus: ${focused}` });
|
|
436
|
+
} catch {
|
|
437
|
+
interactions.push({ name: 'Keyboard navigation', pass: false, value: 'Tab focus failed' });
|
|
438
|
+
}
|
|
232
439
|
|
|
233
|
-
|
|
234
|
-
async runPhase(name) {
|
|
235
|
-
this.#terminal.start();
|
|
440
|
+
// Hover test on first link
|
|
236
441
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
await this.#phaseBrowserInteractions();
|
|
242
|
-
await this.#phaseSecurityScan();
|
|
243
|
-
await this.#phasePerformance();
|
|
244
|
-
await this.#phaseAccessibility();
|
|
245
|
-
await this.#phaseSEO();
|
|
246
|
-
await this.#phaseAIClassification();
|
|
247
|
-
break;
|
|
248
|
-
case 'security': await this.#phaseSecurityScan(); break;
|
|
249
|
-
case 'perf': await this.#phasePerformance(); break;
|
|
250
|
-
case 'a11y': await this.#phaseAccessibility(); break;
|
|
251
|
-
case 'seo': await this.#phaseSEO(); break;
|
|
252
|
-
case 'api': await this.#phaseAPIValidation(); break;
|
|
253
|
-
default:
|
|
254
|
-
await this.run();
|
|
442
|
+
const firstLink = page.locator('a:visible').first();
|
|
443
|
+
if (await firstLink.count() > 0) {
|
|
444
|
+
await firstLink.hover();
|
|
445
|
+
interactions.push({ name: 'Link hover', pass: true, value: 'Hover works' });
|
|
255
446
|
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
await this.#interactor.close().catch(() => {});
|
|
447
|
+
} catch {
|
|
448
|
+
interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' });
|
|
259
449
|
}
|
|
260
|
-
return this.#session;
|
|
261
|
-
}
|
|
262
450
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
451
|
+
results.interactions = interactions;
|
|
452
|
+
dash?.log(chalk.green(` ✓ Interactions: ${interactions.filter(i => i.pass).length}/${interactions.length} passed`));
|
|
453
|
+
|
|
454
|
+
// ── Resource Analysis ─────────────────────────────────────────────────
|
|
455
|
+
const resourceStats = await page.evaluate(() => {
|
|
456
|
+
const entries = performance.getEntriesByType('resource');
|
|
457
|
+
const byType = {};
|
|
458
|
+
let totalSize = 0;
|
|
459
|
+
let totalTime = 0;
|
|
460
|
+
|
|
461
|
+
for (const e of entries) {
|
|
462
|
+
const t = e.initiatorType || 'other';
|
|
463
|
+
if (!byType[t]) byType[t] = { count: 0, size: 0, time: 0, slow: [] };
|
|
464
|
+
byType[t].count++;
|
|
465
|
+
byType[t].size += e.transferSize || 0;
|
|
466
|
+
byType[t].time += e.duration;
|
|
467
|
+
totalSize += e.transferSize || 0;
|
|
468
|
+
totalTime += e.duration;
|
|
469
|
+
if (e.duration > 500) {
|
|
470
|
+
byType[t].slow.push({ url: e.name.split('/').pop().slice(0, 60), duration: Math.round(e.duration), size: e.transferSize || 0 });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { byType, totalSize, totalTime: Math.round(totalTime), count: entries.length };
|
|
474
|
+
}).catch(() => ({}));
|
|
268
475
|
|
|
269
|
-
|
|
270
|
-
async #phaseDiscovery() {
|
|
271
|
-
for (const [label, url] of Object.entries(this.#session.urls)) {
|
|
272
|
-
if (!url) continue;
|
|
273
|
-
this.#terminal.log(`Crawling ${label}: ${url}`);
|
|
274
|
-
|
|
275
|
-
const routes = await this.#crawler.crawl(url, {
|
|
276
|
-
maxPages: 60,
|
|
277
|
-
maxDepth: 4,
|
|
278
|
-
onRoute : (route) => {
|
|
279
|
-
this.#session.routeMap.push(route);
|
|
280
|
-
this.#terminal.log(` Found: ${route.url} (${route.type})`);
|
|
281
|
-
},
|
|
282
|
-
});
|
|
476
|
+
results.resourceStats = resourceStats;
|
|
283
477
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
url, label,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
478
|
+
return { results, navDuration, error: null };
|
|
479
|
+
|
|
480
|
+
} catch (err) {
|
|
481
|
+
dash?.log(chalk.red(` ✗ Playwright error: ${err.message}`));
|
|
482
|
+
return { error: err.message, results };
|
|
483
|
+
} finally {
|
|
484
|
+
try { await page?.close(); } catch {}
|
|
485
|
+
try { await context?.close(); } catch {}
|
|
486
|
+
try { await browser?.close(); } catch {}
|
|
296
487
|
}
|
|
488
|
+
}
|
|
297
489
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
490
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
491
|
+
// Route Crawler — real HTTP crawl
|
|
492
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
+
async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
494
|
+
const visited = new Set();
|
|
495
|
+
const queue = [{ url: baseUrl, depth: 0 }];
|
|
496
|
+
const routes = [];
|
|
497
|
+
|
|
498
|
+
const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
|
|
499
|
+
const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
|
|
500
|
+
|
|
501
|
+
while (queue.length > 0 && routes.length < maxPages) {
|
|
502
|
+
const { url, depth } = queue.shift();
|
|
503
|
+
const n = norm(url);
|
|
504
|
+
if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
|
|
505
|
+
visited.add(n);
|
|
506
|
+
|
|
507
|
+
const r = await httpProbe(n, { timeout: 10000 });
|
|
508
|
+
const type = (() => {
|
|
509
|
+
if (r.status >= 400) return 'error-page';
|
|
510
|
+
if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
|
|
511
|
+
if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
|
|
512
|
+
if (/\/(login|signin|auth)/i.test(n)) return 'auth';
|
|
513
|
+
if (/\/(admin)/i.test(n)) return 'admin';
|
|
514
|
+
return 'page';
|
|
515
|
+
})();
|
|
516
|
+
|
|
517
|
+
const links = [];
|
|
518
|
+
if (r.contentType.includes('text/html')) {
|
|
519
|
+
const re = /href=["']([^"'#?][^"']*?)["']/gi;
|
|
520
|
+
let m;
|
|
521
|
+
while ((m = re.exec(r.body)) !== null) {
|
|
522
|
+
try { links.push(new URL(m[1], n).toString()); } catch {}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
303
525
|
|
|
304
|
-
|
|
526
|
+
const forms = [];
|
|
527
|
+
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
528
|
+
let fm;
|
|
529
|
+
while ((fm = formRe.exec(r.body)) !== null) {
|
|
530
|
+
const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
|
|
531
|
+
const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
|
|
532
|
+
const fields = [];
|
|
533
|
+
const ir = /<input([^>]*)>/gi; let inp;
|
|
534
|
+
while ((inp = ir.exec(fm[2])) !== null) {
|
|
535
|
+
const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
|
|
536
|
+
const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
|
|
537
|
+
if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
|
|
538
|
+
}
|
|
539
|
+
forms.push({ action, method, fields });
|
|
540
|
+
}
|
|
305
541
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const result = await this.#apiValidator.probe(route.url);
|
|
311
|
-
this.#session.apiLog.push(result);
|
|
312
|
-
|
|
313
|
-
this.#addResult({
|
|
314
|
-
name : `API: ${route.url}`,
|
|
315
|
-
type : 'api',
|
|
316
|
-
category: 'api-validation',
|
|
317
|
-
status : result.pass ? 'PASS' : 'FAIL',
|
|
318
|
-
message : result.message,
|
|
319
|
-
data : {
|
|
320
|
-
statusCode : result.statusCode,
|
|
321
|
-
responseTime: result.responseTime,
|
|
322
|
-
contentType : result.contentType,
|
|
323
|
-
body : result.body?.slice(0, 500),
|
|
324
|
-
headers : result.headers,
|
|
325
|
-
},
|
|
326
|
-
url : route.url,
|
|
327
|
-
duration: result.responseTime,
|
|
328
|
-
});
|
|
542
|
+
const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error };
|
|
543
|
+
routes.push(route);
|
|
544
|
+
if (onRoute) onRoute(route);
|
|
329
545
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
severity : result.statusCode >= 500 ? 'P0' : 'P1',
|
|
334
|
-
type : 'api',
|
|
335
|
-
description: result.message,
|
|
336
|
-
evidence : result,
|
|
337
|
-
});
|
|
338
|
-
}
|
|
546
|
+
for (const link of links.slice(0, 20)) {
|
|
547
|
+
const ln = norm(link);
|
|
548
|
+
if (ln && !visited.has(ln) && sameOrigin(ln)) queue.push({ url: ln, depth: depth + 1 });
|
|
339
549
|
}
|
|
550
|
+
}
|
|
340
551
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
552
|
+
// Common paths probe
|
|
553
|
+
const commonPaths = ['/api/health','/health','/api/status','/api/v1/health','/api/docs','/robots.txt','/sitemap.xml'];
|
|
554
|
+
for (const p2 of commonPaths) {
|
|
555
|
+
try {
|
|
556
|
+
const u = new URL(p2, baseUrl).toString();
|
|
557
|
+
const n = norm(u);
|
|
558
|
+
if (visited.has(n)) continue;
|
|
559
|
+
visited.add(n);
|
|
560
|
+
const r = await httpProbe(u, { timeout: 5000 });
|
|
561
|
+
if (r.status > 0 && r.status < 500) {
|
|
562
|
+
const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
|
|
563
|
+
routes.push(route);
|
|
564
|
+
if (onRoute) onRoute(route);
|
|
347
565
|
}
|
|
348
|
-
}
|
|
566
|
+
} catch {}
|
|
349
567
|
}
|
|
350
568
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const pageRoutes = this.#session.routeMap.filter(r =>
|
|
354
|
-
r.type === 'page' || r.type === 'unknown'
|
|
355
|
-
);
|
|
569
|
+
return routes;
|
|
570
|
+
}
|
|
356
571
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
572
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
573
|
+
// Security Scanner
|
|
574
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
575
|
+
async function runSecurityScan(url) {
|
|
576
|
+
const findings = [];
|
|
577
|
+
const r = await httpProbe(url);
|
|
578
|
+
|
|
579
|
+
if (!r.ok && r.status === 0) {
|
|
580
|
+
return [{
|
|
581
|
+
check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
|
|
582
|
+
detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running',
|
|
583
|
+
}];
|
|
584
|
+
}
|
|
360
585
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
586
|
+
const h = r.headers;
|
|
587
|
+
|
|
588
|
+
const headerChecks = [
|
|
589
|
+
{ id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
|
|
590
|
+
validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
|
|
591
|
+
{ id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
|
|
592
|
+
validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
|
|
593
|
+
{ id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
|
|
594
|
+
validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
|
|
595
|
+
{ id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
|
|
596
|
+
validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
|
|
597
|
+
{ id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
|
|
598
|
+
validate: v => !!v, rec: 'Add Referrer-Policy header' },
|
|
599
|
+
{ id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
|
|
600
|
+
validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
|
|
601
|
+
{ id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
|
|
602
|
+
validate: v => !v, rec: 'Remove X-Powered-By header' },
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
for (const c of headerChecks) {
|
|
606
|
+
const val = h[c.header] || '';
|
|
607
|
+
const pass = c.validate(val);
|
|
608
|
+
findings.push({
|
|
609
|
+
check: c.name, pass, severity: pass ? 'INFO' : c.sev,
|
|
610
|
+
category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
|
|
611
|
+
recommendation: c.rec, evidence: { header: c.header, value: val || null },
|
|
612
|
+
});
|
|
613
|
+
}
|
|
369
614
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
result.screenshotPath = screenshot;
|
|
377
|
-
this.#session.screenshots.push({
|
|
378
|
-
url : route.url,
|
|
379
|
-
path : screenshot,
|
|
380
|
-
reason: result.failReason,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
}
|
|
615
|
+
const isHTTPS = url.startsWith('https://');
|
|
616
|
+
findings.push({
|
|
617
|
+
check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
|
|
618
|
+
category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted',
|
|
619
|
+
recommendation: 'Use HTTPS with valid SSL', evidence: { protocol: new URL(url).protocol },
|
|
620
|
+
});
|
|
384
621
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
622
|
+
const corsOrigin = h['access-control-allow-origin'];
|
|
623
|
+
const corsCreds = h['access-control-allow-credentials'];
|
|
624
|
+
const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
|
|
625
|
+
findings.push({
|
|
626
|
+
check: 'CORS wildcard + credentials', pass: corsPass,
|
|
627
|
+
severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
628
|
+
detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
|
|
629
|
+
recommendation: 'Never combine CORS * with allow-credentials',
|
|
630
|
+
evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds },
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const base = new URL(url).origin;
|
|
634
|
+
const sensitives = [
|
|
635
|
+
{ path: '/.env', name: '.env exposed' },
|
|
636
|
+
{ path: '/.git/config', name: 'Git config exposed' },
|
|
637
|
+
{ path: '/phpinfo.php', name: 'phpinfo exposed' },
|
|
638
|
+
{ path: '/server-status', name: 'Apache server-status' },
|
|
639
|
+
{ path: '/actuator', name: 'Spring actuator exposed' },
|
|
640
|
+
{ path: '/graphql', name: 'GraphQL introspection' },
|
|
641
|
+
];
|
|
642
|
+
for (const s of sensitives) {
|
|
643
|
+
try {
|
|
644
|
+
const ctrl = new AbortController();
|
|
645
|
+
const timer = setTimeout(() => ctrl.abort(), 4000);
|
|
646
|
+
const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
|
|
647
|
+
clearTimeout(timer);
|
|
648
|
+
const exposed = res.status === 200;
|
|
649
|
+
findings.push({
|
|
650
|
+
check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
|
|
651
|
+
category: 'information-disclosure',
|
|
652
|
+
detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
|
|
653
|
+
recommendation: exposed ? `Block access to ${s.path} immediately` : null,
|
|
654
|
+
evidence: { url: `${base}${s.path}`, status: res.status },
|
|
407
655
|
});
|
|
656
|
+
} catch {}
|
|
657
|
+
}
|
|
408
658
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
title : `JS Error: ${err.text?.slice(0, 80)}`,
|
|
412
|
-
severity : err.type === 'error' ? 'P1' : 'P2',
|
|
413
|
-
type : 'javascript',
|
|
414
|
-
description: err.text,
|
|
415
|
-
url : route.url,
|
|
416
|
-
evidence : err,
|
|
417
|
-
});
|
|
418
|
-
}
|
|
659
|
+
return findings;
|
|
660
|
+
}
|
|
419
661
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
662
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
663
|
+
// SEO Scanner
|
|
664
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
665
|
+
async function runSEOScan(url) {
|
|
666
|
+
const t0 = Date.now();
|
|
667
|
+
const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
|
|
668
|
+
const html = r.body || '';
|
|
669
|
+
const rt = Date.now() - t0;
|
|
670
|
+
const checks = [];
|
|
671
|
+
|
|
672
|
+
const has = (p) => p.test(html);
|
|
673
|
+
const get = (p) => (html.match(p) || [])[1]?.trim() || null;
|
|
674
|
+
|
|
675
|
+
const title = get(/<title[^>]*>([^<]+)<\/title>/i);
|
|
676
|
+
checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
|
|
677
|
+
detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>', data: { title, length: title?.length },
|
|
678
|
+
recommendation: 'Add unique title (50-60 chars)' });
|
|
679
|
+
|
|
680
|
+
if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
|
|
681
|
+
severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)`,
|
|
682
|
+
recommendation: 'Keep title 30-60 chars' });
|
|
683
|
+
|
|
684
|
+
const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
|
685
|
+
|| get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
686
|
+
checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
|
|
687
|
+
detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description',
|
|
688
|
+
recommendation: 'Add meta description (120-160 chars)' });
|
|
689
|
+
|
|
690
|
+
const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
|
|
691
|
+
checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
|
|
692
|
+
detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : '1 H1 ✓',
|
|
693
|
+
recommendation: 'Use exactly one H1 per page' });
|
|
694
|
+
|
|
695
|
+
const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
|
|
696
|
+
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
|
|
697
|
+
detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
|
|
698
|
+
recommendation: 'Add viewport meta tag' });
|
|
699
|
+
|
|
700
|
+
const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
|
|
701
|
+
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
|
|
702
|
+
detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
|
|
703
|
+
|
|
704
|
+
const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
|
|
705
|
+
checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
|
|
706
|
+
detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
|
|
707
|
+
recommendation: 'Add <link rel="canonical">' });
|
|
708
|
+
|
|
709
|
+
const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
|
|
710
|
+
checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
|
|
711
|
+
detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
|
|
712
|
+
recommendation: 'Add og:title, og:description, og:image' });
|
|
713
|
+
|
|
714
|
+
const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
|
|
715
|
+
const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
|
|
716
|
+
checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
|
|
717
|
+
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
|
|
718
|
+
recommendation: 'Add alt text to all images' });
|
|
719
|
+
|
|
720
|
+
checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
|
|
721
|
+
category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
|
|
722
|
+
recommendation: 'Optimize TTFB with CDN and caching' });
|
|
723
|
+
|
|
724
|
+
const base = new URL(url).origin;
|
|
725
|
+
for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
|
|
726
|
+
try {
|
|
727
|
+
const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
|
|
728
|
+
checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
|
|
729
|
+
detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}`,
|
|
730
|
+
recommendation: `Ensure ${name} exists` });
|
|
731
|
+
} catch {
|
|
732
|
+
checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
430
735
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
736
|
+
return { pass: checks.filter(c => !c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
|
|
737
|
+
}
|
|
434
738
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
739
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
740
|
+
// Accessibility Scanner — HTML analysis
|
|
741
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
742
|
+
async function runA11yScan(url) {
|
|
743
|
+
const r = await httpProbe(url, { timeout: 12000 });
|
|
744
|
+
const html = r.body || '';
|
|
745
|
+
const violations = [], passes = [];
|
|
746
|
+
|
|
747
|
+
const checks = [
|
|
748
|
+
{ id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
|
|
749
|
+
{ id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
|
|
750
|
+
{ id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
|
|
751
|
+
{ id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
|
|
752
|
+
{ id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
|
|
753
|
+
{ id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
|
|
754
|
+
{ id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
|
|
755
|
+
{ id: 'form-labels', impact: 'critical', test: () => /<input(?![^>]*(?:aria-label|aria-labelledby|id=))[^>]*type=(?!"hidden")[^>]*>/i.test(html), pass: 'Form inputs have labels', desc: 'Form elements must have labels' },
|
|
756
|
+
];
|
|
757
|
+
|
|
758
|
+
for (const c of checks) {
|
|
759
|
+
if (c.test()) {
|
|
760
|
+
violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
|
|
761
|
+
tags: ['wcag2a'], category: 'wcag2a', nodes: 1, affectedNodes: [],
|
|
762
|
+
helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
|
|
763
|
+
} else {
|
|
764
|
+
passes.push({ id: c.id, description: c.pass, nodes: 1 });
|
|
438
765
|
}
|
|
439
766
|
}
|
|
440
767
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (!url) continue;
|
|
768
|
+
const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
|
|
769
|
+
return { pass: violations.length === 0, violations, passes, incomplete: [], score, url, mode: 'http-html-analysis' };
|
|
770
|
+
}
|
|
445
771
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
772
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
773
|
+
// AI Bug Classifier
|
|
774
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
775
|
+
const SEV_PATTERNS = {
|
|
776
|
+
P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
|
|
777
|
+
P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
|
|
778
|
+
P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
|
|
779
|
+
P3: [/warning|minor|style|typo|cosmetic/i],
|
|
780
|
+
};
|
|
781
|
+
const CAT_PATTERNS = {
|
|
782
|
+
security : /security|csp|hsts|cors|xss|injection|auth|token/i,
|
|
783
|
+
performance : /lcp|fcp|cls|ttfb|slow|timeout|render/i,
|
|
784
|
+
accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
|
|
785
|
+
seo : /title|meta|description|canonical|sitemap|robots/i,
|
|
786
|
+
api : /api|endpoint|status.*code|response|rest/i,
|
|
787
|
+
javascript : /js.*error|console.*error|uncaught|undefined|null/i,
|
|
788
|
+
network : /network|fetch|connection|request.*fail/i,
|
|
789
|
+
};
|
|
790
|
+
function classifyBug(bug) {
|
|
791
|
+
const text = `${bug.title} ${bug.description || ''}`;
|
|
792
|
+
let severity = bug.severity || 'P3', confidence = 0.7;
|
|
793
|
+
for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
|
|
794
|
+
if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.85; break; }
|
|
795
|
+
}
|
|
796
|
+
let category = bug.type || 'general';
|
|
797
|
+
for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
|
|
798
|
+
if (pat.test(text)) { category = cat; break; }
|
|
799
|
+
}
|
|
800
|
+
const recs = {
|
|
801
|
+
security : 'Review security config and run penetration test',
|
|
802
|
+
performance : 'Run Lighthouse and optimize assets/server',
|
|
803
|
+
accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
|
|
804
|
+
seo : 'Fix meta tags and submit sitemap to Search Console',
|
|
805
|
+
api : 'Check API contract and add proper error handling',
|
|
806
|
+
javascript : 'Debug in browser DevTools, add error boundaries',
|
|
807
|
+
network : 'Check CDN, server logs, network config',
|
|
808
|
+
};
|
|
809
|
+
return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
|
|
810
|
+
}
|
|
460
811
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
812
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
813
|
+
// Terminal Dashboard
|
|
814
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
815
|
+
class TerminalDashboard {
|
|
816
|
+
#session; #lines = 0; #active = false; #timer = null;
|
|
817
|
+
#phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
|
|
818
|
+
#pwMode = false;
|
|
819
|
+
|
|
820
|
+
constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
|
|
821
|
+
|
|
822
|
+
start() {
|
|
823
|
+
this.#active = true; this.#startTime = Date.now();
|
|
824
|
+
process.stdout.write('\x1b[?25l');
|
|
825
|
+
this.#render();
|
|
826
|
+
this.#timer = setInterval(() => this.#render(), 600);
|
|
474
827
|
}
|
|
475
828
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
829
|
+
stop() {
|
|
830
|
+
this.#active = false;
|
|
831
|
+
if (this.#timer) { clearInterval(this.#timer); this.#timer = null; }
|
|
832
|
+
this.#clear();
|
|
833
|
+
process.stdout.write('\x1b[?25h');
|
|
834
|
+
this.#printFinal();
|
|
835
|
+
}
|
|
480
836
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
{ name: 'FCP', value: metrics.fcp, threshold: 1800, unit: 'ms' },
|
|
489
|
-
{ name: 'TTFB', value: metrics.ttfb, threshold: 800, unit: 'ms' },
|
|
490
|
-
{ name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
|
|
491
|
-
];
|
|
492
|
-
|
|
493
|
-
for (const vital of vitals) {
|
|
494
|
-
const na = vital.value === null || vital.value === undefined;
|
|
495
|
-
const pass = !na && vital.value <= vital.threshold;
|
|
496
|
-
|
|
497
|
-
this.#addResult({
|
|
498
|
-
name : `[${label}] ${vital.name} — Core Web Vital`,
|
|
499
|
-
type : 'performance',
|
|
500
|
-
category: 'web-vitals',
|
|
501
|
-
status : na ? 'SKIP' : (pass ? 'PASS' : 'FAIL'),
|
|
502
|
-
message : na
|
|
503
|
-
? `${vital.name} not measurable (HTTP-only mode)`
|
|
504
|
-
: `${vital.name}: ${vital.value}${vital.unit} (threshold: ≤${vital.threshold}${vital.unit})`,
|
|
505
|
-
data : { value: vital.value, threshold: vital.threshold },
|
|
506
|
-
url, label,
|
|
507
|
-
duration: vital.value,
|
|
508
|
-
});
|
|
837
|
+
setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
|
|
838
|
+
setCurrentTest(t) { this.#currentTest = t; }
|
|
839
|
+
addResult() { this.#currentTest = ''; }
|
|
840
|
+
log(msg) {
|
|
841
|
+
this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
|
|
842
|
+
if (this.#log.length > 8) this.#log.shift();
|
|
843
|
+
}
|
|
509
844
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
evidence : { value: vital.value, threshold: vital.threshold },
|
|
518
|
-
recommendation: `Optimize ${vital.name} — see https://web.dev/vitals`,
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
845
|
+
#render() {
|
|
846
|
+
if (!this.#active) return;
|
|
847
|
+
this.#clear();
|
|
848
|
+
const lines = this.#build();
|
|
849
|
+
this.#lines = lines.length;
|
|
850
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
851
|
+
}
|
|
522
852
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
status : 'FAIL',
|
|
529
|
-
message : `${resource.url} took ${resource.duration}ms (${formatBytes(resource.size)})`,
|
|
530
|
-
data : resource,
|
|
531
|
-
url, label,
|
|
532
|
-
duration: resource.duration,
|
|
533
|
-
});
|
|
534
|
-
}
|
|
853
|
+
#clear() {
|
|
854
|
+
if (this.#lines > 0) {
|
|
855
|
+
process.stdout.write(`\x1b[${this.#lines}A`);
|
|
856
|
+
for (let i = 0; i < this.#lines; i++) process.stdout.write('\x1b[2K\n');
|
|
857
|
+
process.stdout.write(`\x1b[${this.#lines}A`);
|
|
535
858
|
}
|
|
859
|
+
this.#lines = 0;
|
|
536
860
|
}
|
|
537
861
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
}
|
|
862
|
+
#build() {
|
|
863
|
+
const s = this.#session;
|
|
864
|
+
const elapsed = ((Date.now() - this.#startTime) / 1000).toFixed(1);
|
|
865
|
+
const passed = s.results.filter(r => r.status === 'PASS' || r.status === 'FLAKY').length;
|
|
866
|
+
const failed = s.results.filter(r => r.status === 'FAIL').length;
|
|
867
|
+
const total = s.results.length;
|
|
868
|
+
const rate = total > 0 ? Math.round(passed / total * 100) : 0;
|
|
869
|
+
const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
|
|
870
|
+
const w = Math.min(process.stdout.columns || 80, 88);
|
|
871
|
+
const bar = '─'.repeat(w - 2);
|
|
872
|
+
const c1 = chalk.hex('#00F5FF');
|
|
873
|
+
const c2 = chalk.hex('#BF40FF');
|
|
874
|
+
const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
|
|
875
|
+
const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 PLAYWRIGHT') : chalk.gray(' HTTP');
|
|
876
|
+
|
|
877
|
+
const pBar = (() => {
|
|
878
|
+
const f = Math.min(Math.round(rate / 100 * 26), 26);
|
|
879
|
+
const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
|
|
880
|
+
return col('█'.repeat(f)) + chalk.gray('░'.repeat(26 - f));
|
|
881
|
+
})();
|
|
882
|
+
|
|
883
|
+
const out = [
|
|
884
|
+
c1(`┌${bar}┐`),
|
|
885
|
+
c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — REAL BROWSER TESTING${pwTag}`)) + c1('│'),
|
|
886
|
+
c1(`├${bar}┤`),
|
|
887
|
+
c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
|
|
888
|
+
c1(`├${bar}┤`),
|
|
889
|
+
c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
|
|
890
|
+
c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1('│'),
|
|
891
|
+
c1(`├${bar}┤`),
|
|
892
|
+
c1('│') + pad(this.#currentTest ? ` ${chalk.yellow('⟳')} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Running...')}`) + c1('│'),
|
|
893
|
+
c1(`├${bar}┤`),
|
|
894
|
+
c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('Screenshots:')} ${chalk.white(s.screenshots.length)} ${chalk.cyan('Bugs:')} ${chalk.white(s.bugs.length)} ${chalk.cyan('Net Errors:')} ${chalk.white(s.networkLog.length)}`) + c1('│'),
|
|
895
|
+
c1(`├${bar}┤`),
|
|
896
|
+
];
|
|
897
|
+
|
|
898
|
+
const recent = s.results.slice(-5);
|
|
899
|
+
for (const r of recent) {
|
|
900
|
+
const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
|
|
901
|
+
out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(12) + ']')} ${chalk.white((r.name||'').slice(0, w - 30))}`) + c1('│'));
|
|
902
|
+
}
|
|
903
|
+
for (let i = recent.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
582
904
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
type : 'accessibility',
|
|
587
|
-
category: 'wcag',
|
|
588
|
-
status : 'PASS',
|
|
589
|
-
message : `${pass.nodes} element(s) verified`,
|
|
590
|
-
data : pass,
|
|
591
|
-
url : route.url,
|
|
592
|
-
});
|
|
593
|
-
}
|
|
905
|
+
out.push(c1(`├${bar}┤`));
|
|
906
|
+
for (const entry of this.#log.slice(-4)) {
|
|
907
|
+
out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
|
|
594
908
|
}
|
|
909
|
+
for (let i = this.#log.length; i < 4; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
910
|
+
out.push(c1(`└${bar}┘`));
|
|
911
|
+
out.push(chalk.dim(` Real browser data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
|
|
912
|
+
|
|
913
|
+
return out;
|
|
595
914
|
}
|
|
596
915
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
916
|
+
#printFinal() {
|
|
917
|
+
const s = this.#session.getSummary();
|
|
918
|
+
const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
|
|
919
|
+
console.log('');
|
|
920
|
+
console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
|
|
921
|
+
console.log(` Tests: ${chalk.white.bold(s.total)}`);
|
|
922
|
+
console.log(` Passed: ${chalk.green.bold(s.passed)}`);
|
|
923
|
+
console.log(` Failed: ${chalk.red.bold(s.failed)}`);
|
|
924
|
+
console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
|
|
925
|
+
console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
|
|
926
|
+
console.log(` Screenshots: ${chalk.white(this.#session.screenshots.length)}`);
|
|
927
|
+
console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
|
|
928
|
+
console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Playwright (Real Browser)') : chalk.gray('HTTP-only')}`);
|
|
929
|
+
console.log('');
|
|
930
|
+
}
|
|
931
|
+
}
|
|
602
932
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
message : check.detail,
|
|
617
|
-
data : check.data,
|
|
618
|
-
url : route.url,
|
|
619
|
-
severity: check.severity,
|
|
620
|
-
});
|
|
933
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
934
|
+
// HTML Report Builder — v13, Dark Theme, Screenshot Gallery + Vitals
|
|
935
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
936
|
+
function buildHTMLReport(session) {
|
|
937
|
+
const summary = session.getSummary();
|
|
938
|
+
const passRate = Number(summary.passRate);
|
|
939
|
+
const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
|
|
940
|
+
|
|
941
|
+
const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
942
|
+
session.bugs.forEach(b => {
|
|
943
|
+
const key = b.aiSeverity || b.severity;
|
|
944
|
+
if (sevCounts[key] !== undefined) sevCounts[key]++;
|
|
945
|
+
});
|
|
621
946
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
description : check.detail,
|
|
628
|
-
url : route.url,
|
|
629
|
-
recommendation: check.recommendation,
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
947
|
+
const coverage = {};
|
|
948
|
+
for (const r of session.results) {
|
|
949
|
+
if (!coverage[r.type]) coverage[r.type] = { pass: 0, fail: 0 };
|
|
950
|
+
if (r.status === 'PASS' || r.status === 'FLAKY') coverage[r.type].pass++;
|
|
951
|
+
else if (r.status === 'FAIL') coverage[r.type].fail++;
|
|
634
952
|
}
|
|
635
953
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
954
|
+
const esc = (s) => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
955
|
+
|
|
956
|
+
// ── Screenshot gallery ───────────────────────────────────────────────────
|
|
957
|
+
const screenshotCards = session.screenshots.length
|
|
958
|
+
? session.screenshots.map(sc => {
|
|
959
|
+
// Embed screenshot as base64 if possible, else show path
|
|
960
|
+
let imgTag = '';
|
|
961
|
+
try {
|
|
962
|
+
const data = fs.readFileSync(sc.path);
|
|
963
|
+
const b64 = data.toString('base64');
|
|
964
|
+
imgTag = `<img src="data:image/png;base64,${b64}" alt="${esc(sc.type)} screenshot" loading="lazy">`;
|
|
965
|
+
} catch {
|
|
966
|
+
imgTag = `<div class="no-img">Screenshot: ${esc(sc.name)}</div>`;
|
|
967
|
+
}
|
|
968
|
+
return `
|
|
969
|
+
<div class="screenshot-card">
|
|
970
|
+
<div class="sc-header">
|
|
971
|
+
<span class="sc-type">${esc(sc.type)}</span>
|
|
972
|
+
<span class="sc-url">${esc(sc.url || '')}</span>
|
|
973
|
+
</div>
|
|
974
|
+
<div class="sc-img-wrap">${imgTag}</div>
|
|
975
|
+
<div class="sc-path">${esc(sc.path)}</div>
|
|
976
|
+
</div>`;
|
|
977
|
+
}).join('')
|
|
978
|
+
: '<p class="no-data">No screenshots (Playwright not available)</p>';
|
|
979
|
+
|
|
980
|
+
// ── Test rows ─────────────────────────────────────────────────────────────
|
|
981
|
+
const testRows = session.results.map(r => `
|
|
982
|
+
<tr class="result-row" data-type="${r.type}" data-status="${r.status}">
|
|
983
|
+
<td>${esc(r.name)}</td>
|
|
984
|
+
<td><span class="badge">${r.type}</span></td>
|
|
985
|
+
<td><span class="status status-${(r.status||'').toLowerCase()}">${r.status}</span></td>
|
|
986
|
+
<td>${r.severity ? `<span class="sev sev-${(r.severity||'').toLowerCase()}">${r.severity}</span>` : '–'}</td>
|
|
987
|
+
<td>${r.duration ? formatDuration(r.duration) : '–'}</td>
|
|
988
|
+
<td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
|
|
989
|
+
</tr>`).join('');
|
|
990
|
+
|
|
991
|
+
// ── Bug cards ─────────────────────────────────────────────────────────────
|
|
992
|
+
const bugCards = session.bugs.length
|
|
993
|
+
? session.bugs.map(b => `
|
|
994
|
+
<div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}" data-severity="${b.aiSeverity||b.severity}">
|
|
995
|
+
<div class="bug-header">
|
|
996
|
+
<span class="bug-id">${esc(b.id)}</span>
|
|
997
|
+
<span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
|
|
998
|
+
<span class="badge">${b.type||'general'}</span>
|
|
999
|
+
${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
|
|
1000
|
+
</div>
|
|
1001
|
+
<div class="bug-title">${esc(b.title)}</div>
|
|
1002
|
+
${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
|
|
1003
|
+
${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
|
|
1004
|
+
${b.evidence ? `<details><summary>Evidence</summary><pre>${esc(JSON.stringify(b.evidence,null,2).slice(0,800))}</pre></details>` : ''}
|
|
1005
|
+
</div>`).join('')
|
|
1006
|
+
: '<p class="no-data">No bugs detected 🎉</p>';
|
|
1007
|
+
|
|
1008
|
+
// ── Route rows ────────────────────────────────────────────────────────────
|
|
1009
|
+
const routeRows = session.routeMap.map(r => `
|
|
1010
|
+
<tr>
|
|
1011
|
+
<td><code class="url">${esc(r.url)}</code></td>
|
|
1012
|
+
<td><span class="badge">${r.type}</span></td>
|
|
1013
|
+
<td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
|
|
1014
|
+
<td>${r.forms?.length || 0}</td>
|
|
1015
|
+
<td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
|
|
1016
|
+
</tr>`).join('');
|
|
1017
|
+
|
|
1018
|
+
// ── Security rows ─────────────────────────────────────────────────────────
|
|
1019
|
+
const secRows = session.secFindings.map(f => `
|
|
1020
|
+
<tr class="${f.pass ? '' : 'fail-row'}">
|
|
1021
|
+
<td>${esc(f.check)}</td>
|
|
1022
|
+
<td><span class="badge">${f.category}</span></td>
|
|
1023
|
+
<td><span class="status ${f.pass ? 'status-pass' : 'status-fail'}">${f.pass?'PASS':'FAIL'}</span></td>
|
|
1024
|
+
<td>${f.severity !== 'INFO' ? `<span class="sev sev-${(f.severity||'').toLowerCase()}">${f.severity}</span>` : '–'}</td>
|
|
1025
|
+
<td>${esc((f.detail||'').slice(0,120))}</td>
|
|
1026
|
+
<td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
|
|
1027
|
+
</tr>`).join('');
|
|
1028
|
+
|
|
1029
|
+
// ── SEO section ───────────────────────────────────────────────────────────
|
|
1030
|
+
const seoSection = session.seoResults.map(r => `
|
|
1031
|
+
<div class="seo-page">
|
|
1032
|
+
<div class="seo-header">
|
|
1033
|
+
<a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
1034
|
+
<span>${r.checks.filter(c=>c.pass).length}/${r.checks.length} passed</span>
|
|
1035
|
+
</div>
|
|
1036
|
+
<table>
|
|
1037
|
+
<thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr></thead>
|
|
1038
|
+
<tbody>${(r.checks||[]).map(c => `<tr>
|
|
1039
|
+
<td>${esc(c.name)}</td><td>${c.category||'–'}</td>
|
|
1040
|
+
<td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
|
|
1041
|
+
<td>${esc((c.detail||'').slice(0,100))}</td>
|
|
1042
|
+
</tr>`).join('')}</tbody>
|
|
1043
|
+
</table>
|
|
1044
|
+
</div>`).join('') || '<p class="no-data">No SEO scans</p>';
|
|
1045
|
+
|
|
1046
|
+
// ── A11y section ──────────────────────────────────────────────────────────
|
|
1047
|
+
const a11ySection = session.a11yResults.map(r => `
|
|
1048
|
+
<div class="a11y-page">
|
|
1049
|
+
<div class="a11y-header">
|
|
1050
|
+
<a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
1051
|
+
<span>Score: <strong>${r.score??'–'}%</strong></span>
|
|
1052
|
+
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
|
|
1053
|
+
</div>
|
|
1054
|
+
${(r.violations||[]).map(v => `
|
|
1055
|
+
<div class="violation impact-${v.impact}">
|
|
1056
|
+
<div class="violation-header">
|
|
1057
|
+
<span class="impact-badge">${v.impact}</span>
|
|
1058
|
+
<strong>${esc(v.description)}</strong>
|
|
1059
|
+
</div>
|
|
1060
|
+
<p>${esc(v.help)}</p>
|
|
1061
|
+
</div>`).join('') || '<p class="no-data">No violations ✓</p>'}
|
|
1062
|
+
</div>`).join('') || '<p class="no-data">No accessibility scans</p>';
|
|
1063
|
+
|
|
1064
|
+
// ── Performance section ───────────────────────────────────────────────────
|
|
1065
|
+
const vitalCard = (name, value, threshold, unit) => {
|
|
1066
|
+
const na = value === null || value === undefined;
|
|
1067
|
+
const pass2 = !na && value <= threshold;
|
|
1068
|
+
const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
|
|
1069
|
+
const color = na ? '#64748b' : pass2 ? '#22c55e' : '#ef4444';
|
|
1070
|
+
const disp = na ? 'N/A' : `${Number(value).toFixed(name==='CLS'?3:0)}${unit}`;
|
|
1071
|
+
return `<div class="vital-card ${cls}">
|
|
1072
|
+
<div class="vital-label">${name}</div>
|
|
1073
|
+
<div class="vital-value" style="color:${color}">${disp}</div>
|
|
1074
|
+
<div class="vital-threshold">≤${threshold}${unit}</div>
|
|
1075
|
+
</div>`;
|
|
1076
|
+
};
|
|
639
1077
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1078
|
+
const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => {
|
|
1079
|
+
const slowResHtml = (m.slowResources||[]).length ? `
|
|
1080
|
+
<h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
|
|
1081
|
+
<table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
|
|
1082
|
+
<tbody>${m.slowResources.map(r => `<tr>
|
|
1083
|
+
<td class="url">${esc((r.url||'').split('/').pop())}</td>
|
|
1084
|
+
<td class="fail">${r.duration}ms</td>
|
|
1085
|
+
<td>${formatBytes(r.size)}</td>
|
|
1086
|
+
</tr>`).join('')}</tbody></table>` : '';
|
|
1087
|
+
|
|
1088
|
+
const resourceTableHtml = m.resourceStats?.byType ? `
|
|
1089
|
+
<h4 style="color:#94a3b8;margin-top:1.5rem">Resource Breakdown</h4>
|
|
1090
|
+
<table><thead><tr><th>Type</th><th>Count</th><th>Total Size</th><th>Total Time</th></tr></thead>
|
|
1091
|
+
<tbody>${Object.entries(m.resourceStats.byType).map(([t, d]) => `<tr>
|
|
1092
|
+
<td><span class="badge">${esc(t)}</span></td>
|
|
1093
|
+
<td>${d.count}</td>
|
|
1094
|
+
<td>${formatBytes(d.size)}</td>
|
|
1095
|
+
<td>${Math.round(d.time)}ms</td>
|
|
1096
|
+
</tr>`).join('')}</tbody></table>` : '';
|
|
1097
|
+
|
|
1098
|
+
const domChecksHtml = m.domChecks?.length ? `
|
|
1099
|
+
<h4 style="color:#94a3b8;margin-top:1.5rem">DOM Checks</h4>
|
|
1100
|
+
<table><thead><tr><th>Check</th><th>Status</th><th>Value</th></tr></thead>
|
|
1101
|
+
<tbody>${m.domChecks.map(c => `<tr>
|
|
1102
|
+
<td>${esc(c.name)}</td>
|
|
1103
|
+
<td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
|
|
1104
|
+
<td>${esc(c.value||'')}</td>
|
|
1105
|
+
</tr>`).join('')}</tbody></table>` : '';
|
|
1106
|
+
|
|
1107
|
+
const interactionsHtml = m.interactions?.length ? `
|
|
1108
|
+
<h4 style="color:#94a3b8;margin-top:1.5rem">Interaction Tests</h4>
|
|
1109
|
+
<table><thead><tr><th>Test</th><th>Status</th><th>Value</th></tr></thead>
|
|
1110
|
+
<tbody>${m.interactions.map(i => `<tr>
|
|
1111
|
+
<td>${esc(i.name)}</td>
|
|
1112
|
+
<td><span class="status ${i.pass?'status-pass':'status-fail'}">${i.pass?'PASS':'FAIL'}</span></td>
|
|
1113
|
+
<td>${esc(i.value||'')}</td>
|
|
1114
|
+
</tr>`).join('')}</tbody></table>` : '';
|
|
1115
|
+
|
|
1116
|
+
return `
|
|
1117
|
+
<div class="perf-card">
|
|
1118
|
+
<h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Playwright</span>' : ''}</h3>
|
|
1119
|
+
<div class="vitals-grid">
|
|
1120
|
+
${vitalCard('TTFB', m.ttfb, 800, 'ms')}
|
|
1121
|
+
${vitalCard('LCP', m.lcp, 2500, 'ms')}
|
|
1122
|
+
${vitalCard('FCP', m.fcp, 1800, 'ms')}
|
|
1123
|
+
${vitalCard('CLS', m.cls, 0.1, '')}
|
|
1124
|
+
${vitalCard('TBT', m.tbt, 200, 'ms')}
|
|
1125
|
+
${vitalCard('DOM Load', m.domLoad, 3000, 'ms')}
|
|
1126
|
+
${vitalCard('DNS', m.dnsLookup, 100, 'ms')}
|
|
1127
|
+
</div>
|
|
1128
|
+
${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
|
|
1129
|
+
${slowResHtml}
|
|
1130
|
+
${resourceTableHtml}
|
|
1131
|
+
${domChecksHtml}
|
|
1132
|
+
${interactionsHtml}
|
|
1133
|
+
</div>`;
|
|
1134
|
+
}).join('') || '<p class="no-data">No performance data</p>';
|
|
1135
|
+
|
|
1136
|
+
// ── Console errors table ──────────────────────────────────────────────────
|
|
1137
|
+
const consoleSection = session.consoleErrors.length
|
|
1138
|
+
? `<table>
|
|
1139
|
+
<thead><tr><th>Type</th><th>Message</th><th>URL</th></tr></thead>
|
|
1140
|
+
<tbody>${session.consoleErrors.slice(0, 100).map(e => `<tr>
|
|
1141
|
+
<td><span class="badge">${esc(e.type)}</span></td>
|
|
1142
|
+
<td>${esc(e.text?.slice(0, 200) || '')}</td>
|
|
1143
|
+
<td class="url">${esc(e.url || '')}</td>
|
|
1144
|
+
</tr>`).join('')}</tbody>
|
|
1145
|
+
</table>`
|
|
1146
|
+
: '<p class="no-data">No console errors 🎉</p>';
|
|
1147
|
+
|
|
1148
|
+
// ── Network failures table ────────────────────────────────────────────────
|
|
1149
|
+
const networkSection = session.networkLog.length
|
|
1150
|
+
? `<table>
|
|
1151
|
+
<thead><tr><th>URL</th><th>Method</th><th>Failure</th></tr></thead>
|
|
1152
|
+
<tbody>${session.networkLog.slice(0, 100).map(e => `<tr>
|
|
1153
|
+
<td class="url">${esc(e.url || '')}</td>
|
|
1154
|
+
<td>${esc(e.method || '')}</td>
|
|
1155
|
+
<td class="fail">${esc(e.failure || e.error || `HTTP ${e.status}`)}</td>
|
|
1156
|
+
</tr>`).join('')}</tbody>
|
|
1157
|
+
</table>`
|
|
1158
|
+
: '<p class="no-data">No network failures 🎉</p>';
|
|
1159
|
+
|
|
1160
|
+
const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
|
|
1161
|
+
.map(([k,v]) => `<div class="url-card"><span class="url-label">${k}</span><a href="${esc(v)}" target="_blank">${esc(v)}</a></div>`).join('');
|
|
1162
|
+
|
|
1163
|
+
const chartTypes = JSON.stringify(Object.keys(coverage));
|
|
1164
|
+
const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
|
|
1165
|
+
const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
|
|
1166
|
+
const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
|
|
1167
|
+
const pwBadge = session.playwrightMode
|
|
1168
|
+
? '<span style="background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44;padding:3px 10px;border-radius:20px;font-size:.7rem">🎭 Playwright</span>'
|
|
1169
|
+
: '<span style="background:#1e293b;color:#64748b;padding:3px 10px;border-radius:20px;font-size:.7rem">HTTP-only</span>';
|
|
1170
|
+
|
|
1171
|
+
return `<!DOCTYPE html>
|
|
1172
|
+
<html lang="en">
|
|
1173
|
+
<head>
|
|
1174
|
+
<meta charset="UTF-8">
|
|
1175
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1176
|
+
<title>Backlist QA Report — ${esc(session.id)}</title>
|
|
1177
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
|
1178
|
+
<style>
|
|
1179
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
|
|
1180
|
+
:root{--bg:#060610;--surface:#0f0f1e;--border:#1e1e3a;--text:#e2e8f0;--dim:#4a5568;--cyan:#00f5ff;--purple:#bf40ff;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}
|
|
1181
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
1182
|
+
body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
|
|
1183
|
+
a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
|
|
1184
|
+
header{background:linear-gradient(135deg,#0a0a1a,#12122a);border-bottom:1px solid #00f5ff22;padding:1.5rem 2rem;display:flex;justify-content:space-between;align-items:flex-start;position:sticky;top:0;z-index:100;backdrop-filter:blur(10px)}
|
|
1185
|
+
.logo{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,var(--cyan),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
|
1186
|
+
.header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
|
|
1187
|
+
nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
|
|
1188
|
+
.nav-tab{padding:.75rem 1.25rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.82rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s;font-family:'Syne',sans-serif}
|
|
1189
|
+
.nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
|
|
1190
|
+
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
|
1191
|
+
.tab-panel{display:none}.tab-panel.active{display:block}
|
|
1192
|
+
.pw-banner{background:rgba(191,64,255,.08);border:1px solid #bf40ff44;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:#c084fc;display:flex;align-items:center;gap:.5rem}
|
|
1193
|
+
.real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.83rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
|
|
1194
|
+
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
|
1195
|
+
.mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
|
|
1196
|
+
.mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
|
|
1197
|
+
.ml{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
|
|
1198
|
+
.mv{font-size:1.8rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
|
|
1199
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
|
|
1200
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
1201
|
+
.card-title{font-size:.9rem;font-weight:700;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
|
1202
|
+
.chart-wrap{position:relative;height:240px}
|
|
1203
|
+
.search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
|
|
1204
|
+
.search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.83rem;flex:1;font-family:'Syne',sans-serif}
|
|
1205
|
+
table{width:100%;border-collapse:collapse;font-size:.8rem}
|
|
1206
|
+
th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em}
|
|
1207
|
+
td{padding:.45rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
|
|
1208
|
+
tr.fail-row td{background:rgba(239,68,68,.04)}
|
|
1209
|
+
.pass{color:var(--green)}.fail{color:var(--red)}
|
|
1210
|
+
.status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace}
|
|
1211
|
+
.status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}.status-flaky{background:#422006;color:#fbbf24}.status-skip{background:#1e293b;color:#94a3b8}
|
|
1212
|
+
.sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
|
|
1213
|
+
.sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
|
|
1214
|
+
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1e293b;color:#94a3b8}
|
|
1215
|
+
.pw-badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.7rem;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
|
|
1216
|
+
.url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
|
|
1217
|
+
code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
|
|
1218
|
+
pre{white-space:pre-wrap;word-break:break-all;font-size:.73rem;padding:.75rem;background:#080814;border-radius:6px;overflow-x:auto;max-height:300px;font-family:'JetBrains Mono',monospace}
|
|
1219
|
+
details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:none}
|
|
1220
|
+
.bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
|
|
1221
|
+
.bug-card:hover{border-left-color:var(--cyan)}
|
|
1222
|
+
.sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
|
|
1223
|
+
.sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
|
|
1224
|
+
.sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
|
|
1225
|
+
.bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
|
|
1226
|
+
.bug-id{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim)}
|
|
1227
|
+
.bug-title{font-weight:700;margin-bottom:.3rem}
|
|
1228
|
+
.bug-url{font-size:.75rem;margin-bottom:.3rem}
|
|
1229
|
+
.bug-rec{font-size:.78rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
|
|
1230
|
+
.ai-badge{font-size:.68rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
|
|
1231
|
+
.rec{font-size:.75rem;color:#86efac}
|
|
1232
|
+
.no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
|
|
1233
|
+
.url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
|
|
1234
|
+
.url-label{font-size:.7rem;color:var(--dim);text-transform:uppercase;min-width:90px}
|
|
1235
|
+
/* Screenshot gallery */
|
|
1236
|
+
.screenshot-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:1.25rem;margin-top:1rem}
|
|
1237
|
+
.screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:.2s}
|
|
1238
|
+
.screenshot-card:hover{border-color:var(--purple);transform:translateY(-3px);box-shadow:0 8px 32px rgba(191,64,255,.15)}
|
|
1239
|
+
.sc-header{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--border)}
|
|
1240
|
+
.sc-type{font-size:.7rem;padding:2px 8px;border-radius:4px;background:#1a1a3b;color:#c084fc;text-transform:uppercase;font-weight:700}
|
|
1241
|
+
.sc-url{font-size:.72rem;color:var(--dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;max-width:240px}
|
|
1242
|
+
.sc-img-wrap{background:#000;min-height:200px;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
|
1243
|
+
.sc-img-wrap img{width:100%;height:auto;display:block;max-height:400px;object-fit:cover}
|
|
1244
|
+
.no-img{color:var(--dim);font-style:italic;padding:2rem;text-align:center}
|
|
1245
|
+
.sc-path{font-family:'JetBrains Mono',monospace;font-size:.67rem;color:var(--dim);padding:.5rem 1rem;background:#080810}
|
|
1246
|
+
/* Vitals */
|
|
1247
|
+
.vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
|
|
1248
|
+
.vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
|
|
1249
|
+
.vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
|
|
1250
|
+
.vital-label{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
|
|
1251
|
+
.vital-threshold{font-size:.68rem;color:var(--dim);margin-top:2px}
|
|
1252
|
+
.vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
|
|
1253
|
+
.vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
|
|
1254
|
+
.vital-na{background:var(--surface)}
|
|
1255
|
+
.perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
1256
|
+
.perf-card h3{color:var(--cyan);margin-bottom:.5rem}
|
|
1257
|
+
.perf-note{font-size:.78rem;color:var(--dim);font-style:italic;margin-top:.75rem}
|
|
1258
|
+
.seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
|
|
1259
|
+
.seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.85rem}
|
|
1260
|
+
.violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
|
|
1261
|
+
.impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
|
|
1262
|
+
.impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
|
|
1263
|
+
.impact-moderate{border-left-color:#3b82f6;background:rgba(59,130,246,.05)}
|
|
1264
|
+
.violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
|
|
1265
|
+
.impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
|
|
1266
|
+
.err-cell details{font-size:.78rem}
|
|
1267
|
+
footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem;font-family:'JetBrains Mono',monospace}
|
|
1268
|
+
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
|
|
1269
|
+
</style>
|
|
1270
|
+
</head>
|
|
1271
|
+
<body>
|
|
1272
|
+
<header>
|
|
1273
|
+
<div>
|
|
1274
|
+
<div class="logo">⚡ Backlist Enterprise QA</div>
|
|
1275
|
+
<div class="header-meta">
|
|
1276
|
+
Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
|
|
1277
|
+
</div>
|
|
1278
|
+
</div>
|
|
1279
|
+
${pwBadge}
|
|
1280
|
+
</header>
|
|
1281
|
+
|
|
1282
|
+
<nav>
|
|
1283
|
+
<button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
|
|
1284
|
+
<button class="nav-tab" onclick="showTab('screenshots',this)">📸 Screenshots (${session.screenshots.length})</button>
|
|
1285
|
+
<button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
|
|
1286
|
+
<button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
|
|
1287
|
+
<button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
|
|
1288
|
+
<button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
|
|
1289
|
+
<button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
|
|
1290
|
+
<button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
|
|
1291
|
+
<button class="nav-tab" onclick="showTab('seo',this)">🔎 SEO</button>
|
|
1292
|
+
<button class="nav-tab" onclick="showTab('console',this)">🖥️ Console (${session.consoleErrors.length})</button>
|
|
1293
|
+
<button class="nav-tab" onclick="showTab('network',this)">📡 Network</button>
|
|
1294
|
+
</nav>
|
|
1295
|
+
|
|
1296
|
+
<div class="container">
|
|
1297
|
+
|
|
1298
|
+
${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real Browser Mode</strong> — Screenshots, Web Vitals, DOM tests, Interaction tests captured from live Chromium browser</div>' : ''}
|
|
1299
|
+
<div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live application testing.</div>
|
|
1300
|
+
|
|
1301
|
+
<!-- OVERVIEW -->
|
|
1302
|
+
<div id="tab-overview" class="tab-panel active">
|
|
1303
|
+
${urlsStr ? `<div class="card"><div class="card-title">Target URLs</div>${urlsStr}</div>` : ''}
|
|
1304
|
+
<div class="metrics">
|
|
1305
|
+
<div class="mc"><div class="ml">Pass Rate</div><div class="mv" style="color:${rateColor}">${summary.passRate}%</div></div>
|
|
1306
|
+
<div class="mc"><div class="ml">Total Tests</div><div class="mv">${summary.total}</div></div>
|
|
1307
|
+
<div class="mc"><div class="ml">Passed</div><div class="mv" style="color:var(--green)">${summary.passed}</div></div>
|
|
1308
|
+
<div class="mc"><div class="ml">Failed</div><div class="mv" style="color:var(--red)">${summary.failed}</div></div>
|
|
1309
|
+
<div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
|
|
1310
|
+
<div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
|
|
1311
|
+
<div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
|
|
1312
|
+
<div class="mc"><div class="ml">Screenshots</div><div class="mv" style="color:#c084fc">${session.screenshots.length}</div></div>
|
|
1313
|
+
<div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
|
|
1314
|
+
<div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
|
|
1315
|
+
<div class="mc"><div class="ml">Console Errors</div><div class="mv" style="color:${session.consoleErrors.length>0?'var(--yellow)':'var(--green)'}">${session.consoleErrors.length}</div></div>
|
|
1316
|
+
<div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
|
|
1317
|
+
</div>
|
|
1318
|
+
<div class="grid2">
|
|
1319
|
+
<div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
|
|
1320
|
+
<div class="card"><div class="card-title">Bug Severity</div><div class="chart-wrap"><canvas id="bugChart"></canvas></div></div>
|
|
1321
|
+
</div>
|
|
1322
|
+
</div>
|
|
1323
|
+
|
|
1324
|
+
<!-- SCREENSHOTS -->
|
|
1325
|
+
<div id="tab-screenshots" class="tab-panel">
|
|
1326
|
+
<div class="card">
|
|
1327
|
+
<div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured</span></div>
|
|
1328
|
+
${session.playwrightMode ? '' : '<div class="perf-note" style="margin-bottom:1rem">⚠️ Screenshots require Playwright. Install: <code>npm install playwright && npx playwright install chromium</code></div>'}
|
|
1329
|
+
<div class="screenshot-gallery">${screenshotCards}</div>
|
|
1330
|
+
</div>
|
|
1331
|
+
</div>
|
|
1332
|
+
|
|
1333
|
+
<!-- TESTS -->
|
|
1334
|
+
<div id="tab-tests" class="tab-panel">
|
|
1335
|
+
<div class="search-bar">
|
|
1336
|
+
<input type="text" id="testSearch" placeholder="Search tests..." onkeyup="filterTests()">
|
|
1337
|
+
<select id="testStatus" onchange="filterTests()">
|
|
1338
|
+
<option value="">All statuses</option>
|
|
1339
|
+
<option value="FAIL">Failed only</option>
|
|
1340
|
+
<option value="PASS">Passed only</option>
|
|
1341
|
+
</select>
|
|
1342
|
+
<select id="testType" onchange="filterTests()">
|
|
1343
|
+
<option value="">All types</option>
|
|
1344
|
+
${[...new Set(session.results.map(r=>r.type))].map(t=>`<option value="${esc(t)}">${t}</option>`).join('')}
|
|
1345
|
+
</select>
|
|
1346
|
+
</div>
|
|
1347
|
+
<div class="card">
|
|
1348
|
+
<div class="card-title">All Test Results <span>${summary.total} tests</span></div>
|
|
1349
|
+
<table id="testTable">
|
|
1350
|
+
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Severity</th><th>Duration</th><th>Details</th></tr></thead>
|
|
1351
|
+
<tbody>${testRows || '<tr><td colspan="6" class="no-data">No tests run yet</td></tr>'}</tbody>
|
|
1352
|
+
</table>
|
|
1353
|
+
</div>
|
|
1354
|
+
</div>
|
|
1355
|
+
|
|
1356
|
+
<!-- BUGS -->
|
|
1357
|
+
<div id="tab-bugs" class="tab-panel">
|
|
1358
|
+
<div class="search-bar">
|
|
1359
|
+
<input type="text" id="bugSearch" placeholder="Search bugs..." onkeyup="filterBugs()">
|
|
1360
|
+
<select id="bugSev" onchange="filterBugs()">
|
|
1361
|
+
<option value="">All severities</option>
|
|
1362
|
+
<option value="P0">P0 Critical</option><option value="P1">P1 High</option>
|
|
1363
|
+
<option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
|
|
1364
|
+
</select>
|
|
1365
|
+
</div>
|
|
1366
|
+
<div id="bugList">${bugCards}</div>
|
|
1367
|
+
</div>
|
|
1368
|
+
|
|
1369
|
+
<!-- ROUTES -->
|
|
1370
|
+
<div id="tab-routes" class="tab-panel">
|
|
1371
|
+
<div class="card">
|
|
1372
|
+
<div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
|
|
1373
|
+
<table>
|
|
1374
|
+
<thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
|
|
1375
|
+
<tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
|
|
1376
|
+
</table>
|
|
1377
|
+
</div>
|
|
1378
|
+
</div>
|
|
1379
|
+
|
|
1380
|
+
<!-- SECURITY -->
|
|
1381
|
+
<div id="tab-security" class="tab-panel">
|
|
1382
|
+
<div class="card">
|
|
1383
|
+
<div class="card-title">Security Scan Results <span>${session.secFindings.length} checks</span></div>
|
|
1384
|
+
<table>
|
|
1385
|
+
<thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
|
|
1386
|
+
<tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
|
|
1387
|
+
</table>
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
|
|
1391
|
+
<!-- PERFORMANCE -->
|
|
1392
|
+
<div id="tab-performance" class="tab-panel">
|
|
1393
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals + Resource Analysis</div>
|
|
1394
|
+
${perfSection}
|
|
1395
|
+
</div>
|
|
1396
|
+
|
|
1397
|
+
<!-- ACCESSIBILITY -->
|
|
1398
|
+
<div id="tab-a11y" class="tab-panel">
|
|
1399
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG HTML Analysis</div>
|
|
1400
|
+
${a11ySection}
|
|
1401
|
+
</div>
|
|
1402
|
+
|
|
1403
|
+
<!-- SEO -->
|
|
1404
|
+
<div id="tab-seo" class="tab-panel">
|
|
1405
|
+
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent</div>
|
|
1406
|
+
${seoSection}
|
|
1407
|
+
</div>
|
|
1408
|
+
|
|
1409
|
+
<!-- CONSOLE -->
|
|
1410
|
+
<div id="tab-console" class="tab-panel">
|
|
1411
|
+
<div class="card">
|
|
1412
|
+
<div class="card-title">Console Errors & Warnings <span>${session.consoleErrors.length} entries</span></div>
|
|
1413
|
+
${consoleSection}
|
|
1414
|
+
</div>
|
|
1415
|
+
</div>
|
|
1416
|
+
|
|
1417
|
+
<!-- NETWORK -->
|
|
1418
|
+
<div id="tab-network" class="tab-panel">
|
|
1419
|
+
<div class="card">
|
|
1420
|
+
<div class="card-title">Network Failures <span>${session.networkLog.length} failures</span></div>
|
|
1421
|
+
${networkSection}
|
|
1422
|
+
</div>
|
|
1423
|
+
</div>
|
|
1424
|
+
|
|
1425
|
+
</div>
|
|
1426
|
+
|
|
1427
|
+
<footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${session.screenshots.length} screenshots · ${new Date().toLocaleString()}</footer>
|
|
1428
|
+
|
|
1429
|
+
<script>
|
|
1430
|
+
function showTab(name, el) {
|
|
1431
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
1432
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
1433
|
+
document.getElementById('tab-' + name)?.classList.add('active');
|
|
1434
|
+
el?.classList.add('active');
|
|
1435
|
+
}
|
|
1436
|
+
function filterTests() {
|
|
1437
|
+
const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
|
|
1438
|
+
const st = document.getElementById('testStatus')?.value||'';
|
|
1439
|
+
const ty = document.getElementById('testType')?.value||'';
|
|
1440
|
+
document.querySelectorAll('#testTable tbody .result-row').forEach(row => {
|
|
1441
|
+
row.style.display = (row.textContent.toLowerCase().includes(s) && (!st || row.dataset.status===st) && (!ty || row.dataset.type===ty)) ? '' : 'none';
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
function filterBugs() {
|
|
1445
|
+
const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
|
|
1446
|
+
const sv = document.getElementById('bugSev')?.value||'';
|
|
1447
|
+
document.querySelectorAll('#bugList .bug-card').forEach(card => {
|
|
1448
|
+
card.style.display = (card.textContent.toLowerCase().includes(s) && (!sv || card.dataset.severity===sv)) ? '' : 'none';
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
const chartCfg = {
|
|
1452
|
+
plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
|
|
1453
|
+
scales:{x:{ticks:{color:'#64748b'},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1},grid:{color:'#1e293b'},beginAtZero:true}}
|
|
1454
|
+
};
|
|
1455
|
+
new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
|
|
1456
|
+
{label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
|
|
1457
|
+
{label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
|
|
1458
|
+
]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
|
|
1459
|
+
new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}}}});
|
|
1460
|
+
</script>
|
|
1461
|
+
</body>
|
|
1462
|
+
</html>`;
|
|
1463
|
+
}
|
|
647
1464
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1465
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1466
|
+
// Main QA Runner — v13 with Playwright integration
|
|
1467
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1468
|
+
async function runQAEngine(session) {
|
|
1469
|
+
const dash = new TerminalDashboard(session);
|
|
1470
|
+
dash.start();
|
|
1471
|
+
|
|
1472
|
+
const addResult = (r) => {
|
|
1473
|
+
const result = { id: shortId(), timestamp: timestamp(), duration: 0, ...r };
|
|
1474
|
+
session.addResult(result);
|
|
1475
|
+
dash.addResult(result);
|
|
1476
|
+
return result;
|
|
1477
|
+
};
|
|
654
1478
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
message : result.message,
|
|
668
|
-
data : {
|
|
669
|
-
fields : form.fields,
|
|
670
|
-
action : form.action,
|
|
671
|
-
method : form.method,
|
|
672
|
-
validationOk: result.validationOk,
|
|
673
|
-
submissionOk: result.submissionOk,
|
|
674
|
-
errors : result.errors,
|
|
1479
|
+
try {
|
|
1480
|
+
// ── Phase 1: Discovery ───────────────────────────────────────────────
|
|
1481
|
+
dash.setPhase('🔍 Phase 1: Route Discovery & Crawling');
|
|
1482
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
1483
|
+
if (!url) continue;
|
|
1484
|
+
dash.log(`Crawling ${label}: ${url}`);
|
|
1485
|
+
const t0 = Date.now();
|
|
1486
|
+
const routes = await crawlSite(url, {
|
|
1487
|
+
maxPages: 50,
|
|
1488
|
+
onRoute: (route) => {
|
|
1489
|
+
session.routeMap.push(route);
|
|
1490
|
+
dash.log(` Found: ${route.url} (${route.type})`);
|
|
675
1491
|
},
|
|
676
|
-
url,
|
|
677
|
-
duration: result.duration,
|
|
678
1492
|
});
|
|
1493
|
+
addResult({ name: `[${label}] Route Discovery`, type: 'discovery', category: 'crawl',
|
|
1494
|
+
status: routes.length > 0 ? 'PASS' : 'FAIL',
|
|
1495
|
+
message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
|
|
1496
|
+
}
|
|
679
1497
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1498
|
+
// ── Phase 2: Playwright Real Browser Tests ───────────────────────────
|
|
1499
|
+
dash.setPhase('🎭 Phase 2: Playwright Real Browser Tests');
|
|
1500
|
+
const chromium = await getPlaywright();
|
|
1501
|
+
|
|
1502
|
+
if (chromium) {
|
|
1503
|
+
session.playwrightMode = true;
|
|
1504
|
+
dash.log(chalk.hex('#BF40FF')(' 🎭 Playwright available! Running real browser tests...'));
|
|
1505
|
+
|
|
1506
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
1507
|
+
if (!url) continue;
|
|
1508
|
+
dash.setCurrentTest(`🎭 Browser: ${url}`);
|
|
1509
|
+
dash.log(chalk.cyan(` Launching Chromium for ${label}...`));
|
|
1510
|
+
|
|
1511
|
+
const pwResult = await runPlaywrightScan(url, session, dash);
|
|
1512
|
+
|
|
1513
|
+
if (pwResult && !pwResult.error) {
|
|
1514
|
+
const { results: pw } = pwResult;
|
|
1515
|
+
|
|
1516
|
+
// Store playwright perf data merged with session
|
|
1517
|
+
session.perfMetrics[label] = {
|
|
1518
|
+
...session.perfMetrics[label],
|
|
1519
|
+
...pw.vitals,
|
|
1520
|
+
slowResources : pw.networkFails.filter(n => n.duration > 1000),
|
|
1521
|
+
resourceStats : pw.resourceStats,
|
|
1522
|
+
domChecks : pw.domChecks,
|
|
1523
|
+
interactions : pw.interactions,
|
|
1524
|
+
playwrightMode: true,
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
// Add DOM check results
|
|
1528
|
+
for (const check of pw.domChecks || []) {
|
|
1529
|
+
addResult({ name: `DOM: ${check.name}`, type: 'browser-dom', category: 'playwright',
|
|
1530
|
+
status: check.pass ? 'PASS' : 'FAIL', message: check.value, url, label });
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Add interaction results
|
|
1534
|
+
for (const interaction of pw.interactions || []) {
|
|
1535
|
+
addResult({ name: `Interaction: ${interaction.name}`, type: 'browser-interaction', category: 'playwright',
|
|
1536
|
+
status: interaction.pass ? 'PASS' : 'FAIL', message: interaction.value, url, label });
|
|
1537
|
+
if (!interaction.pass) {
|
|
1538
|
+
session.addBug({ title: `Interaction Failed: ${interaction.name}`,
|
|
1539
|
+
severity: 'P2', type: 'javascript', url, evidence: { value: interaction.value } });
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Add network failure results
|
|
1544
|
+
for (const fail of pw.networkFails || []) {
|
|
1545
|
+
addResult({ name: `Network Fail: ${fail.url?.split('/').pop()?.slice(0,40)}`, type: 'network', category: 'playwright',
|
|
1546
|
+
status: 'FAIL', message: fail.failure || `HTTP ${fail.status}`, url: fail.url, label });
|
|
1547
|
+
session.addBug({ title: `Network Failure: ${fail.url?.split('/').pop()}`,
|
|
1548
|
+
severity: fail.status >= 500 ? 'P1' : 'P2', type: 'network', url: fail.url,
|
|
1549
|
+
evidence: { status: fail.status, failure: fail.failure } });
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Add console error results
|
|
1553
|
+
for (const err of pw.jsErrors || []) {
|
|
1554
|
+
addResult({ name: `JS Error: ${err.message?.slice(0,60)}`, type: 'javascript', category: 'playwright',
|
|
1555
|
+
status: 'FAIL', message: err.message, url, label, severity: 'P2' });
|
|
1556
|
+
session.addBug({ title: `JS Error: ${err.message?.slice(0,80)}`,
|
|
1557
|
+
severity: 'P2', type: 'javascript', url, evidence: { message: err.message, stack: err.stack?.slice(0,200) } });
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Web vitals results
|
|
1561
|
+
const { lcp, fcp, cls, tbt, ttfb } = pw.vitals || {};
|
|
1562
|
+
if (ttfb !== undefined && ttfb !== null) {
|
|
1563
|
+
addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
|
|
1564
|
+
status: ttfb <= 800 ? 'PASS' : 'FAIL', message: `TTFB: ${ttfb}ms`, url, label, duration: ttfb });
|
|
1565
|
+
}
|
|
1566
|
+
if (lcp !== undefined && lcp !== null) {
|
|
1567
|
+
addResult({ name: `[${label}] LCP`, type: 'performance', category: 'web-vitals',
|
|
1568
|
+
status: lcp <= 2500 ? 'PASS' : 'FAIL', message: `LCP: ${lcp}ms (≤2500ms)`, url, label });
|
|
1569
|
+
if (lcp > 2500) session.addBug({ title: `Poor LCP: ${lcp}ms`, severity: lcp > 4000 ? 'P1' : 'P2',
|
|
1570
|
+
type: 'performance', url, evidence: { lcp }, recommendation: 'Optimize largest contentful paint' });
|
|
1571
|
+
}
|
|
1572
|
+
if (fcp !== undefined && fcp !== null) {
|
|
1573
|
+
addResult({ name: `[${label}] FCP`, type: 'performance', category: 'web-vitals',
|
|
1574
|
+
status: fcp <= 1800 ? 'PASS' : 'FAIL', message: `FCP: ${fcp}ms (≤1800ms)`, url, label });
|
|
1575
|
+
}
|
|
1576
|
+
if (cls !== undefined && cls !== null) {
|
|
1577
|
+
addResult({ name: `[${label}] CLS`, type: 'performance', category: 'web-vitals',
|
|
1578
|
+
status: cls <= 0.1 ? 'PASS' : 'FAIL', message: `CLS: ${cls} (≤0.1)`, url, label });
|
|
1579
|
+
if (cls > 0.1) session.addBug({ title: `High CLS: ${cls}`, severity: 'P2', type: 'performance',
|
|
1580
|
+
url, evidence: { cls }, recommendation: 'Fix layout shifts — set image dimensions, avoid dynamic content insertion' });
|
|
1581
|
+
}
|
|
1582
|
+
if (tbt !== undefined && tbt !== null) {
|
|
1583
|
+
addResult({ name: `[${label}] TBT`, type: 'performance', category: 'web-vitals',
|
|
1584
|
+
status: tbt <= 200 ? 'PASS' : 'FAIL', message: `TBT: ${tbt}ms (≤200ms)`, url, label });
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
addResult({ name: `[${label}] Playwright Scan`, type: 'browser', category: 'playwright',
|
|
1588
|
+
status: 'PASS', message: `${pw.screenshots?.length || 0} screenshots, ${pw.domChecks?.length || 0} DOM checks`, url, label });
|
|
1589
|
+
|
|
1590
|
+
dash.log(chalk.green(` ✅ Playwright scan complete for ${label}`));
|
|
1591
|
+
} else {
|
|
1592
|
+
dash.log(chalk.yellow(` ⚠ Playwright scan failed: ${pwResult?.error || 'unknown error'}`));
|
|
1593
|
+
addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'FAIL',
|
|
1594
|
+
message: pwResult?.error || 'Playwright scan failed', url, label });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
} else {
|
|
1598
|
+
dash.log(chalk.yellow(' ⚠ Playwright not installed. HTTP-only mode.'));
|
|
1599
|
+
dash.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
|
|
1600
|
+
// Fallback: HTTP TTFB
|
|
1601
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
1602
|
+
if (!url) continue;
|
|
1603
|
+
const t0 = Date.now();
|
|
1604
|
+
const r = await httpProbe(url, { timeout: 15000 });
|
|
1605
|
+
const ttfb = Date.now() - t0;
|
|
1606
|
+
session.perfMetrics[label] = { ttfb, bodySize: r.bodySize, statusCode: r.status,
|
|
1607
|
+
slowResources: [], note: 'Install Playwright for real Web Vitals (LCP, FCP, CLS)' };
|
|
1608
|
+
addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
|
|
1609
|
+
status: ttfb <= 800 ? 'PASS' : 'FAIL',
|
|
1610
|
+
message: `TTFB: ${ttfb}ms (threshold: ≤800ms)`, url, label, duration: ttfb });
|
|
1611
|
+
if (ttfb > 800) session.addBug({ title: `Slow TTFB: ${ttfb}ms`, severity: ttfb > 2000 ? 'P1' : 'P2',
|
|
1612
|
+
type: 'performance', url, evidence: { ttfb }, recommendation: 'Optimize server response time' });
|
|
689
1613
|
}
|
|
690
1614
|
}
|
|
691
|
-
}
|
|
692
1615
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1616
|
+
// ── Phase 3: API Validation ──────────────────────────────────────────
|
|
1617
|
+
dash.setPhase('📡 Phase 3: API Validation');
|
|
1618
|
+
const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
|
|
1619
|
+
dash.log(`Validating ${apiRoutes.length} API endpoints...`);
|
|
1620
|
+
for (const route of apiRoutes) {
|
|
1621
|
+
dash.setCurrentTest(`API: ${route.url}`);
|
|
1622
|
+
const r = await httpProbe(route.url);
|
|
1623
|
+
session.apiLog.push({ ...r, id: shortId() });
|
|
1624
|
+
addResult({ name: `API: ${route.url}`, type: 'api', category: 'api',
|
|
1625
|
+
status: r.ok ? 'PASS' : 'FAIL',
|
|
1626
|
+
message: `${r.status} ${r.ok ? 'OK' : 'FAIL'} (${r.responseTime}ms)`,
|
|
1627
|
+
url: route.url, duration: r.responseTime });
|
|
1628
|
+
if (!r.ok) session.addBug({ title: `API Failure: ${route.url}`,
|
|
1629
|
+
severity: r.status >= 500 ? 'P0' : 'P1', type: 'api',
|
|
1630
|
+
description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// ── Phase 4: Security ────────────────────────────────────────────────
|
|
1634
|
+
dash.setPhase('🛡️ Phase 4: Security Scan');
|
|
1635
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
1636
|
+
if (!url) continue;
|
|
1637
|
+
dash.setCurrentTest(`Security: ${url}`);
|
|
1638
|
+
const findings = await runSecurityScan(url);
|
|
1639
|
+
session.secFindings.push(...findings);
|
|
1640
|
+
for (const f of findings) {
|
|
1641
|
+
addResult({ name: `Security: ${f.check}`, type: 'security', category: f.category,
|
|
1642
|
+
status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
|
|
1643
|
+
if (!f.pass && ['P0','P1'].includes(f.severity)) {
|
|
1644
|
+
session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
|
|
1645
|
+
description: f.detail, url, evidence: f.evidence, recommendation: f.recommendation });
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
696
1649
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1650
|
+
// ── Phase 5: Accessibility ───────────────────────────────────────────
|
|
1651
|
+
dash.setPhase('♿ Phase 5: Accessibility Check');
|
|
1652
|
+
const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
|
|
1653
|
+
for (const route of pageRoutes) {
|
|
1654
|
+
dash.setCurrentTest(`A11y: ${route.url}`);
|
|
1655
|
+
const result = await runA11yScan(route.url);
|
|
1656
|
+
session.a11yResults.push({ url: route.url, ...result });
|
|
1657
|
+
for (const v of result.violations) {
|
|
1658
|
+
addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility', category: 'wcag',
|
|
1659
|
+
status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : v.impact === 'serious' ? 'P1' : 'P2',
|
|
1660
|
+
url: route.url });
|
|
1661
|
+
if (['critical','serious'].includes(v.impact)) session.addBug({
|
|
1662
|
+
title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
|
|
1663
|
+
type: 'accessibility', description: v.help, url: route.url, recommendation: v.helpUrl });
|
|
1664
|
+
}
|
|
1665
|
+
for (const pass of result.passes.slice(0, 3)) {
|
|
1666
|
+
addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
704
1669
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1670
|
+
// ── Phase 6: SEO ─────────────────────────────────────────────────────
|
|
1671
|
+
dash.setPhase('🔎 Phase 6: SEO Validation');
|
|
1672
|
+
const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
|
|
1673
|
+
for (const route of seoRoutes) {
|
|
1674
|
+
dash.setCurrentTest(`SEO: ${route.url}`);
|
|
1675
|
+
const result = await runSEOScan(route.url);
|
|
1676
|
+
session.seoResults.push({ url: route.url, ...result });
|
|
1677
|
+
for (const c of result.checks) {
|
|
1678
|
+
addResult({ name: `SEO: ${c.name}`, type: 'seo', category: c.category,
|
|
1679
|
+
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
|
|
1680
|
+
if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
|
|
1681
|
+
title: `SEO: ${c.name}`, severity: c.severity, type: 'seo',
|
|
1682
|
+
description: c.detail, url: route.url, recommendation: c.recommendation });
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
715
1685
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1686
|
+
// ── Phase 7: AI Classification ───────────────────────────────────────
|
|
1687
|
+
dash.setPhase('🤖 Phase 7: AI Bug Classification');
|
|
1688
|
+
dash.log(`Classifying ${session.bugs.length} bugs...`);
|
|
1689
|
+
for (const bug of session.bugs) {
|
|
1690
|
+
const cls = classifyBug(bug);
|
|
1691
|
+
bug.aiSeverity = cls.severity;
|
|
1692
|
+
bug.aiCategory = cls.category;
|
|
1693
|
+
bug.aiRecommendation = cls.recommendation;
|
|
1694
|
+
bug.aiConfidence = cls.confidence;
|
|
725
1695
|
}
|
|
726
|
-
|
|
1696
|
+
session.bugs.sort((a, b) => {
|
|
1697
|
+
const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
1698
|
+
return (o[a.aiSeverity||a.severity]||3) - (o[b.aiSeverity||b.severity]||3);
|
|
1699
|
+
});
|
|
727
1700
|
|
|
728
|
-
|
|
729
|
-
|
|
1701
|
+
} finally {
|
|
1702
|
+
dash.stop();
|
|
730
1703
|
}
|
|
731
1704
|
|
|
732
|
-
|
|
733
|
-
const r = {
|
|
734
|
-
id : shortId(),
|
|
735
|
-
timestamp: timestamp(),
|
|
736
|
-
duration : result.duration || 0,
|
|
737
|
-
...result,
|
|
738
|
-
};
|
|
739
|
-
this.#session.addResult(r);
|
|
740
|
-
this.#terminal.addResult(r);
|
|
741
|
-
this.emit('result', r);
|
|
742
|
-
return r;
|
|
743
|
-
}
|
|
1705
|
+
return session;
|
|
744
1706
|
}
|
|
745
1707
|
|
|
746
1708
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
747
|
-
//
|
|
1709
|
+
// Report Generation
|
|
748
1710
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1711
|
+
async function generateReports(session) {
|
|
1712
|
+
await fs.ensureDir(REPORT_DIR);
|
|
1713
|
+
const base = session.id.toLowerCase();
|
|
1714
|
+
const htmlPath = path.join(REPORT_DIR, `${base}.html`);
|
|
1715
|
+
const jsonPath = path.join(REPORT_DIR, `${base}.json`);
|
|
1716
|
+
const summary = session.getSummary();
|
|
1717
|
+
|
|
1718
|
+
await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
|
|
1719
|
+
await fs.writeJson(jsonPath, {
|
|
1720
|
+
meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
|
|
1721
|
+
dataSource: session.playwrightMode ? 'playwright-real-browser' : 'http-only' },
|
|
1722
|
+
urls: session.urls, summary, results: session.results, bugs: session.bugs,
|
|
1723
|
+
routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
|
|
1724
|
+
perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
|
|
1725
|
+
screenshots: session.screenshots.map(s => ({ ...s, path: undefined })), // strip paths from JSON
|
|
1726
|
+
playwrightMode: session.playwrightMode,
|
|
1727
|
+
ci: {
|
|
1728
|
+
exitCode: summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
|
|
1729
|
+
p0Bugs : session.bugs.filter(b => b.severity === 'P0').length,
|
|
1730
|
+
p1Bugs : session.bugs.filter(b => b.severity === 'P1').length,
|
|
1731
|
+
passRate: summary.passRate,
|
|
1732
|
+
},
|
|
1733
|
+
}, { spaces: 2 });
|
|
1734
|
+
|
|
1735
|
+
return { htmlPath, jsonPath };
|
|
1736
|
+
}
|
|
749
1737
|
|
|
1738
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1739
|
+
// History
|
|
1740
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
750
1741
|
export async function initQASystem() {
|
|
751
1742
|
await fs.ensureDir(QA_DIR);
|
|
752
1743
|
await fs.ensureDir(REPORT_DIR);
|
|
@@ -756,189 +1747,193 @@ export async function initQASystem() {
|
|
|
756
1747
|
}
|
|
757
1748
|
}
|
|
758
1749
|
|
|
759
|
-
|
|
760
|
-
|
|
1750
|
+
async function saveToHistory(session, htmlPath, jsonPath) {
|
|
1751
|
+
let history = { runs: [] };
|
|
1752
|
+
try { history = await fs.readJson(HISTORY_FILE); } catch {}
|
|
761
1753
|
const summary = session.getSummary();
|
|
762
1754
|
history.runs.unshift({
|
|
763
|
-
id
|
|
764
|
-
|
|
765
|
-
urls : session.urls,
|
|
766
|
-
summary,
|
|
767
|
-
version : VERSION,
|
|
768
|
-
bugCount : session.bugs.length,
|
|
1755
|
+
id: session.id, startedAt: session.startedAt, urls: session.urls,
|
|
1756
|
+
summary, version: VERSION, bugCount: session.bugs.length,
|
|
769
1757
|
screenshotCount: session.screenshots.length,
|
|
1758
|
+
playwrightMode: session.playwrightMode,
|
|
1759
|
+
htmlPath, jsonPath,
|
|
770
1760
|
});
|
|
771
1761
|
if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
|
|
772
1762
|
await fs.writeJson(HISTORY_FILE, history, { spaces: 2 });
|
|
773
1763
|
}
|
|
774
1764
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// ── URL QA entry point ────────────────────────────────────────────────────
|
|
781
|
-
export async function runUrlQA({ localUrl, stagingUrl, prodUrl, options = {} } = {}) {
|
|
1765
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1766
|
+
// Public API — runUrlQA (main entry point)
|
|
1767
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1768
|
+
export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
782
1769
|
const urls = {};
|
|
783
1770
|
if (localUrl) urls.localhost = localUrl;
|
|
784
1771
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
785
1772
|
if (prodUrl) urls.production = prodUrl;
|
|
786
1773
|
|
|
787
|
-
if (Object.keys(urls).length
|
|
788
|
-
|
|
789
|
-
|
|
1774
|
+
if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
|
|
1775
|
+
|
|
1776
|
+
// Check Playwright availability and warn
|
|
1777
|
+
const chromium = await getPlaywright();
|
|
1778
|
+
if (chromium) {
|
|
1779
|
+
console.log(chalk.hex('#BF40FF')(' 🎭 Playwright detected — Real browser mode ENABLED'));
|
|
1780
|
+
console.log(chalk.gray(' Screenshots, Web Vitals, DOM tests, Interactions will be captured'));
|
|
1781
|
+
} else {
|
|
1782
|
+
console.log(chalk.yellow(' ⚠ Playwright not found — HTTP-only mode'));
|
|
1783
|
+
console.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
|
|
1784
|
+
console.log(chalk.gray(' For real Web Vitals, screenshots, and DOM tests'));
|
|
790
1785
|
}
|
|
1786
|
+
console.log('');
|
|
791
1787
|
|
|
792
1788
|
const session = new QASession(urls);
|
|
793
|
-
|
|
1789
|
+
await runQAEngine(session);
|
|
1790
|
+
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1791
|
+
await saveToHistory(session, htmlPath, jsonPath);
|
|
794
1792
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1793
|
+
const summary = session.getSummary();
|
|
1794
|
+
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
|
|
1795
|
+
console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
|
|
1796
|
+
console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
|
|
1797
|
+
if (session.screenshots.length > 0) {
|
|
1798
|
+
console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
|
|
1799
|
+
}
|
|
798
1800
|
|
|
799
|
-
|
|
800
|
-
|
|
1801
|
+
try {
|
|
1802
|
+
const { exec } = await import('node:child_process');
|
|
1803
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1804
|
+
exec(`${cmd} "${htmlPath}"`);
|
|
1805
|
+
console.log(chalk.green(' 🌐 Report opened in browser!'));
|
|
1806
|
+
} catch {}
|
|
801
1807
|
|
|
802
1808
|
return { session, htmlPath, jsonPath };
|
|
803
1809
|
}
|
|
804
1810
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const runOnce = async () => {
|
|
1811
|
+
export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
|
|
1812
|
+
const run = async () => {
|
|
808
1813
|
const urls = {};
|
|
809
1814
|
if (localUrl) urls.localhost = localUrl;
|
|
810
1815
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
811
1816
|
if (prodUrl) urls.production = prodUrl;
|
|
812
|
-
|
|
813
|
-
if (Object.keys(urls).length === 0) {
|
|
814
|
-
console.log(chalk.yellow(' No URLs configured. Skipping URL-based tests.'));
|
|
815
|
-
}
|
|
816
|
-
|
|
817
1817
|
const session = new QASession(urls);
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
await
|
|
821
|
-
|
|
822
|
-
await saveSession(session);
|
|
823
|
-
|
|
824
|
-
const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
|
|
825
|
-
const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
|
|
826
|
-
|
|
827
|
-
const summary = session.getSummary();
|
|
828
|
-
console.log(chalk.hex('#00F5FF').bold(
|
|
829
|
-
`\n ✓ Run ${session.id} — ${summary.total} tests · ${summary.failed} failed · ` +
|
|
830
|
-
`${session.bugs.length} bugs · ${formatDuration(summary.duration)}`
|
|
831
|
-
));
|
|
832
|
-
if (htmlPath) console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
1818
|
+
await runQAEngine(session);
|
|
1819
|
+
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1820
|
+
await saveToHistory(session, htmlPath, jsonPath);
|
|
1821
|
+
console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
833
1822
|
return session;
|
|
834
1823
|
};
|
|
835
1824
|
|
|
836
|
-
if (!continuous) return
|
|
837
|
-
|
|
838
|
-
console.log(chalk.cyan(' ⚡ Continuous mode — re-runs every 60s. Ctrl+C to stop.\n'));
|
|
1825
|
+
if (!continuous) return run();
|
|
1826
|
+
console.log(chalk.cyan(' ⚡ Continuous mode — every 60s. Ctrl+C to stop.\n'));
|
|
839
1827
|
let i = 0;
|
|
840
1828
|
while (true) {
|
|
841
|
-
console.log(chalk.gray(`\n ── Run #${++i}
|
|
842
|
-
await
|
|
1829
|
+
console.log(chalk.gray(`\n ── Run #${++i} @ ${new Date().toLocaleTimeString()} ──`));
|
|
1830
|
+
await run();
|
|
843
1831
|
await sleep(60_000);
|
|
844
1832
|
}
|
|
845
1833
|
}
|
|
846
1834
|
|
|
847
|
-
// ── Manual QA ─────────────────────────────────────────────────────────────
|
|
848
1835
|
export async function runManualQA() {
|
|
849
|
-
console.log('');
|
|
850
|
-
|
|
851
1836
|
const action = await p.select({
|
|
852
|
-
message: 'Manual QA
|
|
1837
|
+
message: 'Manual QA mode:',
|
|
853
1838
|
options: [
|
|
854
|
-
{ value: 'full
|
|
855
|
-
{ value: '
|
|
856
|
-
{ value: '
|
|
857
|
-
{ value: '
|
|
858
|
-
{ value: '
|
|
859
|
-
{ value: '
|
|
1839
|
+
{ value: 'full', label: '🌐 Full Scan (All phases + Playwright)' },
|
|
1840
|
+
{ value: 'browser', label: '🎭 Browser-only (Playwright: screenshots + vitals)' },
|
|
1841
|
+
{ value: 'security', label: '🛡️ Security only' },
|
|
1842
|
+
{ value: 'seo', label: '🔎 SEO only' },
|
|
1843
|
+
{ value: 'a11y', label: '♿ Accessibility only' },
|
|
1844
|
+
{ value: 'perf', label: '⚡ Performance only' },
|
|
860
1845
|
],
|
|
861
1846
|
});
|
|
862
1847
|
if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
|
|
863
1848
|
|
|
864
|
-
const localUrl = await p.text({
|
|
865
|
-
message : 'Localhost URL:',
|
|
866
|
-
placeholder: 'http://localhost:3000',
|
|
867
|
-
});
|
|
1849
|
+
const localUrl = await p.text({ message: 'URL to test:', placeholder: 'http://localhost:3000' });
|
|
868
1850
|
if (p.isCancel(localUrl)) { p.cancel('Cancelled.'); return; }
|
|
869
1851
|
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
placeholder: 'https://yoursite.com',
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
const urls = {
|
|
876
|
-
localhost : String(localUrl).trim() || undefined,
|
|
877
|
-
production: !p.isCancel(prodUrl) ? String(prodUrl).trim() || undefined : undefined,
|
|
878
|
-
};
|
|
1852
|
+
const url = String(localUrl).trim();
|
|
1853
|
+
const sess = new QASession({ localhost: url });
|
|
879
1854
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1855
|
+
if (action === 'full') {
|
|
1856
|
+
await runQAEngine(sess);
|
|
1857
|
+
} else {
|
|
1858
|
+
const dash = new TerminalDashboard(sess);
|
|
1859
|
+
dash.start();
|
|
1860
|
+
try {
|
|
1861
|
+
if (action === 'browser') {
|
|
1862
|
+
const chromium = await getPlaywright();
|
|
1863
|
+
if (!chromium) { dash.log(chalk.red('Playwright not installed! Run: npm install playwright && npx playwright install chromium')); }
|
|
1864
|
+
else {
|
|
1865
|
+
sess.playwrightMode = true;
|
|
1866
|
+
await runPlaywrightScan(url, sess, dash);
|
|
1867
|
+
sess.perfMetrics.localhost = { ...sess.perfMetrics.localhost, playwrightMode: true };
|
|
1868
|
+
}
|
|
1869
|
+
} else if (action === 'security') {
|
|
1870
|
+
const f = await runSecurityScan(url);
|
|
1871
|
+
sess.secFindings.push(...f);
|
|
1872
|
+
f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
|
|
1873
|
+
status: finding.pass ? 'PASS' : 'FAIL', message: finding.detail, timestamp: timestamp() }));
|
|
1874
|
+
} else if (action === 'seo') {
|
|
1875
|
+
const r = await runSEOScan(url);
|
|
1876
|
+
sess.seoResults.push({ url, ...r });
|
|
1877
|
+
r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
|
|
1878
|
+
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
|
|
1879
|
+
} else if (action === 'a11y') {
|
|
1880
|
+
const r = await runA11yScan(url);
|
|
1881
|
+
sess.a11yResults.push({ url, ...r });
|
|
1882
|
+
r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
|
|
1883
|
+
status: 'FAIL', message: v.help, timestamp: timestamp() }));
|
|
1884
|
+
} else if (action === 'perf') {
|
|
1885
|
+
const chromium2 = await getPlaywright();
|
|
1886
|
+
if (chromium2) {
|
|
1887
|
+
sess.playwrightMode = true;
|
|
1888
|
+
await runPlaywrightScan(url, sess, dash);
|
|
1889
|
+
} else {
|
|
1890
|
+
const m = await (async () => {
|
|
1891
|
+
const t0 = Date.now(); const r = await httpProbe(url, { timeout: 15000 });
|
|
1892
|
+
return { ttfb: Date.now()-t0, bodySize: r.bodySize, statusCode: r.status, slowResources: [],
|
|
1893
|
+
note: 'Install Playwright for real LCP/FCP/CLS metrics' };
|
|
1894
|
+
})();
|
|
1895
|
+
sess.perfMetrics.localhost = m;
|
|
1896
|
+
sess.addResult({ id: shortId(), name: `TTFB: ${m.ttfb}ms`, type: 'performance',
|
|
1897
|
+
status: m.ttfb <= 800 ? 'PASS' : 'FAIL', message: `${m.ttfb}ms`, timestamp: timestamp() });
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
} finally { dash.stop(); }
|
|
890
1901
|
}
|
|
1902
|
+
|
|
1903
|
+
const { htmlPath } = await generateReports(sess);
|
|
1904
|
+
await saveToHistory(sess, htmlPath, '');
|
|
1905
|
+
p.outro(chalk.hex('#00F5FF').bold('✓ QA complete'));
|
|
1906
|
+
console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
1907
|
+
try {
|
|
1908
|
+
const { exec } = await import('node:child_process');
|
|
1909
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1910
|
+
exec(`${cmd} "${htmlPath}"`);
|
|
1911
|
+
} catch {}
|
|
891
1912
|
}
|
|
892
1913
|
|
|
893
|
-
|
|
894
|
-
export async function autoRunPostGeneration(options = {}) {
|
|
1914
|
+
export async function autoRunPostGeneration() {
|
|
895
1915
|
console.log('');
|
|
896
|
-
console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation
|
|
897
|
-
|
|
898
|
-
console.log('');
|
|
899
|
-
|
|
900
|
-
const url = await p.text({
|
|
901
|
-
message : 'Server URL to validate:',
|
|
902
|
-
placeholder : 'http://localhost:3000',
|
|
903
|
-
defaultValue: 'http://localhost:3000',
|
|
904
|
-
});
|
|
1916
|
+
console.log(chalk.hex('#00F5FF').bold(` ── 🔬 Post-Generation QA v${VERSION} ──`));
|
|
1917
|
+
const url = await p.text({ message: 'Server URL:', placeholder: 'http://localhost:3000', defaultValue: 'http://localhost:3000' });
|
|
905
1918
|
if (p.isCancel(url)) { p.cancel('Cancelled.'); return; }
|
|
906
|
-
|
|
907
|
-
const result = await runUrlQA({ localUrl: String(url).trim() });
|
|
908
|
-
if (result?.htmlPath) {
|
|
909
|
-
console.log(chalk.gray(` 📄 Report: ${result.htmlPath}`));
|
|
910
|
-
}
|
|
1919
|
+
await runUrlQA({ localUrl: String(url).trim() });
|
|
911
1920
|
}
|
|
912
1921
|
|
|
913
|
-
// ── View History ──────────────────────────────────────────────────────────
|
|
914
1922
|
export async function viewQAHistory() {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
console.log(chalk.yellow('\n No QA history found.\n'));
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
1923
|
+
let history = { runs: [] };
|
|
1924
|
+
try { history = await fs.readJson(HISTORY_FILE); } catch {}
|
|
920
1925
|
|
|
921
|
-
console.log('');
|
|
922
|
-
console.log(chalk.hex('#00F5FF').bold(' QA History (real runs only)'));
|
|
923
|
-
console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
|
|
1926
|
+
if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
|
|
924
1927
|
|
|
1928
|
+
console.log('');
|
|
1929
|
+
console.log(chalk.hex('#00F5FF').bold(' QA History'));
|
|
1930
|
+
console.log(chalk.gray(' ──────────────────────────────────────────────────'));
|
|
925
1931
|
for (const run of history.runs.slice(0, 15)) {
|
|
926
|
-
const rate
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
const urlStr = Object.values(run.urls || {}).filter(Boolean).join(', ');
|
|
932
|
-
|
|
933
|
-
console.log(
|
|
934
|
-
` ${chalk.gray(run.id.padEnd(14))} ` +
|
|
935
|
-
`${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(24))} ` +
|
|
936
|
-
`${color(String(rate + '%').padStart(6))} ` +
|
|
937
|
-
`${chalk.gray(String(run.summary?.total || 0) + ' tests')} ` +
|
|
938
|
-
`${chalk.cyan(bugs + ' bugs')} ` +
|
|
939
|
-
`${chalk.gray(shots + ' shots')} ` +
|
|
940
|
-
`${chalk.dim(urlStr.slice(0, 40))}`
|
|
941
|
-
);
|
|
1932
|
+
const rate = run.summary?.passRate ?? '–';
|
|
1933
|
+
const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
|
|
1934
|
+
const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
|
|
1935
|
+
const pwIcon = run.playwrightMode ? chalk.hex('#BF40FF')('🎭') : chalk.gray('⚡');
|
|
1936
|
+
console.log(` ${chalk.gray(run.id.padEnd(16))} ${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(22))} ${col((rate+'%').padStart(7))} ${chalk.cyan((run.bugCount||0)+' bugs')} ${pwIcon} ${chalk.dim(urls.slice(0,40))}`);
|
|
942
1937
|
}
|
|
943
1938
|
console.log('');
|
|
944
1939
|
|
|
@@ -946,25 +1941,23 @@ export async function viewQAHistory() {
|
|
|
946
1941
|
message: 'Open a report?',
|
|
947
1942
|
options: [
|
|
948
1943
|
...history.runs.slice(0, 8).map(r => ({
|
|
949
|
-
value: r.id,
|
|
950
|
-
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
|
|
1944
|
+
value: r.htmlPath || r.id,
|
|
1945
|
+
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs${r.playwrightMode ? ' 🎭' : ''}`,
|
|
951
1946
|
})),
|
|
952
1947
|
{ value: '__back', label: '↩ Back' },
|
|
953
1948
|
],
|
|
954
1949
|
});
|
|
955
1950
|
if (p.isCancel(chosen) || chosen === '__back') return;
|
|
956
1951
|
|
|
957
|
-
const reportPath = path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
|
|
1952
|
+
const reportPath = chosen.endsWith('.html') ? chosen : path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
|
|
958
1953
|
if (await fs.pathExists(reportPath)) {
|
|
959
1954
|
console.log(chalk.green(` 📄 Report: ${reportPath}`));
|
|
960
1955
|
try {
|
|
961
1956
|
const { exec } = await import('node:child_process');
|
|
962
|
-
const cmd = process.platform === 'darwin' ? 'open'
|
|
963
|
-
: process.platform === 'win32' ? 'start'
|
|
964
|
-
: 'xdg-open';
|
|
1957
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
965
1958
|
exec(`${cmd} "${reportPath}"`);
|
|
966
1959
|
} catch {}
|
|
967
1960
|
} else {
|
|
968
|
-
console.log(chalk.yellow(' Report file not found
|
|
1961
|
+
console.log(chalk.yellow(' Report file not found.'));
|
|
969
1962
|
}
|
|
970
|
-
}
|
|
1963
|
+
}
|