create-backlist 10.0.9 → 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 +863 -256
package/src/qa/qa-engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// Backlist Enterprise QA Engine
|
|
3
|
-
// 100% Real Runtime Testing ·
|
|
2
|
+
// Backlist Enterprise QA Engine v13.0 — PLAYWRIGHT REAL BROWSER EDITION
|
|
3
|
+
// 100% Real Runtime Testing · Live Playwright Tests · Rich HTML Reports
|
|
4
4
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
5
5
|
|
|
6
6
|
import * as p from '@clack/prompts';
|
|
@@ -13,32 +13,32 @@ import { performance } from 'node:perf_hooks';
|
|
|
13
13
|
import { EventEmitter } from 'node:events';
|
|
14
14
|
|
|
15
15
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
16
|
-
export const VERSION = '
|
|
16
|
+
export const VERSION = '13.0.0';
|
|
17
17
|
export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
|
|
18
18
|
export const REPORT_DIR = path.join(QA_DIR, 'reports');
|
|
19
19
|
export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
|
|
20
20
|
export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
|
|
21
21
|
|
|
22
22
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
23
|
-
export const timestamp
|
|
24
|
-
export const shortId
|
|
25
|
-
export const sleep
|
|
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
26
|
export const formatDuration = (ms) => {
|
|
27
27
|
if (!ms || ms < 0) return '0ms';
|
|
28
28
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
29
29
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
30
30
|
};
|
|
31
31
|
export const formatBytes = (b) => {
|
|
32
|
-
if (!b || b < 0)
|
|
33
|
-
if (b < 1024)
|
|
34
|
-
if (b < 1024 * 1024)
|
|
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`;
|
|
35
35
|
return `${(b / 1024 / 1024).toFixed(1)}MB`;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
// ──
|
|
38
|
+
// ── readline helper ───────────────────────────────────────────────────────
|
|
39
39
|
function askYesNo(question) {
|
|
40
40
|
return new Promise((resolve) => {
|
|
41
|
-
const rl
|
|
41
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
42
|
const timer = setTimeout(() => { rl.close(); resolve(false); }, 10_000);
|
|
43
43
|
rl.question(question, (ans) => {
|
|
44
44
|
clearTimeout(timer);
|
|
@@ -48,8 +48,18 @@ function askYesNo(question) {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
|
|
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
|
+
|
|
51
61
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
-
// QA Session
|
|
62
|
+
// QA Session
|
|
53
63
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
54
64
|
export class QASession {
|
|
55
65
|
id;
|
|
@@ -66,6 +76,7 @@ export class QASession {
|
|
|
66
76
|
secFindings = [];
|
|
67
77
|
a11yResults = [];
|
|
68
78
|
seoResults = [];
|
|
79
|
+
playwrightMode = false;
|
|
69
80
|
|
|
70
81
|
constructor(urls = {}) {
|
|
71
82
|
this.id = `QA-${shortId().toUpperCase()}`;
|
|
@@ -74,7 +85,13 @@ export class QASession {
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
addResult(r) { this.results.push(r); }
|
|
77
|
-
addBug(bug) {
|
|
88
|
+
addBug(bug) {
|
|
89
|
+
this.bugs.push({
|
|
90
|
+
...bug,
|
|
91
|
+
id: `BUG-${shortId().toUpperCase()}`,
|
|
92
|
+
createdAt: timestamp(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
78
95
|
|
|
79
96
|
getSummary() {
|
|
80
97
|
const passed = this.results.filter(r => r.status === 'PASS').length;
|
|
@@ -91,7 +108,7 @@ export class QASession {
|
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
-
// HTTP Probe — real HTTP requests
|
|
111
|
+
// HTTP Probe — real HTTP requests
|
|
95
112
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
113
|
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
|
|
97
114
|
const t0 = Date.now();
|
|
@@ -101,7 +118,7 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
101
118
|
const res = await fetch(url, {
|
|
102
119
|
method,
|
|
103
120
|
signal : ctrl.signal,
|
|
104
|
-
headers : { 'User-Agent': 'Backlist-QA/
|
|
121
|
+
headers : { 'User-Agent': 'Backlist-QA/13.0', Accept: '*/*', ...headers },
|
|
105
122
|
redirect: 'follow',
|
|
106
123
|
});
|
|
107
124
|
clearTimeout(timer);
|
|
@@ -121,8 +138,7 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
121
138
|
ok: res.status >= 200 && res.status < 400,
|
|
122
139
|
status: res.status, contentType, headers: hdrs,
|
|
123
140
|
body: body.slice(0, 3000), parsed, bodySize,
|
|
124
|
-
responseTime: rt, url, method,
|
|
125
|
-
error: null,
|
|
141
|
+
responseTime: rt, url, method, error: null,
|
|
126
142
|
};
|
|
127
143
|
} catch (err) {
|
|
128
144
|
return {
|
|
@@ -135,14 +151,351 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
135
151
|
}
|
|
136
152
|
|
|
137
153
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
138
|
-
//
|
|
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
|
+
}
|
|
168
|
+
|
|
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
|
+
});
|
|
188
|
+
|
|
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
|
+
});
|
|
195
|
+
|
|
196
|
+
page = await context.newPage();
|
|
197
|
+
|
|
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
|
+
});
|
|
208
|
+
|
|
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
|
+
});
|
|
215
|
+
|
|
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
|
+
});
|
|
246
|
+
|
|
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
|
+
}
|
|
259
|
+
|
|
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
|
+
});
|
|
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' });
|
|
426
|
+
} catch (err) {
|
|
427
|
+
interactions.push({ name: 'Page scroll', pass: false, value: err.message });
|
|
428
|
+
}
|
|
429
|
+
|
|
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
|
+
}
|
|
439
|
+
|
|
440
|
+
// Hover test on first link
|
|
441
|
+
try {
|
|
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' });
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' });
|
|
449
|
+
}
|
|
450
|
+
|
|
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(() => ({}));
|
|
475
|
+
|
|
476
|
+
results.resourceStats = resourceStats;
|
|
477
|
+
|
|
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 {}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
491
|
+
// Route Crawler — real HTTP crawl
|
|
139
492
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
140
493
|
async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
141
494
|
const visited = new Set();
|
|
142
495
|
const queue = [{ url: baseUrl, depth: 0 }];
|
|
143
496
|
const routes = [];
|
|
144
497
|
|
|
145
|
-
const norm
|
|
498
|
+
const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
|
|
146
499
|
const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
|
|
147
500
|
|
|
148
501
|
while (queue.length > 0 && routes.length < maxPages) {
|
|
@@ -151,17 +504,16 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
151
504
|
if (!n || visited.has(n) || !sameOrigin(n) || depth > 3) continue;
|
|
152
505
|
visited.add(n);
|
|
153
506
|
|
|
154
|
-
const r
|
|
507
|
+
const r = await httpProbe(n, { timeout: 10000 });
|
|
155
508
|
const type = (() => {
|
|
156
|
-
if (r.status >= 400)
|
|
157
|
-
if (r.contentType.includes('json') || n.includes('/api/'))
|
|
158
|
-
if (n.endsWith('.xml') || n.endsWith('.txt'))
|
|
159
|
-
if (/\/(login|signin|auth)/i.test(n))
|
|
160
|
-
if (/\/(admin)/i.test(n))
|
|
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';
|
|
161
514
|
return 'page';
|
|
162
515
|
})();
|
|
163
516
|
|
|
164
|
-
// Extract links from HTML
|
|
165
517
|
const links = [];
|
|
166
518
|
if (r.contentType.includes('text/html')) {
|
|
167
519
|
const re = /href=["']([^"'#?][^"']*?)["']/gi;
|
|
@@ -171,17 +523,16 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
171
523
|
}
|
|
172
524
|
}
|
|
173
525
|
|
|
174
|
-
|
|
175
|
-
const forms = [];
|
|
526
|
+
const forms = [];
|
|
176
527
|
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
177
528
|
let fm;
|
|
178
529
|
while ((fm = formRe.exec(r.body)) !== null) {
|
|
179
530
|
const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
|
|
180
531
|
const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
|
|
181
532
|
const fields = [];
|
|
182
|
-
const ir
|
|
533
|
+
const ir = /<input([^>]*)>/gi; let inp;
|
|
183
534
|
while ((inp = ir.exec(fm[2])) !== null) {
|
|
184
|
-
const name
|
|
535
|
+
const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
|
|
185
536
|
const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
|
|
186
537
|
if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
|
|
187
538
|
}
|
|
@@ -198,7 +549,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
198
549
|
}
|
|
199
550
|
}
|
|
200
551
|
|
|
201
|
-
//
|
|
552
|
+
// Common paths probe
|
|
202
553
|
const commonPaths = ['/api/health','/health','/api/status','/api/v1/health','/api/docs','/robots.txt','/sitemap.xml'];
|
|
203
554
|
for (const p2 of commonPaths) {
|
|
204
555
|
try {
|
|
@@ -219,62 +570,67 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
219
570
|
}
|
|
220
571
|
|
|
221
572
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
-
// Security Scanner
|
|
573
|
+
// Security Scanner
|
|
223
574
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
224
575
|
async function runSecurityScan(url) {
|
|
225
576
|
const findings = [];
|
|
226
|
-
const r
|
|
577
|
+
const r = await httpProbe(url);
|
|
227
578
|
|
|
228
579
|
if (!r.ok && r.status === 0) {
|
|
229
|
-
return [{
|
|
230
|
-
|
|
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
|
+
}];
|
|
231
584
|
}
|
|
232
585
|
|
|
233
586
|
const h = r.headers;
|
|
234
587
|
|
|
235
588
|
const headerChecks = [
|
|
236
|
-
{ id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy',
|
|
589
|
+
{ id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
|
|
237
590
|
validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
|
|
238
|
-
{ id: 'hsts', name: 'HSTS',
|
|
591
|
+
{ id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
|
|
239
592
|
validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
|
|
240
|
-
{ id: 'xframe', name: 'X-Frame-Options',
|
|
593
|
+
{ id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
|
|
241
594
|
validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
|
|
242
|
-
{ id: 'xcto', name: 'X-Content-Type-Options',
|
|
595
|
+
{ id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
|
|
243
596
|
validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
|
|
244
|
-
{ id: 'rp', name: 'Referrer-Policy',
|
|
597
|
+
{ id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
|
|
245
598
|
validate: v => !!v, rec: 'Add Referrer-Policy header' },
|
|
246
|
-
{ id: 'server', name: 'Server version hidden',
|
|
599
|
+
{ id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
|
|
247
600
|
validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
|
|
248
|
-
{ id: 'xpb', name: 'X-Powered-By hidden',
|
|
249
|
-
validate: v => !v, rec: 'Remove X-Powered-By
|
|
601
|
+
{ id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
|
|
602
|
+
validate: v => !v, rec: 'Remove X-Powered-By header' },
|
|
250
603
|
];
|
|
251
604
|
|
|
252
605
|
for (const c of headerChecks) {
|
|
253
606
|
const val = h[c.header] || '';
|
|
254
607
|
const pass = c.validate(val);
|
|
255
|
-
findings.push({
|
|
608
|
+
findings.push({
|
|
609
|
+
check: c.name, pass, severity: pass ? 'INFO' : c.sev,
|
|
256
610
|
category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
|
|
257
|
-
recommendation: c.rec, evidence: { header: c.header, value: val || null }
|
|
611
|
+
recommendation: c.rec, evidence: { header: c.header, value: val || null },
|
|
612
|
+
});
|
|
258
613
|
}
|
|
259
614
|
|
|
260
|
-
// HTTPS check
|
|
261
615
|
const isHTTPS = url.startsWith('https://');
|
|
262
|
-
findings.push({
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
});
|
|
265
621
|
|
|
266
|
-
// CORS wildcard check
|
|
267
622
|
const corsOrigin = h['access-control-allow-origin'];
|
|
268
623
|
const corsCreds = h['access-control-allow-credentials'];
|
|
269
624
|
const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
|
|
270
|
-
findings.push({
|
|
625
|
+
findings.push({
|
|
626
|
+
check: 'CORS wildcard + credentials', pass: corsPass,
|
|
271
627
|
severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
272
628
|
detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
|
|
273
629
|
recommendation: 'Never combine CORS * with allow-credentials',
|
|
274
|
-
evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds }
|
|
630
|
+
evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds },
|
|
631
|
+
});
|
|
275
632
|
|
|
276
|
-
|
|
277
|
-
const base = new URL(url).origin;
|
|
633
|
+
const base = new URL(url).origin;
|
|
278
634
|
const sensitives = [
|
|
279
635
|
{ path: '/.env', name: '.env exposed' },
|
|
280
636
|
{ path: '/.git/config', name: 'Git config exposed' },
|
|
@@ -290,11 +646,13 @@ async function runSecurityScan(url) {
|
|
|
290
646
|
const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
|
|
291
647
|
clearTimeout(timer);
|
|
292
648
|
const exposed = res.status === 200;
|
|
293
|
-
findings.push({
|
|
649
|
+
findings.push({
|
|
650
|
+
check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
|
|
294
651
|
category: 'information-disclosure',
|
|
295
652
|
detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
|
|
296
653
|
recommendation: exposed ? `Block access to ${s.path} immediately` : null,
|
|
297
|
-
evidence: { url: `${base}${s.path}`, status: res.status }
|
|
654
|
+
evidence: { url: `${base}${s.path}`, status: res.status },
|
|
655
|
+
});
|
|
298
656
|
} catch {}
|
|
299
657
|
}
|
|
300
658
|
|
|
@@ -302,13 +660,13 @@ async function runSecurityScan(url) {
|
|
|
302
660
|
}
|
|
303
661
|
|
|
304
662
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
305
|
-
// SEO Scanner
|
|
663
|
+
// SEO Scanner
|
|
306
664
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
307
665
|
async function runSEOScan(url) {
|
|
308
|
-
const t0
|
|
309
|
-
const r
|
|
666
|
+
const t0 = Date.now();
|
|
667
|
+
const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
|
|
310
668
|
const html = r.body || '';
|
|
311
|
-
const rt
|
|
669
|
+
const rt = Date.now() - t0;
|
|
312
670
|
const checks = [];
|
|
313
671
|
|
|
314
672
|
const has = (p) => p.test(html);
|
|
@@ -337,7 +695,7 @@ async function runSEOScan(url) {
|
|
|
337
695
|
const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
|
|
338
696
|
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
|
|
339
697
|
detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
|
|
340
|
-
recommendation: 'Add
|
|
698
|
+
recommendation: 'Add viewport meta tag' });
|
|
341
699
|
|
|
342
700
|
const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
|
|
343
701
|
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
|
|
@@ -353,8 +711,8 @@ async function runSEOScan(url) {
|
|
|
353
711
|
detail: ogOk ? 'OG tags present' : 'Missing og:title or og:description',
|
|
354
712
|
recommendation: 'Add og:title, og:description, og:image' });
|
|
355
713
|
|
|
356
|
-
const imgTotal
|
|
357
|
-
const imgNoAlt
|
|
714
|
+
const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
|
|
715
|
+
const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
|
|
358
716
|
checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
|
|
359
717
|
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt`,
|
|
360
718
|
recommendation: 'Add alt text to all images' });
|
|
@@ -363,7 +721,6 @@ async function runSEOScan(url) {
|
|
|
363
721
|
category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)`,
|
|
364
722
|
recommendation: 'Optimize TTFB with CDN and caching' });
|
|
365
723
|
|
|
366
|
-
// robots.txt & sitemap
|
|
367
724
|
const base = new URL(url).origin;
|
|
368
725
|
for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
|
|
369
726
|
try {
|
|
@@ -376,48 +733,11 @@ async function runSEOScan(url) {
|
|
|
376
733
|
}
|
|
377
734
|
}
|
|
378
735
|
|
|
379
|
-
return { pass: checks.filter(c
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
383
|
-
// Performance Profiler — real HTTP TTFB + resource timing
|
|
384
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
385
|
-
async function runPerfProfile(url) {
|
|
386
|
-
const t0 = Date.now();
|
|
387
|
-
const r = await httpProbe(url, { timeout: 15000 });
|
|
388
|
-
const ttfb = Date.now() - t0;
|
|
389
|
-
|
|
390
|
-
const slowResources = [];
|
|
391
|
-
if (ttfb > 3000) slowResources.push({ url, duration: ttfb, size: r.bodySize, type: 'document' });
|
|
392
|
-
|
|
393
|
-
// Parse resource hints from HTML
|
|
394
|
-
const resourceUrls = [];
|
|
395
|
-
if (r.contentType.includes('text/html')) {
|
|
396
|
-
const scriptRe = /src=["']([^"']+\.(?:js|css))["']/gi;
|
|
397
|
-
let m;
|
|
398
|
-
while ((m = scriptRe.exec(r.body)) !== null) {
|
|
399
|
-
try { resourceUrls.push(new URL(m[1], url).toString()); } catch {}
|
|
400
|
-
}
|
|
401
|
-
for (const ru of resourceUrls.slice(0, 5)) {
|
|
402
|
-
const t1 = Date.now();
|
|
403
|
-
const rr = await httpProbe(ru, { timeout: 8000 });
|
|
404
|
-
const dur = Date.now() - t1;
|
|
405
|
-
if (dur > 1000) slowResources.push({ url: ru, duration: dur, size: rr.bodySize, type: ru.endsWith('.css') ? 'stylesheet' : 'script' });
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return {
|
|
410
|
-
ttfb, totalTime: ttfb, bodySize: r.bodySize,
|
|
411
|
-
statusCode: r.status, slowResources,
|
|
412
|
-
lcp: null, fcp: null, cls: null, fid: null, tbt: null,
|
|
413
|
-
resourceTimings: [],
|
|
414
|
-
url, mode: 'http',
|
|
415
|
-
note: 'LCP/FCP/CLS require Playwright — run: npx playwright install chromium',
|
|
416
|
-
};
|
|
736
|
+
return { pass: checks.filter(c => !c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
|
|
417
737
|
}
|
|
418
738
|
|
|
419
739
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
420
|
-
// Accessibility Scanner —
|
|
740
|
+
// Accessibility Scanner — HTML analysis
|
|
421
741
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
422
742
|
async function runA11yScan(url) {
|
|
423
743
|
const r = await httpProbe(url, { timeout: 12000 });
|
|
@@ -425,13 +745,13 @@ async function runA11yScan(url) {
|
|
|
425
745
|
const violations = [], passes = [];
|
|
426
746
|
|
|
427
747
|
const checks = [
|
|
428
|
-
{ id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html),
|
|
429
|
-
{ id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html),
|
|
430
|
-
{ id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html),
|
|
431
|
-
{ id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html),
|
|
432
|
-
{ id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html),
|
|
433
|
-
{ id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html),
|
|
434
|
-
{ id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html),
|
|
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' },
|
|
435
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' },
|
|
436
756
|
];
|
|
437
757
|
|
|
@@ -450,7 +770,7 @@ async function runA11yScan(url) {
|
|
|
450
770
|
}
|
|
451
771
|
|
|
452
772
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
453
|
-
// AI Bug Classifier
|
|
773
|
+
// AI Bug Classifier
|
|
454
774
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
455
775
|
const SEV_PATTERNS = {
|
|
456
776
|
P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
|
|
@@ -459,13 +779,13 @@ const SEV_PATTERNS = {
|
|
|
459
779
|
P3: [/warning|minor|style|typo|cosmetic/i],
|
|
460
780
|
};
|
|
461
781
|
const CAT_PATTERNS = {
|
|
462
|
-
security: /security|csp|hsts|cors|xss|injection|auth|token/i,
|
|
463
|
-
performance: /lcp|fcp|cls|ttfb|slow|timeout|render/i,
|
|
782
|
+
security : /security|csp|hsts|cors|xss|injection|auth|token/i,
|
|
783
|
+
performance : /lcp|fcp|cls|ttfb|slow|timeout|render/i,
|
|
464
784
|
accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
|
|
465
|
-
seo: /title|meta|description|canonical|sitemap|robots/i,
|
|
466
|
-
api: /api|endpoint|status.*code|response|rest/i,
|
|
467
|
-
javascript: /js.*error|console.*error|uncaught|undefined|null/i,
|
|
468
|
-
network: /network|fetch|connection|request.*fail/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,
|
|
469
789
|
};
|
|
470
790
|
function classifyBug(bug) {
|
|
471
791
|
const text = `${bug.title} ${bug.description || ''}`;
|
|
@@ -478,25 +798,26 @@ function classifyBug(bug) {
|
|
|
478
798
|
if (pat.test(text)) { category = cat; break; }
|
|
479
799
|
}
|
|
480
800
|
const recs = {
|
|
481
|
-
security: 'Review security config and run penetration test',
|
|
482
|
-
performance: 'Run Lighthouse and optimize assets/server',
|
|
801
|
+
security : 'Review security config and run penetration test',
|
|
802
|
+
performance : 'Run Lighthouse and optimize assets/server',
|
|
483
803
|
accessibility: 'Fix WCAG 2.1 AA violations with aXe DevTools',
|
|
484
|
-
seo: 'Fix meta tags and submit sitemap to Search Console',
|
|
485
|
-
api: 'Check API contract and add proper error handling',
|
|
486
|
-
javascript: 'Debug in browser DevTools, add error boundaries',
|
|
487
|
-
network: 'Check CDN, server logs, network config',
|
|
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',
|
|
488
808
|
};
|
|
489
809
|
return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
|
|
490
810
|
}
|
|
491
811
|
|
|
492
812
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
-
// Terminal Dashboard
|
|
813
|
+
// Terminal Dashboard
|
|
494
814
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
495
815
|
class TerminalDashboard {
|
|
496
816
|
#session; #lines = 0; #active = false; #timer = null;
|
|
497
817
|
#phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
|
|
818
|
+
#pwMode = false;
|
|
498
819
|
|
|
499
|
-
constructor(s) { this.#session = s; }
|
|
820
|
+
constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
|
|
500
821
|
|
|
501
822
|
start() {
|
|
502
823
|
this.#active = true; this.#startTime = Date.now();
|
|
@@ -513,9 +834,9 @@ class TerminalDashboard {
|
|
|
513
834
|
this.#printFinal();
|
|
514
835
|
}
|
|
515
836
|
|
|
516
|
-
setPhase(p)
|
|
837
|
+
setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
|
|
517
838
|
setCurrentTest(t) { this.#currentTest = t; }
|
|
518
|
-
addResult()
|
|
839
|
+
addResult() { this.#currentTest = ''; }
|
|
519
840
|
log(msg) {
|
|
520
841
|
this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
|
|
521
842
|
if (this.#log.length > 8) this.#log.shift();
|
|
@@ -525,7 +846,7 @@ class TerminalDashboard {
|
|
|
525
846
|
if (!this.#active) return;
|
|
526
847
|
this.#clear();
|
|
527
848
|
const lines = this.#build();
|
|
528
|
-
this.#lines
|
|
849
|
+
this.#lines = lines.length;
|
|
529
850
|
process.stdout.write(lines.join('\n') + '\n');
|
|
530
851
|
}
|
|
531
852
|
|
|
@@ -551,16 +872,17 @@ class TerminalDashboard {
|
|
|
551
872
|
const c1 = chalk.hex('#00F5FF');
|
|
552
873
|
const c2 = chalk.hex('#BF40FF');
|
|
553
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');
|
|
554
876
|
|
|
555
877
|
const pBar = (() => {
|
|
556
|
-
const f
|
|
878
|
+
const f = Math.min(Math.round(rate / 100 * 26), 26);
|
|
557
879
|
const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
|
|
558
880
|
return col('█'.repeat(f)) + chalk.gray('░'.repeat(26 - f));
|
|
559
881
|
})();
|
|
560
882
|
|
|
561
883
|
const out = [
|
|
562
884
|
c1(`┌${bar}┐`),
|
|
563
|
-
c1('│') + c2.bold(pad(` ⚡ BACKLIST
|
|
885
|
+
c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — REAL BROWSER TESTING${pwTag}`)) + c1('│'),
|
|
564
886
|
c1(`├${bar}┤`),
|
|
565
887
|
c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
|
|
566
888
|
c1(`├${bar}┤`),
|
|
@@ -569,7 +891,7 @@ class TerminalDashboard {
|
|
|
569
891
|
c1(`├${bar}┤`),
|
|
570
892
|
c1('│') + pad(this.#currentTest ? ` ${chalk.yellow('⟳')} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Running...')}`) + c1('│'),
|
|
571
893
|
c1(`├${bar}┤`),
|
|
572
|
-
c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('
|
|
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('│'),
|
|
573
895
|
c1(`├${bar}┤`),
|
|
574
896
|
];
|
|
575
897
|
|
|
@@ -586,37 +908,41 @@ class TerminalDashboard {
|
|
|
586
908
|
}
|
|
587
909
|
for (let i = this.#log.length; i < 4; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
588
910
|
out.push(c1(`└${bar}┘`));
|
|
589
|
-
out.push(chalk.dim(` Real
|
|
911
|
+
out.push(chalk.dim(` Real browser data · ${total} tests · ${s.bugs.length} bugs · Ctrl+C to stop`));
|
|
590
912
|
|
|
591
913
|
return out;
|
|
592
914
|
}
|
|
593
915
|
|
|
594
916
|
#printFinal() {
|
|
595
|
-
const s
|
|
917
|
+
const s = this.#session.getSummary();
|
|
596
918
|
const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
|
|
597
919
|
console.log('');
|
|
598
920
|
console.log(chalk.hex('#00F5FF').bold(' ── QA Complete ──────────────────────────────────────'));
|
|
599
|
-
console.log(` Tests:
|
|
600
|
-
console.log(` Passed:
|
|
601
|
-
console.log(` Failed:
|
|
602
|
-
console.log(` Pass rate:
|
|
603
|
-
console.log(` Bugs found:
|
|
604
|
-
console.log(`
|
|
605
|
-
console.log(`
|
|
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')}`);
|
|
606
929
|
console.log('');
|
|
607
930
|
}
|
|
608
931
|
}
|
|
609
932
|
|
|
610
933
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
611
|
-
// HTML Report Builder —
|
|
934
|
+
// HTML Report Builder — v13, Dark Theme, Screenshot Gallery + Vitals
|
|
612
935
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
613
936
|
function buildHTMLReport(session) {
|
|
614
|
-
const summary
|
|
615
|
-
const passRate
|
|
616
|
-
const rateColor
|
|
937
|
+
const summary = session.getSummary();
|
|
938
|
+
const passRate = Number(summary.passRate);
|
|
939
|
+
const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
|
|
617
940
|
|
|
618
|
-
const sevCounts
|
|
619
|
-
session.bugs.forEach(b => {
|
|
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
|
+
});
|
|
620
946
|
|
|
621
947
|
const coverage = {};
|
|
622
948
|
for (const r of session.results) {
|
|
@@ -627,6 +953,31 @@ function buildHTMLReport(session) {
|
|
|
627
953
|
|
|
628
954
|
const esc = (s) => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
629
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 ─────────────────────────────────────────────────────────────
|
|
630
981
|
const testRows = session.results.map(r => `
|
|
631
982
|
<tr class="result-row" data-type="${r.type}" data-status="${r.status}">
|
|
632
983
|
<td>${esc(r.name)}</td>
|
|
@@ -637,8 +988,10 @@ function buildHTMLReport(session) {
|
|
|
637
988
|
<td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
|
|
638
989
|
</tr>`).join('');
|
|
639
990
|
|
|
640
|
-
|
|
641
|
-
|
|
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}">
|
|
642
995
|
<div class="bug-header">
|
|
643
996
|
<span class="bug-id">${esc(b.id)}</span>
|
|
644
997
|
<span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
|
|
@@ -649,8 +1002,10 @@ function buildHTMLReport(session) {
|
|
|
649
1002
|
${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
|
|
650
1003
|
${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
|
|
651
1004
|
${b.evidence ? `<details><summary>Evidence</summary><pre>${esc(JSON.stringify(b.evidence,null,2).slice(0,800))}</pre></details>` : ''}
|
|
652
|
-
</div>`).join('')
|
|
1005
|
+
</div>`).join('')
|
|
1006
|
+
: '<p class="no-data">No bugs detected 🎉</p>';
|
|
653
1007
|
|
|
1008
|
+
// ── Route rows ────────────────────────────────────────────────────────────
|
|
654
1009
|
const routeRows = session.routeMap.map(r => `
|
|
655
1010
|
<tr>
|
|
656
1011
|
<td><code class="url">${esc(r.url)}</code></td>
|
|
@@ -660,6 +1015,7 @@ function buildHTMLReport(session) {
|
|
|
660
1015
|
<td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
|
|
661
1016
|
</tr>`).join('');
|
|
662
1017
|
|
|
1018
|
+
// ── Security rows ─────────────────────────────────────────────────────────
|
|
663
1019
|
const secRows = session.secFindings.map(f => `
|
|
664
1020
|
<tr class="${f.pass ? '' : 'fail-row'}">
|
|
665
1021
|
<td>${esc(f.check)}</td>
|
|
@@ -670,52 +1026,43 @@ function buildHTMLReport(session) {
|
|
|
670
1026
|
<td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
|
|
671
1027
|
</tr>`).join('');
|
|
672
1028
|
|
|
1029
|
+
// ── SEO section ───────────────────────────────────────────────────────────
|
|
673
1030
|
const seoSection = session.seoResults.map(r => `
|
|
674
1031
|
<div class="seo-page">
|
|
675
|
-
<div class="seo-header"
|
|
676
|
-
<
|
|
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>
|
|
677
1036
|
<table>
|
|
678
1037
|
<thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr></thead>
|
|
679
|
-
<tbody>${(r.checks||[]).map(c => `<tr
|
|
1038
|
+
<tbody>${(r.checks||[]).map(c => `<tr>
|
|
1039
|
+
<td>${esc(c.name)}</td><td>${c.category||'–'}</td>
|
|
680
1040
|
<td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
|
|
681
|
-
<td>${esc((c.detail||'').slice(0,100))}</td
|
|
1041
|
+
<td>${esc((c.detail||'').slice(0,100))}</td>
|
|
1042
|
+
</tr>`).join('')}</tbody>
|
|
682
1043
|
</table>
|
|
683
1044
|
</div>`).join('') || '<p class="no-data">No SEO scans</p>';
|
|
684
1045
|
|
|
1046
|
+
// ── A11y section ──────────────────────────────────────────────────────────
|
|
685
1047
|
const a11ySection = session.a11yResults.map(r => `
|
|
686
1048
|
<div class="a11y-page">
|
|
687
|
-
<div class="a11y-header"
|
|
1049
|
+
<div class="a11y-header">
|
|
1050
|
+
<a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
688
1051
|
<span>Score: <strong>${r.score??'–'}%</strong></span>
|
|
689
|
-
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span
|
|
1052
|
+
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
|
|
1053
|
+
</div>
|
|
690
1054
|
${(r.violations||[]).map(v => `
|
|
691
1055
|
<div class="violation impact-${v.impact}">
|
|
692
|
-
<div class="violation-header"
|
|
693
|
-
<
|
|
1056
|
+
<div class="violation-header">
|
|
1057
|
+
<span class="impact-badge">${v.impact}</span>
|
|
1058
|
+
<strong>${esc(v.description)}</strong>
|
|
1059
|
+
</div>
|
|
694
1060
|
<p>${esc(v.help)}</p>
|
|
695
1061
|
</div>`).join('') || '<p class="no-data">No violations ✓</p>'}
|
|
696
1062
|
</div>`).join('') || '<p class="no-data">No accessibility scans</p>';
|
|
697
1063
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
<h3>${esc(label)}</h3>
|
|
701
|
-
<div class="vitals-grid">
|
|
702
|
-
${vitalCard('TTFB', m.ttfb, 800, 'ms')}
|
|
703
|
-
${vitalCard('LCP', m.lcp, 2500, 'ms')}
|
|
704
|
-
${vitalCard('FCP', m.fcp, 1800, 'ms')}
|
|
705
|
-
${vitalCard('CLS', m.cls, 0.1, '')}
|
|
706
|
-
${vitalCard('TBT', m.tbt, 200, 'ms')}
|
|
707
|
-
</div>
|
|
708
|
-
${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
|
|
709
|
-
${(m.slowResources||[]).length ? `<h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
|
|
710
|
-
<table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
|
|
711
|
-
<tbody>${m.slowResources.map(r => `<tr>
|
|
712
|
-
<td class="url">${esc((r.url||'').split('/').pop())}</td>
|
|
713
|
-
<td class="fail">${r.duration}ms</td>
|
|
714
|
-
<td>${formatBytes(r.size)}</td>
|
|
715
|
-
</tr>`).join('')}</tbody></table>` : ''}
|
|
716
|
-
</div>`).join('') || '<p class="no-data">No performance data</p>';
|
|
717
|
-
|
|
718
|
-
function vitalCard(name, value, threshold, unit) {
|
|
1064
|
+
// ── Performance section ───────────────────────────────────────────────────
|
|
1065
|
+
const vitalCard = (name, value, threshold, unit) => {
|
|
719
1066
|
const na = value === null || value === undefined;
|
|
720
1067
|
const pass2 = !na && value <= threshold;
|
|
721
1068
|
const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
|
|
@@ -726,15 +1073,100 @@ function buildHTMLReport(session) {
|
|
|
726
1073
|
<div class="vital-value" style="color:${color}">${disp}</div>
|
|
727
1074
|
<div class="vital-threshold">≤${threshold}${unit}</div>
|
|
728
1075
|
</div>`;
|
|
729
|
-
}
|
|
1076
|
+
};
|
|
730
1077
|
|
|
731
|
-
const
|
|
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)
|
|
732
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('');
|
|
733
1162
|
|
|
734
|
-
const chartTypes
|
|
735
|
-
const chartPass2
|
|
736
|
-
const chartFail2
|
|
737
|
-
const bugSevData
|
|
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>';
|
|
738
1170
|
|
|
739
1171
|
return `<!DOCTYPE html>
|
|
740
1172
|
<html lang="en">
|
|
@@ -752,13 +1184,13 @@ a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
|
|
|
752
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)}
|
|
753
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}
|
|
754
1186
|
.header-meta{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--dim);margin-top:.25rem}
|
|
755
|
-
.version-badge{font-size:.7rem;padding:3px 10px;border-radius:20px;border:1px solid var(--purple);color:var(--purple)}
|
|
756
1187
|
nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;overflow-x:auto;gap:0}
|
|
757
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}
|
|
758
1189
|
.nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
|
|
759
1190
|
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
|
760
1191
|
.tab-panel{display:none}.tab-panel.active{display:block}
|
|
761
|
-
.
|
|
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}
|
|
762
1194
|
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
|
763
1195
|
.mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:.2s;cursor:default}
|
|
764
1196
|
.mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
|
|
@@ -780,6 +1212,7 @@ tr.fail-row td{background:rgba(239,68,68,.04)}
|
|
|
780
1212
|
.sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:800}
|
|
781
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}
|
|
782
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}
|
|
783
1216
|
.url{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--cyan);word-break:break-all}
|
|
784
1217
|
code{font-family:'JetBrains Mono',monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
|
|
785
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}
|
|
@@ -799,6 +1232,18 @@ details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:no
|
|
|
799
1232
|
.no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
|
|
800
1233
|
.url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
|
|
801
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 */
|
|
802
1247
|
.vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.75rem;margin:.75rem 0}
|
|
803
1248
|
.vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
|
|
804
1249
|
.vital-value{font-size:1.5rem;font-weight:800;margin:.25rem 0;font-family:'JetBrains Mono',monospace}
|
|
@@ -820,7 +1265,7 @@ details summary{cursor:pointer;color:var(--cyan);font-size:.78rem;user-select:no
|
|
|
820
1265
|
.impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
|
|
821
1266
|
.err-cell details{font-size:.78rem}
|
|
822
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}
|
|
823
|
-
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
|
|
1268
|
+
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
|
|
824
1269
|
</style>
|
|
825
1270
|
</head>
|
|
826
1271
|
<body>
|
|
@@ -831,11 +1276,12 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
831
1276
|
Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
|
|
832
1277
|
</div>
|
|
833
1278
|
</div>
|
|
834
|
-
|
|
1279
|
+
${pwBadge}
|
|
835
1280
|
</header>
|
|
836
1281
|
|
|
837
1282
|
<nav>
|
|
838
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>
|
|
839
1285
|
<button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
|
|
840
1286
|
<button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
|
|
841
1287
|
<button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
|
|
@@ -843,10 +1289,14 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
843
1289
|
<button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
|
|
844
1290
|
<button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
|
|
845
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>
|
|
846
1294
|
</nav>
|
|
847
1295
|
|
|
848
1296
|
<div class="container">
|
|
849
|
-
|
|
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>
|
|
850
1300
|
|
|
851
1301
|
<!-- OVERVIEW -->
|
|
852
1302
|
<div id="tab-overview" class="tab-panel active">
|
|
@@ -859,10 +1309,10 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
859
1309
|
<div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
|
|
860
1310
|
<div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
|
|
861
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>
|
|
862
1313
|
<div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
|
|
863
|
-
<div class="mc"><div class="ml">APIs Tested</div><div class="mv">${session.apiLog.length}</div></div>
|
|
864
1314
|
<div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
|
|
865
|
-
<div class="mc"><div class="ml">
|
|
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>
|
|
866
1316
|
<div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:1rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
|
|
867
1317
|
</div>
|
|
868
1318
|
<div class="grid2">
|
|
@@ -871,6 +1321,15 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
871
1321
|
</div>
|
|
872
1322
|
</div>
|
|
873
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
|
+
|
|
874
1333
|
<!-- TESTS -->
|
|
875
1334
|
<div id="tab-tests" class="tab-panel">
|
|
876
1335
|
<div class="search-bar">
|
|
@@ -910,7 +1369,7 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
910
1369
|
<!-- ROUTES -->
|
|
911
1370
|
<div id="tab-routes" class="tab-panel">
|
|
912
1371
|
<div class="card">
|
|
913
|
-
<div class="card-title">Discovered Routes <span>${session.routeMap.length}
|
|
1372
|
+
<div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
|
|
914
1373
|
<table>
|
|
915
1374
|
<thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
|
|
916
1375
|
<tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
|
|
@@ -931,25 +1390,41 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
931
1390
|
|
|
932
1391
|
<!-- PERFORMANCE -->
|
|
933
1392
|
<div id="tab-performance" class="tab-panel">
|
|
934
|
-
<div class="card-title" style="padding:.5rem 0 1rem">
|
|
1393
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals + Resource Analysis</div>
|
|
935
1394
|
${perfSection}
|
|
936
1395
|
</div>
|
|
937
1396
|
|
|
938
1397
|
<!-- ACCESSIBILITY -->
|
|
939
1398
|
<div id="tab-a11y" class="tab-panel">
|
|
940
|
-
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility
|
|
1399
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG HTML Analysis</div>
|
|
941
1400
|
${a11ySection}
|
|
942
1401
|
</div>
|
|
943
1402
|
|
|
944
1403
|
<!-- SEO -->
|
|
945
1404
|
<div id="tab-seo" class="tab-panel">
|
|
946
|
-
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis —
|
|
1405
|
+
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent</div>
|
|
947
1406
|
${seoSection}
|
|
948
1407
|
</div>
|
|
949
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
|
+
|
|
950
1425
|
</div>
|
|
951
1426
|
|
|
952
|
-
<footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${new Date().toLocaleString()}</footer>
|
|
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>
|
|
953
1428
|
|
|
954
1429
|
<script>
|
|
955
1430
|
function showTab(name, el) {
|
|
@@ -959,23 +1434,18 @@ function showTab(name, el) {
|
|
|
959
1434
|
el?.classList.add('active');
|
|
960
1435
|
}
|
|
961
1436
|
function filterTests() {
|
|
962
|
-
const s
|
|
1437
|
+
const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
|
|
963
1438
|
const st = document.getElementById('testStatus')?.value||'';
|
|
964
1439
|
const ty = document.getElementById('testType')?.value||'';
|
|
965
1440
|
document.querySelectorAll('#testTable tbody .result-row').forEach(row => {
|
|
966
|
-
|
|
967
|
-
&& (!st || row.dataset.status === st)
|
|
968
|
-
&& (!ty || row.dataset.type === ty);
|
|
969
|
-
row.style.display = show ? '' : 'none';
|
|
1441
|
+
row.style.display = (row.textContent.toLowerCase().includes(s) && (!st || row.dataset.status===st) && (!ty || row.dataset.type===ty)) ? '' : 'none';
|
|
970
1442
|
});
|
|
971
1443
|
}
|
|
972
1444
|
function filterBugs() {
|
|
973
1445
|
const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
|
|
974
1446
|
const sv = document.getElementById('bugSev')?.value||'';
|
|
975
1447
|
document.querySelectorAll('#bugList .bug-card').forEach(card => {
|
|
976
|
-
|
|
977
|
-
&& (!sv || card.dataset.severity === sv);
|
|
978
|
-
card.style.display = show ? '' : 'none';
|
|
1448
|
+
card.style.display = (card.textContent.toLowerCase().includes(s) && (!sv || card.dataset.severity===sv)) ? '' : 'none';
|
|
979
1449
|
});
|
|
980
1450
|
}
|
|
981
1451
|
const chartCfg = {
|
|
@@ -993,7 +1463,7 @@ new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0
|
|
|
993
1463
|
}
|
|
994
1464
|
|
|
995
1465
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
996
|
-
// Main QA Runner
|
|
1466
|
+
// Main QA Runner — v13 with Playwright integration
|
|
997
1467
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
998
1468
|
async function runQAEngine(session) {
|
|
999
1469
|
const dash = new TerminalDashboard(session);
|
|
@@ -1012,7 +1482,7 @@ async function runQAEngine(session) {
|
|
|
1012
1482
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1013
1483
|
if (!url) continue;
|
|
1014
1484
|
dash.log(`Crawling ${label}: ${url}`);
|
|
1015
|
-
const t0
|
|
1485
|
+
const t0 = Date.now();
|
|
1016
1486
|
const routes = await crawlSite(url, {
|
|
1017
1487
|
maxPages: 50,
|
|
1018
1488
|
onRoute: (route) => {
|
|
@@ -1025,8 +1495,126 @@ async function runQAEngine(session) {
|
|
|
1025
1495
|
message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
|
|
1026
1496
|
}
|
|
1027
1497
|
|
|
1028
|
-
// ── Phase 2:
|
|
1029
|
-
dash.setPhase('
|
|
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' });
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// ── Phase 3: API Validation ──────────────────────────────────────────
|
|
1617
|
+
dash.setPhase('📡 Phase 3: API Validation');
|
|
1030
1618
|
const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
|
|
1031
1619
|
dash.log(`Validating ${apiRoutes.length} API endpoints...`);
|
|
1032
1620
|
for (const route of apiRoutes) {
|
|
@@ -1042,8 +1630,8 @@ async function runQAEngine(session) {
|
|
|
1042
1630
|
description: r.error || `HTTP ${r.status}`, evidence: { status: r.status, error: r.error } });
|
|
1043
1631
|
}
|
|
1044
1632
|
|
|
1045
|
-
// ── Phase
|
|
1046
|
-
dash.setPhase('🛡️ Phase
|
|
1633
|
+
// ── Phase 4: Security ────────────────────────────────────────────────
|
|
1634
|
+
dash.setPhase('🛡️ Phase 4: Security Scan');
|
|
1047
1635
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1048
1636
|
if (!url) continue;
|
|
1049
1637
|
dash.setCurrentTest(`Security: ${url}`);
|
|
@@ -1059,24 +1647,6 @@ async function runQAEngine(session) {
|
|
|
1059
1647
|
}
|
|
1060
1648
|
}
|
|
1061
1649
|
|
|
1062
|
-
// ── Phase 4: Performance ─────────────────────────────────────────────
|
|
1063
|
-
dash.setPhase('⚡ Phase 4: Performance Profiling');
|
|
1064
|
-
for (const [label, url] of Object.entries(session.urls)) {
|
|
1065
|
-
if (!url) continue;
|
|
1066
|
-
dash.setCurrentTest(`Perf: ${url}`);
|
|
1067
|
-
const m = await runPerfProfile(url);
|
|
1068
|
-
session.perfMetrics[label] = m;
|
|
1069
|
-
addResult({ name: `[${label}] TTFB`, type: 'performance', category: 'web-vitals',
|
|
1070
|
-
status: m.ttfb <= 800 ? 'PASS' : 'FAIL',
|
|
1071
|
-
message: `TTFB: ${m.ttfb}ms (threshold: ≤800ms)`, url, label, duration: m.ttfb });
|
|
1072
|
-
if (m.ttfb > 800) session.addBug({ title: `Slow TTFB: ${m.ttfb}ms`, severity: m.ttfb > 2000 ? 'P1' : 'P2',
|
|
1073
|
-
type: 'performance', url, evidence: { ttfb: m.ttfb }, recommendation: 'Optimize server response time' });
|
|
1074
|
-
for (const res of (m.slowResources || [])) {
|
|
1075
|
-
addResult({ name: `Slow resource: ${res.url?.split('/').pop()}`, type: 'performance',
|
|
1076
|
-
category: 'resource', status: 'FAIL', message: `${res.duration}ms (${formatBytes(res.size)})`, url, label });
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
1650
|
// ── Phase 5: Accessibility ───────────────────────────────────────────
|
|
1081
1651
|
dash.setPhase('♿ Phase 5: Accessibility Check');
|
|
1082
1652
|
const pageRoutes = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
|
|
@@ -1090,8 +1660,7 @@ async function runQAEngine(session) {
|
|
|
1090
1660
|
url: route.url });
|
|
1091
1661
|
if (['critical','serious'].includes(v.impact)) session.addBug({
|
|
1092
1662
|
title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
|
|
1093
|
-
type: 'accessibility', description: v.help, url: route.url,
|
|
1094
|
-
recommendation: v.helpUrl });
|
|
1663
|
+
type: 'accessibility', description: v.help, url: route.url, recommendation: v.helpUrl });
|
|
1095
1664
|
}
|
|
1096
1665
|
for (const pass of result.passes.slice(0, 3)) {
|
|
1097
1666
|
addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
|
|
@@ -1118,11 +1687,11 @@ async function runQAEngine(session) {
|
|
|
1118
1687
|
dash.setPhase('🤖 Phase 7: AI Bug Classification');
|
|
1119
1688
|
dash.log(`Classifying ${session.bugs.length} bugs...`);
|
|
1120
1689
|
for (const bug of session.bugs) {
|
|
1121
|
-
const cls
|
|
1122
|
-
bug.aiSeverity
|
|
1123
|
-
bug.aiCategory
|
|
1690
|
+
const cls = classifyBug(bug);
|
|
1691
|
+
bug.aiSeverity = cls.severity;
|
|
1692
|
+
bug.aiCategory = cls.category;
|
|
1124
1693
|
bug.aiRecommendation = cls.recommendation;
|
|
1125
|
-
bug.aiConfidence
|
|
1694
|
+
bug.aiConfidence = cls.confidence;
|
|
1126
1695
|
}
|
|
1127
1696
|
session.bugs.sort((a, b) => {
|
|
1128
1697
|
const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
@@ -1141,22 +1710,25 @@ async function runQAEngine(session) {
|
|
|
1141
1710
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1142
1711
|
async function generateReports(session) {
|
|
1143
1712
|
await fs.ensureDir(REPORT_DIR);
|
|
1144
|
-
const base
|
|
1145
|
-
const htmlPath
|
|
1146
|
-
const jsonPath
|
|
1147
|
-
const summary
|
|
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();
|
|
1148
1717
|
|
|
1149
1718
|
await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
|
|
1150
1719
|
await fs.writeJson(jsonPath, {
|
|
1151
|
-
meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
|
|
1720
|
+
meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
|
|
1721
|
+
dataSource: session.playwrightMode ? 'playwright-real-browser' : 'http-only' },
|
|
1152
1722
|
urls: session.urls, summary, results: session.results, bugs: session.bugs,
|
|
1153
1723
|
routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
|
|
1154
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,
|
|
1155
1727
|
ci: {
|
|
1156
|
-
exitCode
|
|
1157
|
-
p0Bugs
|
|
1158
|
-
p1Bugs
|
|
1159
|
-
passRate
|
|
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,
|
|
1160
1732
|
},
|
|
1161
1733
|
}, { spaces: 2 });
|
|
1162
1734
|
|
|
@@ -1182,6 +1754,8 @@ async function saveToHistory(session, htmlPath, jsonPath) {
|
|
|
1182
1754
|
history.runs.unshift({
|
|
1183
1755
|
id: session.id, startedAt: session.startedAt, urls: session.urls,
|
|
1184
1756
|
summary, version: VERSION, bugCount: session.bugs.length,
|
|
1757
|
+
screenshotCount: session.screenshots.length,
|
|
1758
|
+
playwrightMode: session.playwrightMode,
|
|
1185
1759
|
htmlPath, jsonPath,
|
|
1186
1760
|
});
|
|
1187
1761
|
if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
|
|
@@ -1189,9 +1763,8 @@ async function saveToHistory(session, htmlPath, jsonPath) {
|
|
|
1189
1763
|
}
|
|
1190
1764
|
|
|
1191
1765
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1192
|
-
// Public API
|
|
1766
|
+
// Public API — runUrlQA (main entry point)
|
|
1193
1767
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1194
|
-
|
|
1195
1768
|
export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
1196
1769
|
const urls = {};
|
|
1197
1770
|
if (localUrl) urls.localhost = localUrl;
|
|
@@ -1200,17 +1773,31 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
|
1200
1773
|
|
|
1201
1774
|
if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
|
|
1202
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'));
|
|
1785
|
+
}
|
|
1786
|
+
console.log('');
|
|
1787
|
+
|
|
1203
1788
|
const session = new QASession(urls);
|
|
1204
1789
|
await runQAEngine(session);
|
|
1205
1790
|
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1206
1791
|
await saveToHistory(session, htmlPath, jsonPath);
|
|
1207
1792
|
|
|
1208
1793
|
const summary = session.getSummary();
|
|
1209
|
-
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs`));
|
|
1794
|
+
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
|
|
1210
1795
|
console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
|
|
1211
1796
|
console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
|
|
1797
|
+
if (session.screenshots.length > 0) {
|
|
1798
|
+
console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
|
|
1799
|
+
}
|
|
1212
1800
|
|
|
1213
|
-
// Auto-open report
|
|
1214
1801
|
try {
|
|
1215
1802
|
const { exec } = await import('node:child_process');
|
|
1216
1803
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -1249,7 +1836,8 @@ export async function runManualQA() {
|
|
|
1249
1836
|
const action = await p.select({
|
|
1250
1837
|
message: 'Manual QA mode:',
|
|
1251
1838
|
options: [
|
|
1252
|
-
{ value: 'full', label: '🌐 Full Scan (All phases)' },
|
|
1839
|
+
{ value: 'full', label: '🌐 Full Scan (All phases + Playwright)' },
|
|
1840
|
+
{ value: 'browser', label: '🎭 Browser-only (Playwright: screenshots + vitals)' },
|
|
1253
1841
|
{ value: 'security', label: '🛡️ Security only' },
|
|
1254
1842
|
{ value: 'seo', label: '🔎 SEO only' },
|
|
1255
1843
|
{ value: 'a11y', label: '♿ Accessibility only' },
|
|
@@ -1270,7 +1858,15 @@ export async function runManualQA() {
|
|
|
1270
1858
|
const dash = new TerminalDashboard(sess);
|
|
1271
1859
|
dash.start();
|
|
1272
1860
|
try {
|
|
1273
|
-
if (action === '
|
|
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') {
|
|
1274
1870
|
const f = await runSecurityScan(url);
|
|
1275
1871
|
sess.secFindings.push(...f);
|
|
1276
1872
|
f.forEach(finding => sess.addResult({ id: shortId(), name: `Security: ${finding.check}`, type: 'security',
|
|
@@ -1286,10 +1882,20 @@ export async function runManualQA() {
|
|
|
1286
1882
|
r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
|
|
1287
1883
|
status: 'FAIL', message: v.help, timestamp: timestamp() }));
|
|
1288
1884
|
} else if (action === 'perf') {
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
+
}
|
|
1293
1899
|
}
|
|
1294
1900
|
} finally { dash.stop(); }
|
|
1295
1901
|
}
|
|
@@ -1326,7 +1932,8 @@ export async function viewQAHistory() {
|
|
|
1326
1932
|
const rate = run.summary?.passRate ?? '–';
|
|
1327
1933
|
const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
|
|
1328
1934
|
const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
|
|
1329
|
-
|
|
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))}`);
|
|
1330
1937
|
}
|
|
1331
1938
|
console.log('');
|
|
1332
1939
|
|
|
@@ -1335,7 +1942,7 @@ export async function viewQAHistory() {
|
|
|
1335
1942
|
options: [
|
|
1336
1943
|
...history.runs.slice(0, 8).map(r => ({
|
|
1337
1944
|
value: r.htmlPath || r.id,
|
|
1338
|
-
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
|
|
1945
|
+
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs${r.playwrightMode ? ' 🎭' : ''}`,
|
|
1339
1946
|
})),
|
|
1340
1947
|
{ value: '__back', label: '↩ Back' },
|
|
1341
1948
|
],
|
|
@@ -1353,4 +1960,4 @@ export async function viewQAHistory() {
|
|
|
1353
1960
|
} else {
|
|
1354
1961
|
console.log(chalk.yellow(' Report file not found.'));
|
|
1355
1962
|
}
|
|
1356
|
-
}
|
|
1963
|
+
}
|