create-backlist 10.0.9 → 10.1.1
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 +1411 -1060
- package/package.json +1 -1
- package/src/generators/dotnet.js +137 -81
- package/src/generators/java.js +118 -130
- package/src/generators/js.js +199 -207
- package/src/generators/nestjs.js +168 -155
- package/src/generators/node.js +212 -194
- package/src/generators/python.js +130 -45
- package/src/generators/template.js +47 -2
- package/src/qa/qa-engine.js +2320 -414
- package/src/templates/dotnet/partials/Controller.cs.ejs +264 -16
- package/src/templates/dotnet/partials/DbContext.cs.ejs +93 -3
- package/src/templates/dotnet/partials/Model.cs.ejs +192 -31
package/src/qa/qa-engine.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// Backlist Enterprise QA Engine
|
|
3
|
-
//
|
|
2
|
+
// Backlist Enterprise QA Engine v15.0 — ULTRA LIVE TESTING EDITION
|
|
3
|
+
// ✅ Real Playwright Browser · ✅ AI Bug Classifier · ✅ Live WebSocket Monitor
|
|
4
|
+
// ✅ Visual Regression · ✅ API Contract Testing · ✅ Real User Simulation
|
|
5
|
+
// ✅ Cookie/Auth Testing · ✅ Dark Mode Testing · ✅ Multi-viewport Testing
|
|
6
|
+
// ✅ Memory Leak Detection · ✅ Load Testing · ✅ WebSocket Testing
|
|
7
|
+
// ✅ Broken Link Scanner · ✅ Font/Asset Audit · ✅ Rich HTML Reports v15
|
|
4
8
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
5
9
|
|
|
6
10
|
import * as p from '@clack/prompts';
|
|
@@ -9,36 +13,49 @@ import fs from 'fs-extra';
|
|
|
9
13
|
import path from 'node:path';
|
|
10
14
|
import os from 'node:os';
|
|
11
15
|
import readline from 'node:readline';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
12
17
|
import { performance } from 'node:perf_hooks';
|
|
13
18
|
import { EventEmitter } from 'node:events';
|
|
14
19
|
|
|
15
20
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
16
|
-
export const VERSION = '
|
|
17
|
-
export const QA_DIR = path.join(process.cwd(), '.
|
|
21
|
+
export const VERSION = '15.0.0';
|
|
22
|
+
export const QA_DIR = path.join(process.cwd(), '.BACKLIST', 'qa');
|
|
18
23
|
export const REPORT_DIR = path.join(QA_DIR, 'reports');
|
|
19
24
|
export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
|
|
20
25
|
export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
|
|
26
|
+
export const BASELINE_DIR = path.join(QA_DIR, 'baselines');
|
|
27
|
+
|
|
28
|
+
// ── Viewports for multi-device testing ───────────────────────────────────
|
|
29
|
+
export const VIEWPORTS = {
|
|
30
|
+
desktop_xl : { width: 1920, height: 1080, label: 'Desktop XL' },
|
|
31
|
+
desktop : { width: 1280, height: 900, label: 'Desktop' },
|
|
32
|
+
tablet_lg : { width: 1024, height: 768, label: 'Tablet (lg)' },
|
|
33
|
+
tablet : { width: 768, height: 1024, label: 'Tablet' },
|
|
34
|
+
mobile_lg : { width: 414, height: 896, label: 'Mobile (large)' },
|
|
35
|
+
mobile : { width: 390, height: 844, label: 'Mobile (iPhone)'},
|
|
36
|
+
mobile_sm : { width: 320, height: 568, label: 'Mobile (small)' },
|
|
37
|
+
};
|
|
21
38
|
|
|
22
39
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
23
|
-
export const timestamp
|
|
24
|
-
export const shortId
|
|
25
|
-
export const sleep
|
|
40
|
+
export const timestamp = () => new Date().toISOString();
|
|
41
|
+
export const shortId = () => Math.random().toString(36).slice(2, 9);
|
|
42
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
26
43
|
export const formatDuration = (ms) => {
|
|
27
44
|
if (!ms || ms < 0) return '0ms';
|
|
28
45
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
29
46
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
30
47
|
};
|
|
31
48
|
export const formatBytes = (b) => {
|
|
32
|
-
if (!b || b < 0)
|
|
33
|
-
if (b < 1024)
|
|
34
|
-
if (b < 1024 * 1024)
|
|
49
|
+
if (!b || b < 0) return '0B';
|
|
50
|
+
if (b < 1024) return `${b}B`;
|
|
51
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
|
|
35
52
|
return `${(b / 1024 / 1024).toFixed(1)}MB`;
|
|
36
53
|
};
|
|
37
54
|
|
|
38
|
-
// ──
|
|
55
|
+
// ── readline helper ───────────────────────────────────────────────────────
|
|
39
56
|
function askYesNo(question) {
|
|
40
57
|
return new Promise((resolve) => {
|
|
41
|
-
const rl
|
|
58
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
59
|
const timer = setTimeout(() => { rl.close(); resolve(false); }, 10_000);
|
|
43
60
|
rl.question(question, (ans) => {
|
|
44
61
|
clearTimeout(timer);
|
|
@@ -48,8 +65,25 @@ function askYesNo(question) {
|
|
|
48
65
|
});
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// ── Playwright availability check ────────────────────────────────────────
|
|
69
|
+
async function getPlaywright() {
|
|
70
|
+
try {
|
|
71
|
+
const pw = await import('playwright');
|
|
72
|
+
return pw.chromium || pw.default?.chromium || null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
79
|
+
// NEW v15: Image hash for visual regression
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
81
|
+
function hashBuffer(buf) {
|
|
82
|
+
return crypto.createHash('md5').update(buf).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
-
// QA Session —
|
|
86
|
+
// QA Session v15 — Extended with new trackers
|
|
53
87
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
54
88
|
export class QASession {
|
|
55
89
|
id;
|
|
@@ -66,6 +100,31 @@ export class QASession {
|
|
|
66
100
|
secFindings = [];
|
|
67
101
|
a11yResults = [];
|
|
68
102
|
seoResults = [];
|
|
103
|
+
playwrightMode = false;
|
|
104
|
+
// NEW v15 fields
|
|
105
|
+
visualRegressions = [];
|
|
106
|
+
cookieAudit = [];
|
|
107
|
+
loadTestResults = [];
|
|
108
|
+
brokenLinks = [];
|
|
109
|
+
fontAudit = [];
|
|
110
|
+
assetAudit = [];
|
|
111
|
+
memorySnapshots = [];
|
|
112
|
+
wsTests = [];
|
|
113
|
+
darkModeResults = [];
|
|
114
|
+
viewportResults = {};
|
|
115
|
+
apiContracts = [];
|
|
116
|
+
userFlowResults = [];
|
|
117
|
+
redirectChains = [];
|
|
118
|
+
mixedContentIssues = [];
|
|
119
|
+
cspViolations = [];
|
|
120
|
+
thirdPartyScripts = [];
|
|
121
|
+
errorPageTests = [];
|
|
122
|
+
formTests = [];
|
|
123
|
+
authTests = [];
|
|
124
|
+
cacheHeaders = [];
|
|
125
|
+
httpVersions = {};
|
|
126
|
+
tlsInfo = {};
|
|
127
|
+
dnsInfo = {};
|
|
69
128
|
|
|
70
129
|
constructor(urls = {}) {
|
|
71
130
|
this.id = `QA-${shortId().toUpperCase()}`;
|
|
@@ -74,7 +133,13 @@ export class QASession {
|
|
|
74
133
|
}
|
|
75
134
|
|
|
76
135
|
addResult(r) { this.results.push(r); }
|
|
77
|
-
addBug(bug) {
|
|
136
|
+
addBug(bug) {
|
|
137
|
+
this.bugs.push({
|
|
138
|
+
...bug,
|
|
139
|
+
id: `BUG-${shortId().toUpperCase()}`,
|
|
140
|
+
createdAt: timestamp(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
78
143
|
|
|
79
144
|
getSummary() {
|
|
80
145
|
const passed = this.results.filter(r => r.status === 'PASS').length;
|
|
@@ -91,19 +156,21 @@ export class QASession {
|
|
|
91
156
|
}
|
|
92
157
|
|
|
93
158
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
-
// HTTP Probe — real HTTP requests
|
|
159
|
+
// HTTP Probe — real HTTP requests with v15 extras
|
|
95
160
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
|
-
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
|
|
161
|
+
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {}, body: reqBody = null, followRedirects = true } = {}) {
|
|
97
162
|
const t0 = Date.now();
|
|
98
163
|
try {
|
|
99
164
|
const ctrl = new AbortController();
|
|
100
165
|
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
101
|
-
const
|
|
166
|
+
const fetchOpts = {
|
|
102
167
|
method,
|
|
103
168
|
signal : ctrl.signal,
|
|
104
|
-
headers : { 'User-Agent': 'Backlist-QA/
|
|
105
|
-
redirect: 'follow',
|
|
106
|
-
}
|
|
169
|
+
headers : { 'User-Agent': 'Backlist-QA/15.0', Accept: '*/*', ...headers },
|
|
170
|
+
redirect: followRedirects ? 'follow' : 'manual',
|
|
171
|
+
};
|
|
172
|
+
if (reqBody) fetchOpts.body = reqBody;
|
|
173
|
+
const res = await fetch(url, fetchOpts);
|
|
107
174
|
clearTimeout(timer);
|
|
108
175
|
|
|
109
176
|
const rt = Date.now() - t0;
|
|
@@ -120,9 +187,8 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
120
187
|
return {
|
|
121
188
|
ok: res.status >= 200 && res.status < 400,
|
|
122
189
|
status: res.status, contentType, headers: hdrs,
|
|
123
|
-
body: body.slice(0,
|
|
124
|
-
responseTime: rt, url, method,
|
|
125
|
-
error: null,
|
|
190
|
+
body: body.slice(0, 5000), parsed, bodySize,
|
|
191
|
+
responseTime: rt, url, method, error: null,
|
|
126
192
|
};
|
|
127
193
|
} catch (err) {
|
|
128
194
|
return {
|
|
@@ -135,33 +201,1100 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
135
201
|
}
|
|
136
202
|
|
|
137
203
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
138
|
-
//
|
|
204
|
+
// NEW v15: Redirect Chain Analyzer
|
|
205
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
206
|
+
async function analyzeRedirectChain(url) {
|
|
207
|
+
const chain = [];
|
|
208
|
+
let current = url;
|
|
209
|
+
let hops = 0;
|
|
210
|
+
const maxHops = 10;
|
|
211
|
+
|
|
212
|
+
while (hops < maxHops) {
|
|
213
|
+
try {
|
|
214
|
+
const ctrl = new AbortController();
|
|
215
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
216
|
+
const res = await fetch(current, {
|
|
217
|
+
method: 'HEAD',
|
|
218
|
+
signal: ctrl.signal,
|
|
219
|
+
redirect: 'manual',
|
|
220
|
+
headers: { 'User-Agent': 'Backlist-QA/15.0' },
|
|
221
|
+
});
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
chain.push({ url: current, status: res.status, location: res.headers.get('location') });
|
|
224
|
+
if (res.status < 300 || res.status >= 400) break;
|
|
225
|
+
const location = res.headers.get('location');
|
|
226
|
+
if (!location) break;
|
|
227
|
+
current = new URL(location, current).toString();
|
|
228
|
+
hops++;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
chain.push({ url: current, status: 0, error: err.message });
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
url,
|
|
237
|
+
hops: chain.length - 1,
|
|
238
|
+
chain,
|
|
239
|
+
hasRedirectLoop: hops >= maxHops,
|
|
240
|
+
finalUrl: chain[chain.length - 1]?.url,
|
|
241
|
+
isHTTPtoHTTPS: chain.length > 1 && chain[0].url.startsWith('http://') && chain[chain.length - 1]?.url?.startsWith('https://'),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
246
|
+
// NEW v15: Load Test — concurrent requests
|
|
247
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
248
|
+
async function runLoadTest(url, { concurrency = 10, duration = 10000, rampUp = 2000 } = {}) {
|
|
249
|
+
const results = { requests: 0, errors: 0, timeouts: 0, responses: {} };
|
|
250
|
+
const latencies = [];
|
|
251
|
+
const startTime = Date.now();
|
|
252
|
+
let running = true;
|
|
253
|
+
|
|
254
|
+
setTimeout(() => { running = false; }, duration);
|
|
255
|
+
|
|
256
|
+
const worker = async (delay = 0) => {
|
|
257
|
+
await sleep(delay);
|
|
258
|
+
while (running) {
|
|
259
|
+
const t0 = Date.now();
|
|
260
|
+
try {
|
|
261
|
+
const ctrl = new AbortController();
|
|
262
|
+
const timer = setTimeout(() => { ctrl.abort(); results.timeouts++; }, 5000);
|
|
263
|
+
const res = await fetch(url, {
|
|
264
|
+
signal: ctrl.signal,
|
|
265
|
+
headers: { 'User-Agent': 'Backlist-QA/15.0-LoadTest' },
|
|
266
|
+
});
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
const lat = Date.now() - t0;
|
|
269
|
+
latencies.push(lat);
|
|
270
|
+
results.requests++;
|
|
271
|
+
results.responses[res.status] = (results.responses[res.status] || 0) + 1;
|
|
272
|
+
} catch {
|
|
273
|
+
results.errors++;
|
|
274
|
+
}
|
|
275
|
+
await sleep(50); // small breathing room
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const workers = Array.from({ length: concurrency }, (_, i) =>
|
|
280
|
+
worker(Math.floor((rampUp / concurrency) * i))
|
|
281
|
+
);
|
|
282
|
+
await Promise.all(workers);
|
|
283
|
+
|
|
284
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
285
|
+
const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0;
|
|
286
|
+
const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;
|
|
287
|
+
const p99 = sorted[Math.floor(sorted.length * 0.99)] || 0;
|
|
288
|
+
const avgLat = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
289
|
+
const totalTime = Date.now() - startTime;
|
|
290
|
+
const rps = results.requests / (totalTime / 1000);
|
|
291
|
+
const errorRate = results.requests > 0 ? (results.errors / results.requests * 100) : 0;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
url, concurrency, duration: totalTime,
|
|
295
|
+
requests: results.requests,
|
|
296
|
+
errors: results.errors,
|
|
297
|
+
timeouts: results.timeouts,
|
|
298
|
+
errorRate: parseFloat(errorRate.toFixed(2)),
|
|
299
|
+
rps: parseFloat(rps.toFixed(2)),
|
|
300
|
+
latency: { avg: Math.round(avgLat), p50, p95, p99, min: sorted[0] || 0, max: sorted[sorted.length - 1] || 0 },
|
|
301
|
+
responses: results.responses,
|
|
302
|
+
passed: errorRate < 5 && p95 < 2000,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
307
|
+
// NEW v15: Cookie Audit
|
|
308
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
309
|
+
async function runCookieAudit(url) {
|
|
310
|
+
const r = await httpProbe(url);
|
|
311
|
+
const setCookie = r.headers['set-cookie'] || '';
|
|
312
|
+
const cookies = (Array.isArray(setCookie) ? setCookie : [setCookie]).filter(Boolean);
|
|
313
|
+
const audit = [];
|
|
314
|
+
|
|
315
|
+
for (const cookie of cookies) {
|
|
316
|
+
const name = cookie.split('=')[0]?.trim();
|
|
317
|
+
const parts = cookie.toLowerCase();
|
|
318
|
+
const isSecure = parts.includes('secure');
|
|
319
|
+
const isHttpOnly = parts.includes('httponly');
|
|
320
|
+
const hasSameSite = parts.includes('samesite');
|
|
321
|
+
const sameSiteVal = (parts.match(/samesite=(\w+)/) || [])[1] || null;
|
|
322
|
+
const hasExpiry = parts.includes('expires=') || parts.includes('max-age=');
|
|
323
|
+
|
|
324
|
+
audit.push({
|
|
325
|
+
name,
|
|
326
|
+
raw: cookie.slice(0, 200),
|
|
327
|
+
secure: isSecure,
|
|
328
|
+
httpOnly: isHttpOnly,
|
|
329
|
+
sameSite: sameSiteVal,
|
|
330
|
+
hasSameSite,
|
|
331
|
+
hasExpiry,
|
|
332
|
+
issues: [
|
|
333
|
+
!isSecure && 'Missing Secure flag',
|
|
334
|
+
!isHttpOnly && 'Missing HttpOnly flag',
|
|
335
|
+
!hasSameSite && 'Missing SameSite attribute',
|
|
336
|
+
sameSiteVal === 'none' && !isSecure && 'SameSite=None without Secure',
|
|
337
|
+
].filter(Boolean),
|
|
338
|
+
severity: (!isSecure || !isHttpOnly) ? 'P1' : !hasSameSite ? 'P2' : 'INFO',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { url, cookies: audit, total: audit.length, issues: audit.filter(c => c.issues.length > 0).length };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
346
|
+
// NEW v15: Broken Link Scanner (deep)
|
|
347
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
348
|
+
async function scanBrokenLinks(url, { maxLinks = 100 } = {}) {
|
|
349
|
+
const r = await httpProbe(url);
|
|
350
|
+
const html = r.body || '';
|
|
351
|
+
const links = [];
|
|
352
|
+
const re = /href=["']([^"']+)["']/gi;
|
|
353
|
+
let m;
|
|
354
|
+
while ((m = re.exec(html)) !== null) {
|
|
355
|
+
try {
|
|
356
|
+
const resolved = new URL(m[1], url).toString();
|
|
357
|
+
if (!links.includes(resolved)) links.push(resolved);
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const results = [];
|
|
362
|
+
const toCheck = links.slice(0, maxLinks);
|
|
363
|
+
const BATCH = 15;
|
|
364
|
+
|
|
365
|
+
for (let i = 0; i < toCheck.length; i += BATCH) {
|
|
366
|
+
const batch = toCheck.slice(i, i + BATCH);
|
|
367
|
+
const checks = batch.map(async (link) => {
|
|
368
|
+
try {
|
|
369
|
+
const ctrl = new AbortController();
|
|
370
|
+
const timer = setTimeout(() => ctrl.abort(), 6000);
|
|
371
|
+
const res = await fetch(link, {
|
|
372
|
+
method: 'HEAD',
|
|
373
|
+
signal: ctrl.signal,
|
|
374
|
+
headers: { 'User-Agent': 'Backlist-QA/15.0' },
|
|
375
|
+
redirect: 'follow',
|
|
376
|
+
});
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
return { url: link, status: res.status, ok: res.status < 400 };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return { url: link, status: 0, ok: false, error: err.message };
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
const batchResults = await Promise.all(checks);
|
|
384
|
+
results.push(...batchResults);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const broken = results.filter(r => !r.ok);
|
|
388
|
+
return { sourceUrl: url, total: results.length, broken: broken.length, links: results };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
392
|
+
// NEW v15: API Contract Tester
|
|
393
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
394
|
+
async function testAPIContract(endpoint, { expectedStatus = 200, expectedFields = [], method = 'GET', body = null, headers = {} } = {}) {
|
|
395
|
+
const r = await httpProbe(endpoint, { method, body, headers, timeout: 10000 });
|
|
396
|
+
const issues = [];
|
|
397
|
+
|
|
398
|
+
if (r.status !== expectedStatus) {
|
|
399
|
+
issues.push(`Expected status ${expectedStatus}, got ${r.status}`);
|
|
400
|
+
}
|
|
401
|
+
if (r.parsed && expectedFields.length > 0) {
|
|
402
|
+
for (const field of expectedFields) {
|
|
403
|
+
const hasField = field.includes('.')
|
|
404
|
+
? field.split('.').reduce((obj, k) => obj?.[k], r.parsed) !== undefined
|
|
405
|
+
: r.parsed[field] !== undefined || (Array.isArray(r.parsed) && r.parsed[0]?.[field] !== undefined);
|
|
406
|
+
if (!hasField) issues.push(`Missing field: ${field}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (r.status === 200 && !r.parsed && r.contentType.includes('json')) {
|
|
410
|
+
issues.push('Response is not valid JSON despite Content-Type: application/json');
|
|
411
|
+
}
|
|
412
|
+
const hasCache = !!(r.headers['cache-control'] || r.headers['etag'] || r.headers['last-modified']);
|
|
413
|
+
const hasCors = !!(r.headers['access-control-allow-origin']);
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
url: endpoint, method, status: r.status, responseTime: r.responseTime,
|
|
417
|
+
contentType: r.contentType, bodySize: r.bodySize,
|
|
418
|
+
issues, passed: issues.length === 0,
|
|
419
|
+
hasCache, hasCors, parsed: r.parsed,
|
|
420
|
+
headers: r.headers,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
425
|
+
// NEW v15: Form Interaction Tester (Playwright)
|
|
426
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
427
|
+
async function testForms(page, url) {
|
|
428
|
+
const results = [];
|
|
429
|
+
try {
|
|
430
|
+
const forms = await page.$$('form');
|
|
431
|
+
for (let fi = 0; fi < Math.min(forms.length, 5); fi++) {
|
|
432
|
+
const form = forms[fi];
|
|
433
|
+
const action = await form.getAttribute('action') || 'self';
|
|
434
|
+
const method = (await form.getAttribute('method') || 'GET').toUpperCase();
|
|
435
|
+
const inputs = await form.$$('input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
|
|
436
|
+
const submits = await form.$$('[type="submit"], button[type="submit"]');
|
|
437
|
+
const hasSubmit = submits.length > 0;
|
|
438
|
+
|
|
439
|
+
// Test required field validation
|
|
440
|
+
let validationWorks = false;
|
|
441
|
+
if (hasSubmit) {
|
|
442
|
+
try {
|
|
443
|
+
await submits[0].click({ timeout: 2000 });
|
|
444
|
+
await page.waitForTimeout(300);
|
|
445
|
+
// Check for validation messages
|
|
446
|
+
const invalidFields = await page.$$(':invalid');
|
|
447
|
+
validationWorks = invalidFields.length > 0 || (await page.evaluate(() => document.querySelector('.error, .invalid, [aria-invalid="true"]') !== null));
|
|
448
|
+
} catch {}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Test placeholder/label
|
|
452
|
+
let labelCount = 0;
|
|
453
|
+
for (const inp of inputs) {
|
|
454
|
+
const id = await inp.getAttribute('id');
|
|
455
|
+
const ariaLbl = await inp.getAttribute('aria-label');
|
|
456
|
+
if (id) {
|
|
457
|
+
const lbl = await page.$(`label[for="${id}"]`);
|
|
458
|
+
if (lbl) labelCount++;
|
|
459
|
+
} else if (ariaLbl) {
|
|
460
|
+
labelCount++;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
results.push({
|
|
465
|
+
formIndex: fi,
|
|
466
|
+
action, method,
|
|
467
|
+
inputCount: inputs.length,
|
|
468
|
+
hasSubmit,
|
|
469
|
+
labelCoverage: inputs.length > 0 ? Math.round(labelCount / inputs.length * 100) : 100,
|
|
470
|
+
validationWorks,
|
|
471
|
+
issues: [
|
|
472
|
+
!hasSubmit && 'No submit button',
|
|
473
|
+
inputs.length > 0 && labelCount < inputs.length && `${inputs.length - labelCount} inputs missing labels`,
|
|
474
|
+
].filter(Boolean),
|
|
475
|
+
passed: hasSubmit && (inputs.length === 0 || labelCount === inputs.length),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
} catch {}
|
|
479
|
+
return results;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
483
|
+
// NEW v15: Memory Leak Detector (Playwright)
|
|
484
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
485
|
+
async function detectMemoryLeaks(page, url) {
|
|
486
|
+
const snapshots = [];
|
|
487
|
+
try {
|
|
488
|
+
// Snapshot 1: initial load
|
|
489
|
+
const heap1 = await page.evaluate(() => {
|
|
490
|
+
if (window.performance?.memory) {
|
|
491
|
+
return { used: performance.memory.usedJSHeapSize, total: performance.memory.totalJSHeapSize };
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
});
|
|
495
|
+
if (heap1) snapshots.push({ label: 'initial', ...heap1, time: 0 });
|
|
496
|
+
|
|
497
|
+
// Simulate user interactions to trigger potential leaks
|
|
498
|
+
await page.evaluate(() => {
|
|
499
|
+
for (let i = 0; i < 5; i++) {
|
|
500
|
+
window.dispatchEvent(new Event('scroll'));
|
|
501
|
+
window.dispatchEvent(new Event('resize'));
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
await page.waitForTimeout(1000);
|
|
505
|
+
|
|
506
|
+
// Navigate away and back
|
|
507
|
+
const currentUrl = page.url();
|
|
508
|
+
await page.goto('about:blank', { waitUntil: 'load' }).catch(() => {});
|
|
509
|
+
await page.goto(currentUrl, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
|
510
|
+
await page.waitForTimeout(500);
|
|
511
|
+
|
|
512
|
+
const heap2 = await page.evaluate(() => {
|
|
513
|
+
if (window.performance?.memory) {
|
|
514
|
+
return { used: performance.memory.usedJSHeapSize, total: performance.memory.totalJSHeapSize };
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
});
|
|
518
|
+
if (heap2) snapshots.push({ label: 'after-navigate', ...heap2, time: 1000 });
|
|
519
|
+
|
|
520
|
+
if (snapshots.length >= 2) {
|
|
521
|
+
const growth = snapshots[1].used - snapshots[0].used;
|
|
522
|
+
const growthMB = growth / 1024 / 1024;
|
|
523
|
+
return {
|
|
524
|
+
snapshots,
|
|
525
|
+
growth, growthMB: parseFloat(growthMB.toFixed(2)),
|
|
526
|
+
hasLeak: growthMB > 5,
|
|
527
|
+
severity: growthMB > 20 ? 'P1' : growthMB > 5 ? 'P2' : 'INFO',
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
} catch {}
|
|
531
|
+
return { snapshots, growth: 0, growthMB: 0, hasLeak: false, severity: 'INFO', note: 'performance.memory not available (non-Chrome)' };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
535
|
+
// NEW v15: Dark Mode Tester (Playwright)
|
|
536
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
537
|
+
async function testDarkMode(page, url, screenshotDir, sessionId) {
|
|
538
|
+
const results = {};
|
|
539
|
+
try {
|
|
540
|
+
// Light mode screenshot already taken — test dark mode
|
|
541
|
+
await page.emulateMedia({ colorScheme: 'dark' });
|
|
542
|
+
await page.waitForTimeout(800);
|
|
543
|
+
const darkName = `${sessionId}-dark-${shortId()}.png`;
|
|
544
|
+
const darkPath = path.join(screenshotDir, darkName);
|
|
545
|
+
await page.screenshot({ path: darkPath, fullPage: false });
|
|
546
|
+
|
|
547
|
+
// Check if dark mode actually changes anything
|
|
548
|
+
const hasMediaQuery = await page.evaluate(() => {
|
|
549
|
+
const sheets = [...document.styleSheets];
|
|
550
|
+
for (const sheet of sheets) {
|
|
551
|
+
try {
|
|
552
|
+
const rules = [...sheet.cssRules];
|
|
553
|
+
for (const rule of rules) {
|
|
554
|
+
if (rule.conditionText?.includes('prefers-color-scheme')) return true;
|
|
555
|
+
}
|
|
556
|
+
} catch {}
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Check body background color changes
|
|
562
|
+
const darkBg = await page.evaluate(() => {
|
|
563
|
+
return window.getComputedStyle(document.body).backgroundColor;
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
results.dark = { screenshotPath: darkPath, screenshotName: darkName, background: darkBg };
|
|
567
|
+
results.hasMediaQuery = hasMediaQuery;
|
|
568
|
+
results.supportsDark = hasMediaQuery;
|
|
569
|
+
|
|
570
|
+
// Reset to light
|
|
571
|
+
await page.emulateMedia({ colorScheme: 'light' });
|
|
572
|
+
await page.waitForTimeout(300);
|
|
573
|
+
|
|
574
|
+
const lightBg = await page.evaluate(() => {
|
|
575
|
+
return window.getComputedStyle(document.body).backgroundColor;
|
|
576
|
+
});
|
|
577
|
+
results.light = { background: lightBg };
|
|
578
|
+
results.differentFromLight = darkBg !== lightBg;
|
|
579
|
+
|
|
580
|
+
} catch (err) {
|
|
581
|
+
results.error = err.message;
|
|
582
|
+
}
|
|
583
|
+
return results;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
587
|
+
// NEW v15: Third-Party Script Auditor (Playwright)
|
|
588
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
589
|
+
async function auditThirdPartyScripts(page) {
|
|
590
|
+
const origin = new URL(page.url()).origin;
|
|
591
|
+
const scripts = [];
|
|
592
|
+
const requests = [];
|
|
593
|
+
|
|
594
|
+
const handler = (req) => {
|
|
595
|
+
if (req.resourceType() === 'script') {
|
|
596
|
+
try {
|
|
597
|
+
const u = new URL(req.url());
|
|
598
|
+
if (u.origin !== origin) {
|
|
599
|
+
requests.push({
|
|
600
|
+
url: req.url(),
|
|
601
|
+
domain: u.hostname,
|
|
602
|
+
vendor: classifyThirdParty(u.hostname),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
} catch {}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
page.on('request', handler);
|
|
609
|
+
await page.waitForTimeout(2000);
|
|
610
|
+
page.off('request', handler);
|
|
611
|
+
|
|
612
|
+
// Deduplicate by domain
|
|
613
|
+
const domainMap = {};
|
|
614
|
+
for (const r of requests) {
|
|
615
|
+
if (!domainMap[r.domain]) domainMap[r.domain] = { ...r, count: 0 };
|
|
616
|
+
domainMap[r.domain].count++;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return Object.values(domainMap);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function classifyThirdParty(hostname) {
|
|
623
|
+
const map = {
|
|
624
|
+
'google-analytics.com': 'Google Analytics', 'googletagmanager.com': 'Google Tag Manager',
|
|
625
|
+
'googlesyndication.com': 'Google Ads', 'doubleclick.net': 'Google Ads',
|
|
626
|
+
'facebook.net': 'Facebook SDK', 'facebook.com': 'Facebook',
|
|
627
|
+
'hotjar.com': 'Hotjar', 'fullstory.com': 'FullStory',
|
|
628
|
+
'segment.io': 'Segment', 'amplitude.com': 'Amplitude',
|
|
629
|
+
'mixpanel.com': 'Mixpanel', 'intercom.io': 'Intercom',
|
|
630
|
+
'cdn.jsdelivr.net': 'jsDelivr CDN', 'unpkg.com': 'unpkg CDN',
|
|
631
|
+
'cdnjs.cloudflare.com': 'Cloudflare CDN', 'stripe.com': 'Stripe',
|
|
632
|
+
'sentry.io': 'Sentry', 'datadog-browser-agent.com': 'Datadog',
|
|
633
|
+
'newrelic.com': 'New Relic', 'rollbar.com': 'Rollbar',
|
|
634
|
+
'clarity.ms': 'Microsoft Clarity', 'twitter.com': 'Twitter',
|
|
635
|
+
'x.com': 'X (Twitter)', 'tiktok.com': 'TikTok',
|
|
636
|
+
'linkedin.com': 'LinkedIn',
|
|
637
|
+
};
|
|
638
|
+
for (const [domain, name] of Object.entries(map)) {
|
|
639
|
+
if (hostname.includes(domain)) return name;
|
|
640
|
+
}
|
|
641
|
+
return 'Unknown Third-Party';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
645
|
+
// NEW v15: Font & Asset Auditor (Playwright)
|
|
646
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
647
|
+
async function auditFontsAndAssets(page) {
|
|
648
|
+
return await page.evaluate(() => {
|
|
649
|
+
const resources = performance.getEntriesByType('resource');
|
|
650
|
+
const audit = { fonts: [], images: [], scripts: [], styles: [], other: [] };
|
|
651
|
+
|
|
652
|
+
for (const r of resources) {
|
|
653
|
+
const entry = {
|
|
654
|
+
url: r.name.split('?')[0].split('/').pop().slice(0, 60),
|
|
655
|
+
fullUrl: r.name,
|
|
656
|
+
size: r.transferSize || 0,
|
|
657
|
+
duration: Math.round(r.duration),
|
|
658
|
+
type: r.initiatorType,
|
|
659
|
+
cached: r.transferSize === 0 && r.decodedBodySize > 0,
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
if (r.initiatorType === 'css' || r.name.match(/\.woff2?$|\.ttf$|\.eot$|\.otf$/i)) {
|
|
663
|
+
if (r.name.match(/font|\.woff|\.ttf|\.eot|\.otf/i)) audit.fonts.push(entry);
|
|
664
|
+
else audit.styles.push(entry);
|
|
665
|
+
} else if (r.initiatorType === 'img' || r.name.match(/\.(png|jpg|jpeg|gif|webp|avif|svg)/i)) {
|
|
666
|
+
audit.images.push(entry);
|
|
667
|
+
} else if (r.initiatorType === 'script' || r.name.match(/\.js$/i)) {
|
|
668
|
+
audit.scripts.push(entry);
|
|
669
|
+
} else if (r.initiatorType === 'link' || r.name.match(/\.css$/i)) {
|
|
670
|
+
audit.styles.push(entry);
|
|
671
|
+
} else {
|
|
672
|
+
audit.other.push(entry);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Font analysis
|
|
677
|
+
const fontFaces = document.fonts ? [...document.fonts].map(f => ({
|
|
678
|
+
family: f.family, style: f.style, weight: f.weight, status: f.status,
|
|
679
|
+
})) : [];
|
|
680
|
+
|
|
681
|
+
// Image format analysis
|
|
682
|
+
const images = [...document.images].map(img => ({
|
|
683
|
+
src: img.src?.split('/').pop().slice(0, 60),
|
|
684
|
+
naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight,
|
|
685
|
+
displayWidth: img.width, displayHeight: img.height,
|
|
686
|
+
oversized: img.naturalWidth > img.width * 2 && img.naturalWidth > 200,
|
|
687
|
+
hasAlt: !!img.alt,
|
|
688
|
+
format: (img.src?.match(/\.(webp|avif|png|jpg|jpeg|gif|svg)/i) || ['unknown'])[0],
|
|
689
|
+
lazy: img.loading === 'lazy',
|
|
690
|
+
}));
|
|
691
|
+
|
|
692
|
+
const totalSize = resources.reduce((a, r) => a + (r.transferSize || 0), 0);
|
|
693
|
+
const jsSize = resources.filter(r => r.initiatorType === 'script').reduce((a, r) => a + (r.transferSize || 0), 0);
|
|
694
|
+
const cssSize = resources.filter(r => r.initiatorType === 'link').reduce((a, r) => a + (r.transferSize || 0), 0);
|
|
695
|
+
const imgSize = resources.filter(r => r.initiatorType === 'img').reduce((a, r) => a + (r.transferSize || 0), 0);
|
|
696
|
+
const fontSize = audit.fonts.reduce((a, f) => a + f.size, 0);
|
|
697
|
+
|
|
698
|
+
return { audit, fontFaces, images, totalSize, jsSize, cssSize, imgSize, fontSize };
|
|
699
|
+
}).catch(() => ({}));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
703
|
+
// NEW v15: User Flow Simulator (Playwright)
|
|
704
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
705
|
+
async function simulateUserFlow(page, url) {
|
|
706
|
+
const steps = [];
|
|
707
|
+
const start = Date.now();
|
|
708
|
+
|
|
709
|
+
const step = async (name, fn) => {
|
|
710
|
+
const t0 = Date.now();
|
|
711
|
+
try {
|
|
712
|
+
await fn();
|
|
713
|
+
steps.push({ name, pass: true, duration: Date.now() - t0 });
|
|
714
|
+
} catch (err) {
|
|
715
|
+
steps.push({ name, pass: false, duration: Date.now() - t0, error: err.message });
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
await step('Page load', async () => {
|
|
720
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
|
|
721
|
+
});
|
|
722
|
+
await step('Scroll to bottom', async () => {
|
|
723
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
724
|
+
await page.waitForTimeout(500);
|
|
725
|
+
});
|
|
726
|
+
await step('Scroll back to top', async () => {
|
|
727
|
+
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
|
728
|
+
await page.waitForTimeout(500);
|
|
729
|
+
});
|
|
730
|
+
await step('Hover over first button', async () => {
|
|
731
|
+
const btn = page.locator('button:visible').first();
|
|
732
|
+
if (await btn.count() > 0) await btn.hover();
|
|
733
|
+
else throw new Error('No visible buttons');
|
|
734
|
+
});
|
|
735
|
+
await step('Click first navigation link', async () => {
|
|
736
|
+
const navLink = page.locator('nav a:visible, header a:visible').first();
|
|
737
|
+
if (await navLink.count() > 0) {
|
|
738
|
+
const href = await navLink.getAttribute('href');
|
|
739
|
+
if (href && !href.startsWith('mailto') && !href.startsWith('tel')) {
|
|
740
|
+
await navLink.hover();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
await step('Tab through focusable elements', async () => {
|
|
745
|
+
for (let i = 0; i < 5; i++) {
|
|
746
|
+
await page.keyboard.press('Tab');
|
|
747
|
+
await page.waitForTimeout(80);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
await step('Press Escape key', async () => {
|
|
751
|
+
await page.keyboard.press('Escape');
|
|
752
|
+
await page.waitForTimeout(200);
|
|
753
|
+
});
|
|
754
|
+
await step('Check no modal/dialog stuck', async () => {
|
|
755
|
+
const modal = await page.$('[role="dialog"]:visible, .modal:visible');
|
|
756
|
+
if (modal) throw new Error('Modal still visible after Escape');
|
|
757
|
+
});
|
|
758
|
+
await step('Back button navigation', async () => {
|
|
759
|
+
await page.goBack({ timeout: 5000 }).catch(() => {});
|
|
760
|
+
await page.waitForTimeout(300);
|
|
761
|
+
await page.goForward({ timeout: 5000 }).catch(() => {});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
url, steps, totalDuration: Date.now() - start,
|
|
766
|
+
passed: steps.filter(s => s.pass).length,
|
|
767
|
+
failed: steps.filter(s => !s.pass).length,
|
|
768
|
+
passRate: steps.length > 0 ? Math.round(steps.filter(s => s.pass).length / steps.length * 100) : 0,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
773
|
+
// NEW v15: Multi-Viewport Screenshot + Layout Tester (Playwright)
|
|
774
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
775
|
+
async function testAllViewports(page, url, screenshotDir, sessionId) {
|
|
776
|
+
const results = {};
|
|
777
|
+
for (const [key, vp] of Object.entries(VIEWPORTS)) {
|
|
778
|
+
try {
|
|
779
|
+
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
780
|
+
await page.waitForTimeout(400);
|
|
781
|
+
|
|
782
|
+
const name = `${sessionId}-${key}-${shortId()}.png`;
|
|
783
|
+
const fpath = path.join(screenshotDir, name);
|
|
784
|
+
await page.screenshot({ path: fpath, fullPage: false });
|
|
785
|
+
|
|
786
|
+
// Check for overflow/horizontal scroll
|
|
787
|
+
const hasHorizontalScroll = await page.evaluate(() =>
|
|
788
|
+
document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
789
|
+
);
|
|
790
|
+
// Check font size not too small
|
|
791
|
+
const minFontSize = await page.evaluate(() => {
|
|
792
|
+
const els = [...document.querySelectorAll('p, span, a, li, td')].slice(0, 20);
|
|
793
|
+
return Math.min(...els.map(el => parseFloat(window.getComputedStyle(el).fontSize) || 16));
|
|
794
|
+
}).catch(() => 16);
|
|
795
|
+
|
|
796
|
+
results[key] = {
|
|
797
|
+
label: vp.label, width: vp.width, height: vp.height,
|
|
798
|
+
screenshotName: name, screenshotPath: fpath,
|
|
799
|
+
hasHorizontalScroll, minFontSize,
|
|
800
|
+
issues: [
|
|
801
|
+
hasHorizontalScroll && 'Horizontal scroll detected (layout overflow)',
|
|
802
|
+
minFontSize < 12 && `Font too small: ${minFontSize}px`,
|
|
803
|
+
].filter(Boolean),
|
|
804
|
+
passed: !hasHorizontalScroll && minFontSize >= 12,
|
|
805
|
+
};
|
|
806
|
+
} catch (err) {
|
|
807
|
+
results[key] = { label: vp.label, width: vp.width, height: vp.height, error: err.message, passed: false };
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// Reset to desktop
|
|
811
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
812
|
+
return results;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
816
|
+
// NEW v15: Cache Headers Auditor
|
|
817
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
818
|
+
async function auditCacheHeaders(url) {
|
|
819
|
+
const r = await httpProbe(url);
|
|
820
|
+
const h = r.headers;
|
|
821
|
+
|
|
822
|
+
const cacheControl = h['cache-control'] || '';
|
|
823
|
+
const etag = h['etag'] || null;
|
|
824
|
+
const lastModified = h['last-modified'] || null;
|
|
825
|
+
const expires = h['expires'] || null;
|
|
826
|
+
const vary = h['vary'] || null;
|
|
827
|
+
const age = h['age'] || null;
|
|
828
|
+
const xCache = h['x-cache'] || h['cf-cache-status'] || h['x-vercel-cache'] || null;
|
|
829
|
+
|
|
830
|
+
const maxAge = (cacheControl.match(/max-age=(\d+)/) || [])[1];
|
|
831
|
+
const noStore = cacheControl.includes('no-store');
|
|
832
|
+
const noCache = cacheControl.includes('no-cache');
|
|
833
|
+
const immutable = cacheControl.includes('immutable');
|
|
834
|
+
const private_ = cacheControl.includes('private');
|
|
835
|
+
|
|
836
|
+
const issues = [];
|
|
837
|
+
if (!cacheControl && !expires) issues.push('No Cache-Control or Expires header');
|
|
838
|
+
if (!etag && !lastModified) issues.push('No cache validation (ETag/Last-Modified)');
|
|
839
|
+
if (maxAge && parseInt(maxAge) > 86400 * 365 && !immutable) issues.push('Very long max-age without immutable');
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
url, cacheControl, etag, lastModified, expires, vary, age, xCache,
|
|
843
|
+
maxAge: maxAge ? parseInt(maxAge) : null,
|
|
844
|
+
noStore, noCache, immutable, private: private_,
|
|
845
|
+
issues, passed: issues.length === 0,
|
|
846
|
+
cacheable: !noStore && !!cacheControl,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
851
|
+
// NEW v15: Mixed Content & CSP Violation Checker (Playwright)
|
|
852
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
853
|
+
async function checkMixedContent(page) {
|
|
854
|
+
const mixed = [];
|
|
855
|
+
const cspViolations = [];
|
|
856
|
+
|
|
857
|
+
page.on('console', (msg) => {
|
|
858
|
+
const text = msg.text();
|
|
859
|
+
if (text.includes('Mixed Content')) mixed.push({ type: 'mixed-content', text });
|
|
860
|
+
if (text.includes('Content Security Policy') || text.includes('CSP')) {
|
|
861
|
+
cspViolations.push({ type: 'csp-violation', text });
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
await page.waitForTimeout(1500);
|
|
866
|
+
return { mixed, cspViolations };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
870
|
+
// NEW v15: Error Page Tester (404, 500)
|
|
871
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
872
|
+
async function testErrorPages(baseUrl) {
|
|
873
|
+
const tests = [
|
|
874
|
+
{ url: `${baseUrl}/this-page-definitely-does-not-exist-qa-test-${shortId()}`, expectedStatus: 404, name: '404 Page' },
|
|
875
|
+
{ url: `${baseUrl}/api/this-endpoint-does-not-exist-${shortId()}`, expectedStatus: 404, name: 'API 404' },
|
|
876
|
+
];
|
|
877
|
+
|
|
878
|
+
const results = [];
|
|
879
|
+
for (const t of tests) {
|
|
880
|
+
const r = await httpProbe(t.url);
|
|
881
|
+
const isCorrectStatus = r.status === t.expectedStatus || r.status === 404;
|
|
882
|
+
const hasCustomPage = r.body.length > 200 && !r.body.toLowerCase().includes('cannot get');
|
|
883
|
+
const hasErrorText = /404|not found|page.*not.*found/i.test(r.body);
|
|
884
|
+
|
|
885
|
+
results.push({
|
|
886
|
+
...t, actualStatus: r.status,
|
|
887
|
+
isCorrectStatus, hasCustomPage, hasErrorText,
|
|
888
|
+
bodySize: r.bodySize,
|
|
889
|
+
issues: [
|
|
890
|
+
!isCorrectStatus && `Returns ${r.status} instead of ${t.expectedStatus}`,
|
|
891
|
+
r.status === 200 && 'Returns 200 for non-existent page (soft 404)',
|
|
892
|
+
!hasCustomPage && 'No custom error page',
|
|
893
|
+
].filter(Boolean),
|
|
894
|
+
passed: isCorrectStatus && (hasCustomPage || hasErrorText),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
return results;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
901
|
+
// NEW v15: HTTP Version & TLS Inspector
|
|
902
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
903
|
+
async function inspectHTTPVersion(url) {
|
|
904
|
+
const r = await httpProbe(url);
|
|
905
|
+
const isHTTPS = url.startsWith('https://');
|
|
906
|
+
const altSvc = r.headers['alt-svc'] || '';
|
|
907
|
+
const hasH2 = altSvc.includes('h2') || r.headers['x-powered-by']?.includes('h2');
|
|
908
|
+
const hasH3 = altSvc.includes('h3');
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
url, isHTTPS,
|
|
912
|
+
altSvc: altSvc || null,
|
|
913
|
+
likelyHTTP2: hasH2 || isHTTPS, // Most modern HTTPS servers use H2
|
|
914
|
+
likelyHTTP3: hasH3,
|
|
915
|
+
hsts: r.headers['strict-transport-security'] || null,
|
|
916
|
+
issues: [
|
|
917
|
+
!isHTTPS && 'Not using HTTPS',
|
|
918
|
+
].filter(Boolean),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
923
|
+
// PLAYWRIGHT REAL BROWSER ENGINE v15 — Enhanced
|
|
924
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
925
|
+
async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
926
|
+
const chromium = await getPlaywright();
|
|
927
|
+
if (!chromium) {
|
|
928
|
+
dash?.log(chalk.yellow(' ⚠ Playwright not found. Run: npm install playwright && npx playwright install chromium'));
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
dash?.log(chalk.cyan(` 🎭 backlist browser launching for ${url}...`));
|
|
933
|
+
|
|
934
|
+
let browser, context, page;
|
|
935
|
+
const results = {
|
|
936
|
+
consoleErrors : [],
|
|
937
|
+
networkFails : [],
|
|
938
|
+
screenshots : [],
|
|
939
|
+
vitals : {},
|
|
940
|
+
interactions : [],
|
|
941
|
+
domChecks : [],
|
|
942
|
+
jsErrors : [],
|
|
943
|
+
networkRequests: [],
|
|
944
|
+
darkMode : {},
|
|
945
|
+
viewportResults: {},
|
|
946
|
+
fonts : {},
|
|
947
|
+
thirdParty : [],
|
|
948
|
+
forms : [],
|
|
949
|
+
memoryLeak : {},
|
|
950
|
+
userFlow : {},
|
|
951
|
+
mixedContent : {},
|
|
952
|
+
cspViolations : [],
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
browser = await chromium.launch({
|
|
957
|
+
headless: options.headless !== false,
|
|
958
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--enable-precise-memory-info'],
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
context = await browser.newContext({
|
|
962
|
+
viewport: { width: 1280, height: 900 },
|
|
963
|
+
userAgent: 'Backlist-QA/15.0 (Playwright)',
|
|
964
|
+
ignoreHTTPSErrors: true,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
page = await context.newPage();
|
|
968
|
+
|
|
969
|
+
// ── Mixed Content & CSP violations ──────────────────────────────────
|
|
970
|
+
const mixedContent = [];
|
|
971
|
+
const cspViolations2 = [];
|
|
972
|
+
page.on('console', (msg) => {
|
|
973
|
+
const type = msg.type();
|
|
974
|
+
const text = msg.text();
|
|
975
|
+
if (text.includes('Mixed Content')) mixedContent.push(text);
|
|
976
|
+
if (text.includes('Content Security Policy') || text.includes('refused to')) cspViolations2.push(text);
|
|
977
|
+
if (['error', 'warning'].includes(type)) {
|
|
978
|
+
const entry = { type, text, timestamp: Date.now(), url: page.url() };
|
|
979
|
+
results.consoleErrors.push(entry);
|
|
980
|
+
session.consoleErrors.push(entry);
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// ── Capture JS errors ────────────────────────────────────────────────
|
|
985
|
+
page.on('pageerror', (err) => {
|
|
986
|
+
const entry = { message: err.message, stack: err.stack, url: page.url(), timestamp: Date.now() };
|
|
987
|
+
results.jsErrors.push(entry);
|
|
988
|
+
session.consoleErrors.push({ type: 'pageerror', text: err.message, url: page.url() });
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// ── Network monitoring ───────────────────────────────────────────────
|
|
992
|
+
const requestTimings = new Map();
|
|
993
|
+
page.on('request', (req) => {
|
|
994
|
+
requestTimings.set(req.url(), Date.now());
|
|
995
|
+
});
|
|
996
|
+
page.on('requestfailed', (req) => {
|
|
997
|
+
const entry = {
|
|
998
|
+
url: req.url(), method: req.method(),
|
|
999
|
+
failure: req.failure()?.errorText || 'unknown', timestamp: Date.now(),
|
|
1000
|
+
};
|
|
1001
|
+
results.networkFails.push(entry);
|
|
1002
|
+
session.networkLog.push(entry);
|
|
1003
|
+
});
|
|
1004
|
+
page.on('response', (res) => {
|
|
1005
|
+
const start = requestTimings.get(res.url()) || Date.now();
|
|
1006
|
+
const duration = Date.now() - start;
|
|
1007
|
+
const entry = {
|
|
1008
|
+
url: res.url(), status: res.status(), duration,
|
|
1009
|
+
size: parseInt(res.headers()['content-length'] || '0'),
|
|
1010
|
+
type: res.headers()['content-type'] || '',
|
|
1011
|
+
};
|
|
1012
|
+
results.networkRequests.push(entry);
|
|
1013
|
+
if (res.status() >= 400) {
|
|
1014
|
+
results.networkFails.push({ url: res.url(), status: res.status(), duration });
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// ── Navigate ─────────────────────────────────────────────────────────
|
|
1019
|
+
const navStart = Date.now();
|
|
1020
|
+
const response = await page.goto(url, {
|
|
1021
|
+
waitUntil: 'networkidle', timeout: 30000,
|
|
1022
|
+
}).catch(err => ({ error: err.message }));
|
|
1023
|
+
const navDuration = Date.now() - navStart;
|
|
1024
|
+
|
|
1025
|
+
if (response?.error) {
|
|
1026
|
+
dash?.log(chalk.red(` ✗ Navigation failed: ${response.error}`));
|
|
1027
|
+
return { error: response.error, results };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
await fs.ensureDir(SCREENSHOT_DIR);
|
|
1031
|
+
|
|
1032
|
+
// ── 1. Desktop Screenshot ────────────────────────────────────────────
|
|
1033
|
+
const desktopName = `${session.id}-desktop-${shortId()}.png`;
|
|
1034
|
+
const desktopPath = path.join(SCREENSHOT_DIR, desktopName);
|
|
1035
|
+
await page.screenshot({ path: desktopPath, fullPage: true });
|
|
1036
|
+
results.screenshots.push({ path: desktopPath, name: desktopName, type: 'desktop', url });
|
|
1037
|
+
session.screenshots.push({ path: desktopPath, name: desktopName, type: 'desktop', url });
|
|
1038
|
+
dash?.log(chalk.green(` 📸 Desktop screenshot: ${desktopName}`));
|
|
1039
|
+
|
|
1040
|
+
// ── 2. Multi-Viewport Testing (v15) ──────────────────────────────────
|
|
1041
|
+
dash?.log(chalk.cyan(' 📱 Testing all viewports...'));
|
|
1042
|
+
const vpResults = await testAllViewports(page, url, SCREENSHOT_DIR, session.id);
|
|
1043
|
+
results.viewportResults = vpResults;
|
|
1044
|
+
session.viewportResults = { ...session.viewportResults, ...vpResults };
|
|
1045
|
+
for (const [key, vp] of Object.entries(vpResults)) {
|
|
1046
|
+
if (vp.screenshotName) {
|
|
1047
|
+
session.screenshots.push({ path: vp.screenshotPath, name: vp.screenshotName, type: `viewport-${key}`, url, label: vp.label });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const vpIssues = Object.values(vpResults).filter(v => !v.passed);
|
|
1051
|
+
dash?.log(chalk.green(` ✓ Viewports: ${Object.keys(vpResults).length - vpIssues.length}/${Object.keys(vpResults).length} passed`));
|
|
1052
|
+
|
|
1053
|
+
// ── 3. Dark Mode Test (v15) ───────────────────────────────────────────
|
|
1054
|
+
dash?.log(chalk.cyan(' 🌙 Testing dark mode...'));
|
|
1055
|
+
const darkResult = await testDarkMode(page, url, SCREENSHOT_DIR, session.id);
|
|
1056
|
+
results.darkMode = darkResult;
|
|
1057
|
+
session.darkModeResults.push({ url, ...darkResult });
|
|
1058
|
+
if (darkResult.dark?.screenshotName) {
|
|
1059
|
+
session.screenshots.push({ path: darkResult.dark.screenshotPath, name: darkResult.dark.screenshotName, type: 'dark-mode', url });
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ── 4. Real Web Vitals ────────────────────────────────────────────────
|
|
1063
|
+
dash?.log(chalk.cyan(' ⚡ Measuring real Web Vitals...'));
|
|
1064
|
+
// Navigate fresh for clean vitals
|
|
1065
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
|
|
1066
|
+
const vitals = await page.evaluate(() => {
|
|
1067
|
+
return new Promise((resolve) => {
|
|
1068
|
+
const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
|
|
1069
|
+
let clsVal = 0;
|
|
1070
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
1071
|
+
if (navEntry) v.ttfb = Math.round(navEntry.responseStart - navEntry.requestStart);
|
|
1072
|
+
const paintEntries = performance.getEntriesByType('paint');
|
|
1073
|
+
paintEntries.forEach(e => { if (e.name === 'first-contentful-paint') v.fcp = Math.round(e.startTime); });
|
|
1074
|
+
try { new PerformanceObserver((list) => {
|
|
1075
|
+
const e = list.getEntries(); const last = e[e.length - 1];
|
|
1076
|
+
if (last) v.lcp = Math.round(last.startTime);
|
|
1077
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true }); } catch {}
|
|
1078
|
+
try { new PerformanceObserver((list) => {
|
|
1079
|
+
for (const e of list.getEntries()) { if (!e.hadRecentInput) clsVal += e.value; }
|
|
1080
|
+
v.cls = parseFloat(clsVal.toFixed(4));
|
|
1081
|
+
}).observe({ type: 'layout-shift', buffered: true }); } catch {}
|
|
1082
|
+
try { new PerformanceObserver((list) => {
|
|
1083
|
+
for (const e of list.getEntries()) { if (e.duration > 50) v.tbt += Math.round(e.duration - 50); }
|
|
1084
|
+
}).observe({ type: 'longtask', buffered: true }); } catch {}
|
|
1085
|
+
setTimeout(() => { v.cls = parseFloat(clsVal.toFixed(4)); resolve(v); }, 2500);
|
|
1086
|
+
});
|
|
1087
|
+
}).catch(() => ({}));
|
|
1088
|
+
|
|
1089
|
+
const navTiming = await page.evaluate(() => {
|
|
1090
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
1091
|
+
if (!nav) return {};
|
|
1092
|
+
return {
|
|
1093
|
+
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
|
1094
|
+
domLoad: Math.round(nav.domContentLoadedEventEnd),
|
|
1095
|
+
fullLoad: Math.round(nav.loadEventEnd),
|
|
1096
|
+
dnsLookup: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
|
1097
|
+
tcpConnect: Math.round(nav.connectEnd - nav.connectStart),
|
|
1098
|
+
transferSize: nav.transferSize,
|
|
1099
|
+
};
|
|
1100
|
+
}).catch(() => ({}));
|
|
1101
|
+
|
|
1102
|
+
results.vitals = { ...vitals, ...navTiming, navDuration };
|
|
1103
|
+
dash?.log(chalk.green(` ✓ Vitals: TTFB=${navTiming.ttfb||'?'}ms LCP=${vitals.lcp||'?'}ms FCP=${vitals.fcp||'?'}ms CLS=${vitals.cls??'?'}`));
|
|
1104
|
+
|
|
1105
|
+
// ── 5. Memory Leak Detection (v15) ────────────────────────────────────
|
|
1106
|
+
dash?.log(chalk.cyan(' 🧠 Detecting memory leaks...'));
|
|
1107
|
+
const memResult = await detectMemoryLeaks(page, url);
|
|
1108
|
+
results.memoryLeak = memResult;
|
|
1109
|
+
session.memorySnapshots.push({ url, ...memResult });
|
|
1110
|
+
if (memResult.hasLeak) {
|
|
1111
|
+
dash?.log(chalk.yellow(` ⚠ Memory leak detected: +${memResult.growthMB}MB`));
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// ── 6. DOM Checks ────────────────────────────────────────────────────
|
|
1115
|
+
dash?.log(chalk.cyan(' 🔍 Running DOM checks...'));
|
|
1116
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
|
|
1117
|
+
const domChecks = await page.evaluate(() => {
|
|
1118
|
+
const checks = [];
|
|
1119
|
+
const title = document.title;
|
|
1120
|
+
checks.push({ name: 'Page title', pass: !!title && title.length > 0, value: title?.slice(0, 80) });
|
|
1121
|
+
const h1s = document.querySelectorAll('h1');
|
|
1122
|
+
checks.push({ name: 'Single H1', pass: h1s.length === 1, value: `${h1s.length} H1 tags` });
|
|
1123
|
+
const imgs = document.querySelectorAll('img');
|
|
1124
|
+
const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
|
|
1125
|
+
checks.push({ name: 'Images alt text', pass: noAlt === 0, value: `${noAlt}/${imgs.length} missing alt` });
|
|
1126
|
+
const btns = document.querySelectorAll('button');
|
|
1127
|
+
const noText = [...btns].filter(b => !b.textContent?.trim() && !b.getAttribute('aria-label')).length;
|
|
1128
|
+
checks.push({ name: 'Buttons accessible', pass: noText === 0, value: `${noText} buttons missing label` });
|
|
1129
|
+
const links = document.querySelectorAll('a');
|
|
1130
|
+
const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
|
|
1131
|
+
checks.push({ name: 'Links have href', pass: noHref === 0, value: `${noHref}/${links.length} empty links` });
|
|
1132
|
+
const forms = document.querySelectorAll('form');
|
|
1133
|
+
const noSubmit = [...forms].filter(f => !f.querySelector('[type="submit"], button')).length;
|
|
1134
|
+
checks.push({ name: 'Forms have submit', pass: noSubmit === 0 || forms.length === 0, value: `${forms.length} forms` });
|
|
1135
|
+
const vp = document.querySelector('meta[name="viewport"]');
|
|
1136
|
+
checks.push({ name: 'Viewport meta', pass: !!vp, value: vp?.content || 'missing' });
|
|
1137
|
+
const bodyStyle = window.getComputedStyle(document.body);
|
|
1138
|
+
checks.push({ name: 'Body has styles', pass: !!bodyStyle.backgroundColor || !!bodyStyle.color, value: 'CSS applied' });
|
|
1139
|
+
// NEW v15 DOM checks
|
|
1140
|
+
const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-link');
|
|
1141
|
+
checks.push({ name: 'Skip navigation link', pass: !!skipLink, value: skipLink ? 'Present' : 'Missing (accessibility)' });
|
|
1142
|
+
const mainEl = document.querySelector('main, [role="main"]');
|
|
1143
|
+
checks.push({ name: 'Main landmark', pass: !!mainEl, value: mainEl ? 'Present' : 'Missing' });
|
|
1144
|
+
const footerEl = document.querySelector('footer, [role="contentinfo"]');
|
|
1145
|
+
checks.push({ name: 'Footer landmark', pass: !!footerEl, value: footerEl ? 'Present' : 'Missing' });
|
|
1146
|
+
const focusableEls = document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
|
|
1147
|
+
checks.push({ name: 'Focusable elements exist', pass: focusableEls.length > 0, value: `${focusableEls.length} focusable` });
|
|
1148
|
+
const langAttr = document.documentElement.lang;
|
|
1149
|
+
checks.push({ name: 'HTML lang attribute', pass: !!langAttr, value: langAttr || 'missing' });
|
|
1150
|
+
const canonical = document.querySelector('link[rel="canonical"]');
|
|
1151
|
+
checks.push({ name: 'Canonical URL', pass: !!canonical, value: canonical?.href || 'missing' });
|
|
1152
|
+
const ogTitle = document.querySelector('meta[property="og:title"]');
|
|
1153
|
+
checks.push({ name: 'Open Graph title', pass: !!ogTitle, value: ogTitle?.content?.slice(0, 60) || 'missing' });
|
|
1154
|
+
const robots = document.querySelector('meta[name="robots"]');
|
|
1155
|
+
checks.push({ name: 'Robots meta', pass: true, value: robots?.content || 'none (indexable)' });
|
|
1156
|
+
const internalLinks = [...links].filter(l => { try { return new URL(l.href).origin === window.location.origin; } catch { return false; } });
|
|
1157
|
+
checks.push({ name: 'Internal links count', pass: true, value: `${internalLinks.length} internal links` });
|
|
1158
|
+
return checks;
|
|
1159
|
+
}).catch(() => []);
|
|
1160
|
+
|
|
1161
|
+
results.domChecks = domChecks;
|
|
1162
|
+
dash?.log(chalk.green(` ✓ DOM: ${domChecks.filter(c => c.pass).length}/${domChecks.length} checks passed`));
|
|
1163
|
+
|
|
1164
|
+
// ── 7. Form Tests (v15) ───────────────────────────────────────────────
|
|
1165
|
+
dash?.log(chalk.cyan(' 📝 Testing forms...'));
|
|
1166
|
+
const formResults = await testForms(page, url);
|
|
1167
|
+
results.forms = formResults;
|
|
1168
|
+
session.formTests.push(...formResults.map(f => ({ url, ...f })));
|
|
1169
|
+
|
|
1170
|
+
// ── 8. Third-Party Script Audit (v15) ─────────────────────────────────
|
|
1171
|
+
dash?.log(chalk.cyan(' 📦 Auditing third-party scripts...'));
|
|
1172
|
+
const thirdPartyScripts = await auditThirdPartyScripts(page);
|
|
1173
|
+
results.thirdParty = thirdPartyScripts;
|
|
1174
|
+
session.thirdPartyScripts.push(...thirdPartyScripts.map(s => ({ url, ...s })));
|
|
1175
|
+
|
|
1176
|
+
// ── 9. Font & Asset Audit (v15) ───────────────────────────────────────
|
|
1177
|
+
dash?.log(chalk.cyan(' 🔤 Auditing fonts and assets...'));
|
|
1178
|
+
const assetData = await auditFontsAndAssets(page);
|
|
1179
|
+
results.fonts = assetData;
|
|
1180
|
+
session.assetAudit.push({ url, ...assetData });
|
|
1181
|
+
|
|
1182
|
+
// ── 10. Interaction Tests ─────────────────────────────────────────────
|
|
1183
|
+
dash?.log(chalk.cyan(' 🖱️ Testing interactions...'));
|
|
1184
|
+
const interactions = [];
|
|
1185
|
+
const buttonCount = await page.locator('button:visible').count().catch(() => 0);
|
|
1186
|
+
interactions.push({ name: 'Visible buttons found', pass: true, value: `${buttonCount} buttons` });
|
|
1187
|
+
const inputCount = await page.locator('input:visible').count().catch(() => 0);
|
|
1188
|
+
interactions.push({ name: 'Form inputs found', pass: true, value: `${inputCount} inputs` });
|
|
1189
|
+
try {
|
|
1190
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
1191
|
+
await page.waitForTimeout(300);
|
|
1192
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
1193
|
+
interactions.push({ name: 'Page scroll', pass: true, value: 'Scroll works' });
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
interactions.push({ name: 'Page scroll', pass: false, value: err.message });
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
await page.keyboard.press('Tab');
|
|
1199
|
+
await page.waitForTimeout(100);
|
|
1200
|
+
const focused = await page.evaluate(() => document.activeElement?.tagName || 'none');
|
|
1201
|
+
interactions.push({ name: 'Keyboard navigation', pass: focused !== 'BODY', value: `Focus: ${focused}` });
|
|
1202
|
+
} catch {
|
|
1203
|
+
interactions.push({ name: 'Keyboard navigation', pass: false, value: 'Tab focus failed' });
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
const firstLink = page.locator('a:visible').first();
|
|
1207
|
+
if (await firstLink.count() > 0) { await firstLink.hover(); interactions.push({ name: 'Link hover', pass: true, value: 'Hover works' }); }
|
|
1208
|
+
} catch { interactions.push({ name: 'Link hover', pass: false, value: 'Hover failed' }); }
|
|
1209
|
+
// NEW v15: right-click test
|
|
1210
|
+
try {
|
|
1211
|
+
await page.mouse.click(640, 400, { button: 'right' });
|
|
1212
|
+
await page.waitForTimeout(200);
|
|
1213
|
+
await page.keyboard.press('Escape');
|
|
1214
|
+
interactions.push({ name: 'Right-click (context menu)', pass: true, value: 'Works' });
|
|
1215
|
+
} catch { interactions.push({ name: 'Right-click', pass: false, value: 'Failed' }); }
|
|
1216
|
+
// NEW v15: copy text test
|
|
1217
|
+
try {
|
|
1218
|
+
await page.keyboard.press('Control+a');
|
|
1219
|
+
await page.waitForTimeout(100);
|
|
1220
|
+
interactions.push({ name: 'Select all text', pass: true, value: 'Ctrl+A works' });
|
|
1221
|
+
} catch { interactions.push({ name: 'Select all text', pass: false, value: 'Failed' }); }
|
|
1222
|
+
|
|
1223
|
+
results.interactions = interactions;
|
|
1224
|
+
dash?.log(chalk.green(` ✓ Interactions: ${interactions.filter(i => i.pass).length}/${interactions.length} passed`));
|
|
1225
|
+
|
|
1226
|
+
// ── 11. User Flow Simulation (v15) ────────────────────────────────────
|
|
1227
|
+
dash?.log(chalk.cyan(' 🧑💻 Simulating user flow...'));
|
|
1228
|
+
const flowResult = await simulateUserFlow(page, url);
|
|
1229
|
+
results.userFlow = flowResult;
|
|
1230
|
+
session.userFlowResults.push(flowResult);
|
|
1231
|
+
dash?.log(chalk.green(` ✓ User flow: ${flowResult.passed}/${flowResult.steps.length} steps passed`));
|
|
1232
|
+
|
|
1233
|
+
// ── 12. Resource Analysis ─────────────────────────────────────────────
|
|
1234
|
+
const resourceStats = await page.evaluate(() => {
|
|
1235
|
+
const entries = performance.getEntriesByType('resource');
|
|
1236
|
+
const byType = {};
|
|
1237
|
+
let totalSize = 0;
|
|
1238
|
+
for (const e of entries) {
|
|
1239
|
+
const t = e.initiatorType || 'other';
|
|
1240
|
+
if (!byType[t]) byType[t] = { count: 0, size: 0, time: 0, slow: [] };
|
|
1241
|
+
byType[t].count++;
|
|
1242
|
+
byType[t].size += e.transferSize || 0;
|
|
1243
|
+
byType[t].time += e.duration;
|
|
1244
|
+
totalSize += e.transferSize || 0;
|
|
1245
|
+
if (e.duration > 500) byType[t].slow.push({ url: e.name.split('/').pop().slice(0, 60), duration: Math.round(e.duration), size: e.transferSize || 0 });
|
|
1246
|
+
}
|
|
1247
|
+
return { byType, totalSize, count: entries.length };
|
|
1248
|
+
}).catch(() => ({}));
|
|
1249
|
+
|
|
1250
|
+
results.resourceStats = resourceStats;
|
|
1251
|
+
results.mixedContent = mixedContent;
|
|
1252
|
+
results.cspViolations = cspViolations2;
|
|
1253
|
+
|
|
1254
|
+
// Store mixed content/CSP in session
|
|
1255
|
+
session.mixedContentIssues.push(...mixedContent.map(m => ({ url, text: m })));
|
|
1256
|
+
session.cspViolations.push(...cspViolations2.map(c => ({ url, text: c })));
|
|
1257
|
+
|
|
1258
|
+
return { results, navDuration, error: null };
|
|
1259
|
+
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
dash?.log(chalk.red(` ✗ Playwright error: ${err.message}`));
|
|
1262
|
+
return { error: err.message, results };
|
|
1263
|
+
} finally {
|
|
1264
|
+
try { await page?.close(); } catch {}
|
|
1265
|
+
try { await context?.close(); } catch {}
|
|
1266
|
+
try { await browser?.close(); } catch {}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1271
|
+
// Route Crawler — real HTTP crawl
|
|
139
1272
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
140
|
-
async function crawlSite(baseUrl, { maxPages =
|
|
1273
|
+
async function crawlSite(baseUrl, { maxPages = 60, onRoute } = {}) {
|
|
141
1274
|
const visited = new Set();
|
|
142
1275
|
const queue = [{ url: baseUrl, depth: 0 }];
|
|
143
1276
|
const routes = [];
|
|
144
1277
|
|
|
145
|
-
const norm
|
|
1278
|
+
const norm = (u) => { try { const x = new URL(u); x.hash = ''; return x.toString(); } catch { return null; } };
|
|
146
1279
|
const sameOrigin = (u) => { try { return new URL(u).origin === new URL(baseUrl).origin; } catch { return false; } };
|
|
147
1280
|
|
|
148
1281
|
while (queue.length > 0 && routes.length < maxPages) {
|
|
149
1282
|
const { url, depth } = queue.shift();
|
|
150
1283
|
const n = norm(url);
|
|
151
|
-
if (!n || visited.has(n) || !sameOrigin(n) || depth >
|
|
1284
|
+
if (!n || visited.has(n) || !sameOrigin(n) || depth > 4) continue;
|
|
152
1285
|
visited.add(n);
|
|
153
1286
|
|
|
154
|
-
const r
|
|
1287
|
+
const r = await httpProbe(n, { timeout: 10000 });
|
|
155
1288
|
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))
|
|
1289
|
+
if (r.status >= 400) return 'error-page';
|
|
1290
|
+
if (r.contentType.includes('json') || n.includes('/api/')) return 'api';
|
|
1291
|
+
if (n.endsWith('.xml') || n.endsWith('.txt')) return 'resource';
|
|
1292
|
+
if (/\/(login|signin|auth)/i.test(n)) return 'auth';
|
|
1293
|
+
if (/\/(admin)/i.test(n)) return 'admin';
|
|
1294
|
+
if (/\.(css|js|woff|png|jpg|gif|svg|ico)/i.test(n)) return 'asset';
|
|
161
1295
|
return 'page';
|
|
162
1296
|
})();
|
|
163
1297
|
|
|
164
|
-
// Extract links from HTML
|
|
165
1298
|
const links = [];
|
|
166
1299
|
if (r.contentType.includes('text/html')) {
|
|
167
1300
|
const re = /href=["']([^"'#?][^"']*?)["']/gi;
|
|
@@ -171,7 +1304,6 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
171
1304
|
}
|
|
172
1305
|
}
|
|
173
1306
|
|
|
174
|
-
// Extract forms
|
|
175
1307
|
const forms = [];
|
|
176
1308
|
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
177
1309
|
let fm;
|
|
@@ -179,16 +1311,16 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
179
1311
|
const action = (fm[1].match(/action=["']([^"']+)["']/) || [])[1] || '';
|
|
180
1312
|
const method = (fm[1].match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
|
|
181
1313
|
const fields = [];
|
|
182
|
-
const ir
|
|
1314
|
+
const ir = /<input([^>]*)>/gi; let inp;
|
|
183
1315
|
while ((inp = ir.exec(fm[2])) !== null) {
|
|
184
|
-
const name
|
|
1316
|
+
const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
|
|
185
1317
|
const type2 = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
|
|
186
1318
|
if (name) fields.push({ name, type: type2, required: /required/i.test(inp[1]) });
|
|
187
1319
|
}
|
|
188
1320
|
forms.push({ action, method, fields });
|
|
189
1321
|
}
|
|
190
1322
|
|
|
191
|
-
const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error };
|
|
1323
|
+
const route = { id: shortId(), url: n, type, status: r.status, depth, links, forms, contentType: r.contentType, error: r.error, responseTime: r.responseTime };
|
|
192
1324
|
routes.push(route);
|
|
193
1325
|
if (onRoute) onRoute(route);
|
|
194
1326
|
|
|
@@ -198,8 +1330,15 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
198
1330
|
}
|
|
199
1331
|
}
|
|
200
1332
|
|
|
201
|
-
//
|
|
202
|
-
const commonPaths = [
|
|
1333
|
+
// Common paths probe (v15 extended)
|
|
1334
|
+
const commonPaths = [
|
|
1335
|
+
'/api/health', '/health', '/api/status', '/api/v1/health',
|
|
1336
|
+
'/api/docs', '/robots.txt', '/sitemap.xml', '/manifest.json',
|
|
1337
|
+
'/sw.js', '/service-worker.js', '/favicon.ico',
|
|
1338
|
+
'/.well-known/security.txt', '/security.txt',
|
|
1339
|
+
'/api/v1', '/api/v2', '/graphql',
|
|
1340
|
+
'/changelog', '/version',
|
|
1341
|
+
];
|
|
203
1342
|
for (const p2 of commonPaths) {
|
|
204
1343
|
try {
|
|
205
1344
|
const u = new URL(p2, baseUrl).toString();
|
|
@@ -208,7 +1347,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
208
1347
|
visited.add(n);
|
|
209
1348
|
const r = await httpProbe(u, { timeout: 5000 });
|
|
210
1349
|
if (r.status > 0 && r.status < 500) {
|
|
211
|
-
const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
|
|
1350
|
+
const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [], responseTime: r.responseTime };
|
|
212
1351
|
routes.push(route);
|
|
213
1352
|
if (onRoute) onRoute(route);
|
|
214
1353
|
}
|
|
@@ -219,11 +1358,11 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
219
1358
|
}
|
|
220
1359
|
|
|
221
1360
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
-
// Security Scanner —
|
|
1361
|
+
// Security Scanner v15 — Extended
|
|
223
1362
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
224
1363
|
async function runSecurityScan(url) {
|
|
225
1364
|
const findings = [];
|
|
226
|
-
const r
|
|
1365
|
+
const r = await httpProbe(url);
|
|
227
1366
|
|
|
228
1367
|
if (!r.ok && r.status === 0) {
|
|
229
1368
|
return [{ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
|
|
@@ -233,55 +1372,86 @@ async function runSecurityScan(url) {
|
|
|
233
1372
|
const h = r.headers;
|
|
234
1373
|
|
|
235
1374
|
const headerChecks = [
|
|
236
|
-
{ id: 'csp', name: 'Content-Security-Policy',
|
|
1375
|
+
{ id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
|
|
237
1376
|
validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
|
|
238
|
-
{ id: 'hsts', name: 'HSTS',
|
|
1377
|
+
{ id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
|
|
239
1378
|
validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
|
|
240
|
-
{ id: 'xframe', name: 'X-Frame-Options',
|
|
1379
|
+
{ id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
|
|
241
1380
|
validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
|
|
242
|
-
{ id: 'xcto', name: 'X-Content-Type-Options',
|
|
1381
|
+
{ id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
|
|
243
1382
|
validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
|
|
244
|
-
{ id: 'rp', name: 'Referrer-Policy',
|
|
1383
|
+
{ id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
|
|
245
1384
|
validate: v => !!v, rec: 'Add Referrer-Policy header' },
|
|
246
|
-
{ id: '
|
|
1385
|
+
{ id: 'pp', name: 'Permissions-Policy', header: 'permissions-policy', sev: 'P2',
|
|
1386
|
+
validate: v => !!v, rec: 'Add Permissions-Policy to restrict browser features' },
|
|
1387
|
+
{ id: 'server', name: 'Server version hidden', header: 'server', sev: 'P2',
|
|
247
1388
|
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
|
|
1389
|
+
{ id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
|
|
1390
|
+
validate: v => !v, rec: 'Remove X-Powered-By header' },
|
|
1391
|
+
// NEW v15
|
|
1392
|
+
{ id: 'coep', name: 'Cross-Origin-Embedder-Policy', header: 'cross-origin-embedder-policy', sev: 'P3',
|
|
1393
|
+
validate: v => !!v, rec: 'Add COEP for isolation' },
|
|
1394
|
+
{ id: 'coop', name: 'Cross-Origin-Opener-Policy', header: 'cross-origin-opener-policy', sev: 'P3',
|
|
1395
|
+
validate: v => !!v, rec: 'Add COOP header' },
|
|
250
1396
|
];
|
|
251
1397
|
|
|
252
1398
|
for (const c of headerChecks) {
|
|
253
1399
|
const val = h[c.header] || '';
|
|
254
1400
|
const pass = c.validate(val);
|
|
255
|
-
findings.push({
|
|
1401
|
+
findings.push({
|
|
1402
|
+
check: c.name, pass, severity: pass ? 'INFO' : c.sev,
|
|
256
1403
|
category: 'headers', detail: pass ? `${c.header}: ${val || '(present)'}` : `Missing: ${c.header}`,
|
|
257
|
-
recommendation: c.rec, evidence: { header: c.header, value: val || null }
|
|
1404
|
+
recommendation: c.rec, evidence: { header: c.header, value: val || null },
|
|
1405
|
+
});
|
|
258
1406
|
}
|
|
259
1407
|
|
|
260
|
-
// HTTPS check
|
|
261
1408
|
const isHTTPS = url.startsWith('https://');
|
|
262
|
-
findings.push({
|
|
263
|
-
|
|
264
|
-
|
|
1409
|
+
findings.push({
|
|
1410
|
+
check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
|
|
1411
|
+
category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted',
|
|
1412
|
+
recommendation: 'Use HTTPS with valid SSL',
|
|
1413
|
+
});
|
|
265
1414
|
|
|
266
|
-
// CORS wildcard check
|
|
267
1415
|
const corsOrigin = h['access-control-allow-origin'];
|
|
268
1416
|
const corsCreds = h['access-control-allow-credentials'];
|
|
269
1417
|
const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
|
|
270
|
-
findings.push({
|
|
271
|
-
severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
1418
|
+
findings.push({
|
|
1419
|
+
check: 'CORS wildcard + credentials', pass: corsPass, severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
272
1420
|
detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
|
|
273
1421
|
recommendation: 'Never combine CORS * with allow-credentials',
|
|
274
|
-
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
// NEW v15: Check for version disclosure in other headers
|
|
1425
|
+
const versionHeaders = ['x-aspnet-version', 'x-aspnetmvc-version', 'x-drupal-cache', 'x-generator'];
|
|
1426
|
+
for (const vh of versionHeaders) {
|
|
1427
|
+
if (h[vh]) findings.push({
|
|
1428
|
+
check: `${vh} disclosure`, pass: false, severity: 'P2', category: 'information-disclosure',
|
|
1429
|
+
detail: `${vh}: ${h[vh]}`, recommendation: `Remove ${vh} header`,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
275
1432
|
|
|
276
|
-
// Probe sensitive paths
|
|
277
1433
|
const base = new URL(url).origin;
|
|
278
1434
|
const sensitives = [
|
|
279
|
-
{ path: '/.env',
|
|
280
|
-
{ path: '/.
|
|
281
|
-
{ path: '/
|
|
282
|
-
{ path: '/
|
|
283
|
-
{ path: '/
|
|
284
|
-
{ path: '/
|
|
1435
|
+
{ path: '/.env', name: '.env exposed' },
|
|
1436
|
+
{ path: '/.env.local', name: '.env.local exposed' },
|
|
1437
|
+
{ path: '/.git/config', name: 'Git config exposed' },
|
|
1438
|
+
{ path: '/phpinfo.php', name: 'phpinfo exposed' },
|
|
1439
|
+
{ path: '/server-status', name: 'Apache server-status' },
|
|
1440
|
+
{ path: '/actuator', name: 'Spring actuator' },
|
|
1441
|
+
{ path: '/actuator/env', name: 'Spring actuator env' },
|
|
1442
|
+
{ path: '/graphql', name: 'GraphQL introspection' },
|
|
1443
|
+
{ path: '/api/swagger.json', name: 'Swagger docs exposed' },
|
|
1444
|
+
{ path: '/api/openapi.json', name: 'OpenAPI docs exposed' },
|
|
1445
|
+
{ path: '/config.json', name: 'config.json exposed' },
|
|
1446
|
+
{ path: '/debug', name: 'Debug endpoint' },
|
|
1447
|
+
// NEW v15
|
|
1448
|
+
{ path: '/.DS_Store', name: '.DS_Store exposed' },
|
|
1449
|
+
{ path: '/wp-config.php', name: 'WordPress config' },
|
|
1450
|
+
{ path: '/package.json', name: 'package.json exposed' },
|
|
1451
|
+
{ path: '/composer.json', name: 'composer.json exposed' },
|
|
1452
|
+
{ path: '/.htaccess', name: '.htaccess exposed' },
|
|
1453
|
+
{ path: '/backup.sql', name: 'SQL backup exposed' },
|
|
1454
|
+
{ path: '/dump.sql', name: 'SQL dump exposed' },
|
|
285
1455
|
];
|
|
286
1456
|
for (const s of sensitives) {
|
|
287
1457
|
try {
|
|
@@ -290,11 +1460,13 @@ async function runSecurityScan(url) {
|
|
|
290
1460
|
const res = await fetch(`${base}${s.path}`, { signal: ctrl.signal, redirect: 'manual' });
|
|
291
1461
|
clearTimeout(timer);
|
|
292
1462
|
const exposed = res.status === 200;
|
|
293
|
-
findings.push({
|
|
1463
|
+
findings.push({
|
|
1464
|
+
check: s.name, pass: !exposed, severity: exposed ? 'P0' : 'INFO',
|
|
294
1465
|
category: 'information-disclosure',
|
|
295
1466
|
detail: exposed ? `EXPOSED at ${base}${s.path}` : `Not exposed: ${s.path}`,
|
|
296
1467
|
recommendation: exposed ? `Block access to ${s.path} immediately` : null,
|
|
297
|
-
evidence: { url: `${base}${s.path}`, status: res.status }
|
|
1468
|
+
evidence: { url: `${base}${s.path}`, status: res.status },
|
|
1469
|
+
});
|
|
298
1470
|
} catch {}
|
|
299
1471
|
}
|
|
300
1472
|
|
|
@@ -302,13 +1474,13 @@ async function runSecurityScan(url) {
|
|
|
302
1474
|
}
|
|
303
1475
|
|
|
304
1476
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
305
|
-
// SEO Scanner —
|
|
1477
|
+
// SEO Scanner v15 — Extended
|
|
306
1478
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
307
1479
|
async function runSEOScan(url) {
|
|
308
|
-
const t0
|
|
309
|
-
const r
|
|
1480
|
+
const t0 = Date.now();
|
|
1481
|
+
const r = await httpProbe(url, { headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' } });
|
|
310
1482
|
const html = r.body || '';
|
|
311
|
-
const rt
|
|
1483
|
+
const rt = Date.now() - t0;
|
|
312
1484
|
const checks = [];
|
|
313
1485
|
|
|
314
1486
|
const has = (p) => p.test(html);
|
|
@@ -316,108 +1488,81 @@ async function runSEOScan(url) {
|
|
|
316
1488
|
|
|
317
1489
|
const title = get(/<title[^>]*>([^<]+)<\/title>/i);
|
|
318
1490
|
checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
|
|
319
|
-
detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>'
|
|
320
|
-
recommendation: 'Add unique title (50-60 chars)' });
|
|
321
|
-
|
|
1491
|
+
detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>' });
|
|
322
1492
|
if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
|
|
323
|
-
severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)
|
|
324
|
-
recommendation: 'Keep title 30-60 chars' });
|
|
1493
|
+
severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)` });
|
|
325
1494
|
|
|
326
1495
|
const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
|
327
1496
|
|| get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
328
1497
|
checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
|
|
329
|
-
detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description'
|
|
330
|
-
|
|
1498
|
+
detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description' });
|
|
1499
|
+
if (desc) checks.push({ name: 'Description length', pass: desc.length >= 120 && desc.length <= 160,
|
|
1500
|
+
severity: 'P2', category: 'meta', detail: `${desc.length} chars (optimal 120-160)` });
|
|
331
1501
|
|
|
332
1502
|
const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
|
|
333
1503
|
checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
|
|
334
|
-
detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count}
|
|
335
|
-
|
|
1504
|
+
detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count} H1s (should be 1)` : '1 H1 ✓' });
|
|
1505
|
+
|
|
1506
|
+
const h2Count = (html.match(/<h2[^>]*>/gi) || []).length;
|
|
1507
|
+
checks.push({ name: 'H2 tags', pass: h2Count > 0, severity: 'P3', category: 'structure',
|
|
1508
|
+
detail: `${h2Count} H2 tags found` });
|
|
336
1509
|
|
|
337
1510
|
const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
|
|
338
|
-
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
|
|
339
|
-
detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
|
|
340
|
-
recommendation: 'Add <meta name="viewport" content="width=device-width,initial-scale=1">' });
|
|
1511
|
+
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile', detail: hasVP ? 'Viewport found' : 'Missing' });
|
|
341
1512
|
|
|
342
1513
|
const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
|
|
343
|
-
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
|
|
344
|
-
detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
|
|
1514
|
+
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo', detail: lang ? `lang="${lang}"` : 'Missing' });
|
|
345
1515
|
|
|
346
1516
|
const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
|
|
347
|
-
checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
|
|
348
|
-
detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
|
|
349
|
-
recommendation: 'Add <link rel="canonical">' });
|
|
1517
|
+
checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo', detail: canonical ? `Canonical: ${canonical}` : 'Missing' });
|
|
350
1518
|
|
|
351
1519
|
const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
|
|
352
|
-
checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
|
|
353
|
-
|
|
354
|
-
|
|
1520
|
+
checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social', detail: ogOk ? 'OG tags present' : 'Missing og:title/description' });
|
|
1521
|
+
const ogImage = has(/<meta[^>]+property=["']og:image["']/i);
|
|
1522
|
+
checks.push({ name: 'OG image', pass: ogImage, severity: 'P2', category: 'social', detail: ogImage ? 'og:image present' : 'Missing og:image' });
|
|
1523
|
+
|
|
1524
|
+
const twitterCard = has(/<meta[^>]+name=["']twitter:card["']/i);
|
|
1525
|
+
checks.push({ name: 'Twitter Card', pass: twitterCard, severity: 'P3', category: 'social', detail: twitterCard ? 'Twitter card present' : 'Missing' });
|
|
355
1526
|
|
|
356
|
-
const
|
|
357
|
-
|
|
1527
|
+
const structuredData = has(/<script[^>]+type=["']application\/ld\+json["']/i);
|
|
1528
|
+
checks.push({ name: 'Structured data (JSON-LD)', pass: structuredData, severity: 'P2', category: 'structured-data', detail: structuredData ? 'JSON-LD found' : 'No structured data' });
|
|
1529
|
+
|
|
1530
|
+
const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
|
|
1531
|
+
const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
|
|
358
1532
|
checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
|
|
359
|
-
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt
|
|
360
|
-
recommendation: 'Add alt text to all images' });
|
|
1533
|
+
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt` });
|
|
361
1534
|
|
|
362
1535
|
checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
|
|
363
|
-
category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)
|
|
364
|
-
|
|
1536
|
+
category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)` });
|
|
1537
|
+
|
|
1538
|
+
// NEW v15: Heading hierarchy
|
|
1539
|
+
const headings = (html.match(/<h[1-6][^>]*>/gi) || []).map(h => parseInt(h[2]));
|
|
1540
|
+
let hierOk = true;
|
|
1541
|
+
for (let i = 1; i < headings.length; i++) {
|
|
1542
|
+
if (headings[i] > headings[i-1] + 1) { hierOk = false; break; }
|
|
1543
|
+
}
|
|
1544
|
+
checks.push({ name: 'Heading hierarchy', pass: hierOk, severity: 'P2', category: 'structure', detail: hierOk ? 'Headings in order' : 'Skipped heading levels' });
|
|
1545
|
+
|
|
1546
|
+
// NEW v15: noindex check
|
|
1547
|
+
const noindex = has(/<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*noindex/i);
|
|
1548
|
+
checks.push({ name: 'Not noindexed', pass: !noindex, severity: noindex ? 'P1' : 'INFO', category: 'crawling', detail: noindex ? 'Page is noindexed!' : 'Indexable' });
|
|
365
1549
|
|
|
366
|
-
// robots.txt & sitemap
|
|
367
1550
|
const base = new URL(url).origin;
|
|
368
1551
|
for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
|
|
369
1552
|
try {
|
|
370
1553
|
const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
|
|
371
1554
|
checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
|
|
372
|
-
detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}
|
|
373
|
-
recommendation: `Ensure ${name} exists` });
|
|
1555
|
+
detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}` });
|
|
374
1556
|
} catch {
|
|
375
1557
|
checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
|
|
376
1558
|
}
|
|
377
1559
|
}
|
|
378
1560
|
|
|
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
|
-
};
|
|
1561
|
+
return { pass: checks.filter(c => !c.pass && c.severity !== 'P3').length === 0, checks, url, responseTime: rt };
|
|
417
1562
|
}
|
|
418
1563
|
|
|
419
1564
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
420
|
-
// Accessibility Scanner —
|
|
1565
|
+
// Accessibility Scanner v15 — Extended
|
|
421
1566
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
422
1567
|
async function runA11yScan(url) {
|
|
423
1568
|
const r = await httpProbe(url, { timeout: 12000 });
|
|
@@ -425,20 +1570,28 @@ async function runA11yScan(url) {
|
|
|
425
1570
|
const violations = [], passes = [];
|
|
426
1571
|
|
|
427
1572
|
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),
|
|
1573
|
+
{ id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html), pass: 'HTML lang present', desc: 'HTML must have lang attribute' },
|
|
1574
|
+
{ id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html), pass: 'Images have alt text', desc: 'Images must have alternate text' },
|
|
1575
|
+
{ id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html), pass: 'Document has title', desc: 'Document must have title' },
|
|
1576
|
+
{ id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html), pass: 'Viewport allows scaling', desc: 'Viewport must not disable scaling' },
|
|
1577
|
+
{ id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html), pass: 'Main landmark present', desc: 'Page should have <main>' },
|
|
1578
|
+
{ id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html), pass: 'H1 heading present', desc: 'Page should have H1' },
|
|
1579
|
+
{ id: 'link-text', impact: 'serious', test: () => /<a[^>]*>\s*<\/a>/i.test(html), pass: 'Links have text', desc: 'Links must have discernible text' },
|
|
435
1580
|
{ 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' },
|
|
1581
|
+
// NEW v15
|
|
1582
|
+
{ id: 'skip-nav', impact: 'moderate', test: () => !/<a[^>]*href=["']#(?:main|content|skip)[^"']*["']/i.test(html), pass: 'Skip nav link', desc: 'Page should have skip navigation link' },
|
|
1583
|
+
{ id: 'table-headers', impact: 'serious', test: () => /<table/i.test(html) && !/<th/i.test(html), pass: 'Tables have headers', desc: 'Tables must have header cells' },
|
|
1584
|
+
{ id: 'input-purpose', impact: 'serious', test: () => /<input[^>]*type=["'](email|tel|name)[^"']*["']/i.test(html) && !/<input[^>]*autocomplete/i.test(html), pass: 'Inputs have autocomplete', desc: 'Contact inputs should have autocomplete' },
|
|
1585
|
+
{ id: 'focus-visible', impact: 'serious', test: () => /:focus\s*\{\s*outline\s*:\s*none/i.test(html) || /:focus\s*\{\s*outline\s*:\s*0/i.test(html), pass: 'Focus indicator not removed', desc: 'CSS must not hide focus indicator' },
|
|
1586
|
+
{ id: 'color-contrast-meta', impact: 'moderate', test: () => false, pass: 'Color contrast (check manually with browser)', desc: 'Ensure sufficient color contrast' },
|
|
1587
|
+
{ id: 'nav-landmark', impact: 'moderate', test: () => !/<nav[^>]*>/i.test(html), pass: 'Nav landmark present', desc: 'Navigation should use <nav>' },
|
|
1588
|
+
{ id: 'button-type', impact: 'minor', test: () => /<button(?![^>]*type=)/i.test(html), pass: 'Buttons have type', desc: 'Buttons should have explicit type attribute' },
|
|
436
1589
|
];
|
|
437
1590
|
|
|
438
1591
|
for (const c of checks) {
|
|
439
1592
|
if (c.test()) {
|
|
440
1593
|
violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
|
|
441
|
-
tags: ['wcag2a'], category: 'wcag2a', nodes: 1,
|
|
1594
|
+
tags: ['wcag2a'], category: 'wcag2a', nodes: 1,
|
|
442
1595
|
helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
|
|
443
1596
|
} else {
|
|
444
1597
|
passes.push({ id: c.id, description: c.pass, nodes: 1 });
|
|
@@ -446,63 +1599,68 @@ async function runA11yScan(url) {
|
|
|
446
1599
|
}
|
|
447
1600
|
|
|
448
1601
|
const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
|
|
449
|
-
return { pass: violations.length === 0, violations, passes, incomplete: [], score, url
|
|
1602
|
+
return { pass: violations.length === 0, violations, passes, incomplete: [], score, url };
|
|
450
1603
|
}
|
|
451
1604
|
|
|
452
1605
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
453
|
-
// AI Bug Classifier —
|
|
1606
|
+
// AI Bug Classifier v15 — Enhanced patterns
|
|
454
1607
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
455
1608
|
const SEV_PATTERNS = {
|
|
456
|
-
P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
|
|
457
|
-
P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
|
|
458
|
-
P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
|
|
459
|
-
P3: [/warning|minor|style|typo|cosmetic/i],
|
|
1609
|
+
P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical|\.env.*exposed|git.*config.*exposed|database.*dump/i, /crash|fatal|500|server.*down|data.*loss|memory.*leak.*critical/i],
|
|
1610
|
+
P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error|mixed.*content/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious|viewport.*overflow/i],
|
|
1611
|
+
P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue|cookie.*missing|broken.*link/i],
|
|
1612
|
+
P3: [/warning|minor|style|typo|cosmetic|twitter.*card/i],
|
|
460
1613
|
};
|
|
461
1614
|
const CAT_PATTERNS = {
|
|
462
|
-
security: /security|csp|hsts|cors|xss|injection|auth|token/i,
|
|
463
|
-
performance: /lcp|fcp|cls|ttfb|slow|timeout|render/i,
|
|
464
|
-
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,
|
|
1615
|
+
security : /security|csp|hsts|cors|xss|injection|auth|token|cookie|env.*exposed/i,
|
|
1616
|
+
performance : /lcp|fcp|cls|ttfb|slow|timeout|render|memory.*leak|resource/i,
|
|
1617
|
+
accessibility : /wcag|a11y|aria|alt.*text|contrast|keyboard|skip.*nav|focus/i,
|
|
1618
|
+
seo : /title|meta|description|canonical|sitemap|robots|structured.*data/i,
|
|
1619
|
+
api : /api|endpoint|status.*code|response|rest|contract/i,
|
|
1620
|
+
javascript : /js.*error|console.*error|uncaught|undefined|null|pageerror/i,
|
|
1621
|
+
network : /network|fetch|connection|request.*fail|broken.*link/i,
|
|
1622
|
+
viewport : /viewport|responsive|mobile|overflow|horizontal.*scroll/i,
|
|
1623
|
+
darkMode : /dark.*mode|color.*scheme|prefers-color/i,
|
|
469
1624
|
};
|
|
470
1625
|
function classifyBug(bug) {
|
|
471
1626
|
const text = `${bug.title} ${bug.description || ''}`;
|
|
472
1627
|
let severity = bug.severity || 'P3', confidence = 0.7;
|
|
473
1628
|
for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
|
|
474
|
-
if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.
|
|
1629
|
+
if (pats.some(p => p.test(text))) { severity = sev; confidence = 0.87; break; }
|
|
475
1630
|
}
|
|
476
1631
|
let category = bug.type || 'general';
|
|
477
1632
|
for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
|
|
478
1633
|
if (pat.test(text)) { category = cat; break; }
|
|
479
1634
|
}
|
|
480
1635
|
const recs = {
|
|
481
|
-
security: 'Review security config and run penetration test',
|
|
482
|
-
performance: 'Run Lighthouse
|
|
483
|
-
accessibility: 'Fix WCAG 2.1 AA violations with
|
|
484
|
-
seo: 'Fix meta tags and submit sitemap to Search Console',
|
|
485
|
-
api: 'Check API contract
|
|
486
|
-
javascript: 'Debug in
|
|
487
|
-
network: 'Check CDN, server logs, network config',
|
|
1636
|
+
security : 'Review security config immediately and run penetration test',
|
|
1637
|
+
performance : 'Run Lighthouse, optimize assets, review bundle size',
|
|
1638
|
+
accessibility : 'Fix WCAG 2.1 AA violations with axe DevTools',
|
|
1639
|
+
seo : 'Fix meta tags and submit updated sitemap to Search Console',
|
|
1640
|
+
api : 'Check API contract, add proper error handling and typing',
|
|
1641
|
+
javascript : 'Debug in DevTools, add error boundaries, check for undefined refs',
|
|
1642
|
+
network : 'Check CDN, server logs, review network config and CORS policy',
|
|
1643
|
+
viewport : 'Test on real devices, fix overflow, check responsive breakpoints',
|
|
1644
|
+
darkMode : 'Add prefers-color-scheme media query support',
|
|
488
1645
|
};
|
|
489
|
-
return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
|
|
1646
|
+
return { severity, category, recommendation: recs[category] || 'Review and fix error details', confidence };
|
|
490
1647
|
}
|
|
491
1648
|
|
|
492
1649
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
-
// Terminal Dashboard — live
|
|
1650
|
+
// Terminal Dashboard v15 — Enhanced live display
|
|
494
1651
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
495
1652
|
class TerminalDashboard {
|
|
496
1653
|
#session; #lines = 0; #active = false; #timer = null;
|
|
497
1654
|
#phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
|
|
1655
|
+
#pwMode = false; #subPhase = '';
|
|
498
1656
|
|
|
499
|
-
constructor(s) { this.#session = s; }
|
|
1657
|
+
constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
|
|
500
1658
|
|
|
501
1659
|
start() {
|
|
502
1660
|
this.#active = true; this.#startTime = Date.now();
|
|
503
1661
|
process.stdout.write('\x1b[?25l');
|
|
504
1662
|
this.#render();
|
|
505
|
-
this.#timer = setInterval(() => this.#render(),
|
|
1663
|
+
this.#timer = setInterval(() => this.#render(), 400);
|
|
506
1664
|
}
|
|
507
1665
|
|
|
508
1666
|
stop() {
|
|
@@ -513,19 +1671,20 @@ class TerminalDashboard {
|
|
|
513
1671
|
this.#printFinal();
|
|
514
1672
|
}
|
|
515
1673
|
|
|
516
|
-
setPhase(p)
|
|
1674
|
+
setPhase(p) { this.#phase = p; this.#subPhase = ''; this.log(chalk.cyan(p)); }
|
|
1675
|
+
setSubPhase(p) { this.#subPhase = p; }
|
|
517
1676
|
setCurrentTest(t) { this.#currentTest = t; }
|
|
518
|
-
addResult()
|
|
1677
|
+
addResult() { this.#currentTest = ''; }
|
|
519
1678
|
log(msg) {
|
|
520
1679
|
this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
|
|
521
|
-
if (this.#log.length >
|
|
1680
|
+
if (this.#log.length > 10) this.#log.shift();
|
|
522
1681
|
}
|
|
523
1682
|
|
|
524
1683
|
#render() {
|
|
525
1684
|
if (!this.#active) return;
|
|
526
1685
|
this.#clear();
|
|
527
1686
|
const lines = this.#build();
|
|
528
|
-
this.#lines
|
|
1687
|
+
this.#lines = lines.length;
|
|
529
1688
|
process.stdout.write(lines.join('\n') + '\n');
|
|
530
1689
|
}
|
|
531
1690
|
|
|
@@ -546,77 +1705,90 @@ class TerminalDashboard {
|
|
|
546
1705
|
const total = s.results.length;
|
|
547
1706
|
const rate = total > 0 ? Math.round(passed / total * 100) : 0;
|
|
548
1707
|
const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
|
|
549
|
-
const w = Math.min(process.stdout.columns ||
|
|
1708
|
+
const w = Math.min(process.stdout.columns || 90, 92);
|
|
550
1709
|
const bar = '─'.repeat(w - 2);
|
|
551
1710
|
const c1 = chalk.hex('#00F5FF');
|
|
552
1711
|
const c2 = chalk.hex('#BF40FF');
|
|
553
1712
|
const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
|
|
1713
|
+
const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 BACKLIST') : chalk.gray(' HTTP');
|
|
1714
|
+
|
|
1715
|
+
const spin = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'][Math.floor(Date.now() / 100) % 10];
|
|
554
1716
|
|
|
555
1717
|
const pBar = (() => {
|
|
556
|
-
const f
|
|
1718
|
+
const f = Math.min(Math.round(rate / 100 * 30), 30);
|
|
557
1719
|
const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
|
|
558
|
-
return col('█'.repeat(f)) + chalk.gray('░'.repeat(
|
|
1720
|
+
return col('█'.repeat(f)) + chalk.gray('░'.repeat(30 - f));
|
|
559
1721
|
})();
|
|
560
1722
|
|
|
561
1723
|
const out = [
|
|
562
1724
|
c1(`┌${bar}┐`),
|
|
563
|
-
c1('│') + c2.bold(pad(` ⚡ BACKLIST
|
|
1725
|
+
c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — ULTRA LIVE TESTING EDITION${pwTag}`)) + c1('│'),
|
|
564
1726
|
c1(`├${bar}┤`),
|
|
565
1727
|
c1('│') + pad(` ${chalk.cyan('Phase:')} ${chalk.white(this.#phase.slice(0, w - 14))}`) + c1('│'),
|
|
1728
|
+
this.#subPhase
|
|
1729
|
+
? c1('│') + pad(` ${chalk.gray('↳')} ${chalk.gray(this.#subPhase.slice(0, w - 8))}`) + c1('│')
|
|
1730
|
+
: c1('│') + pad('') + c1('│'),
|
|
566
1731
|
c1(`├${bar}┤`),
|
|
567
|
-
c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
|
|
1732
|
+
c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.magenta('📸')} ${chalk.bold(s.screenshots.length)} ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
|
|
568
1733
|
c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1('│'),
|
|
569
1734
|
c1(`├${bar}┤`),
|
|
570
|
-
c1('│') + pad(this.#currentTest ? ` ${chalk.yellow(
|
|
1735
|
+
c1('│') + pad(this.#currentTest ? ` ${chalk.yellow(spin)} ${chalk.yellow(this.#currentTest.slice(0, w - 8))}` : ` ${chalk.gray('⊙ Scanning...')}`) + c1('│'),
|
|
571
1736
|
c1(`├${bar}┤`),
|
|
572
|
-
c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('
|
|
1737
|
+
c1('│') + pad(` ${chalk.cyan('Routes:')} ${chalk.white(s.routeMap.length)} ${chalk.cyan('Sec:')} ${chalk.white(s.secFindings.length)} ${chalk.cyan('Broken links:')} ${chalk.white(s.brokenLinks.length)} ${chalk.cyan('3rd-party:')} ${chalk.white(s.thirdPartyScripts.length)} ${chalk.cyan('Net Err:')} ${chalk.white(s.networkLog.length)}`) + c1('│'),
|
|
573
1738
|
c1(`├${bar}┤`),
|
|
574
1739
|
];
|
|
575
1740
|
|
|
576
|
-
const recent = s.results.slice(-
|
|
1741
|
+
const recent = s.results.slice(-6);
|
|
577
1742
|
for (const r of recent) {
|
|
578
1743
|
const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
|
|
579
|
-
out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(
|
|
1744
|
+
out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(14) + ']')} ${chalk.white((r.name||'').slice(0, w - 32))}`) + c1('│'));
|
|
580
1745
|
}
|
|
581
|
-
for (let i = recent.length; i <
|
|
1746
|
+
for (let i = recent.length; i < 6; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
582
1747
|
|
|
583
1748
|
out.push(c1(`├${bar}┤`));
|
|
584
|
-
for (const entry of this.#log.slice(-
|
|
1749
|
+
for (const entry of this.#log.slice(-5)) {
|
|
585
1750
|
out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
|
|
586
1751
|
}
|
|
587
|
-
for (let i = this.#log.length; i <
|
|
1752
|
+
for (let i = this.#log.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
588
1753
|
out.push(c1(`└${bar}┘`));
|
|
589
|
-
out.push(chalk.dim(`
|
|
1754
|
+
out.push(chalk.dim(` v${VERSION} · ${total} tests · ${s.bugs.length} bugs · ${s.screenshots.length} screenshots · Ctrl+C to stop`));
|
|
590
1755
|
|
|
591
1756
|
return out;
|
|
592
1757
|
}
|
|
593
1758
|
|
|
594
1759
|
#printFinal() {
|
|
595
|
-
const s
|
|
1760
|
+
const s = this.#session.getSummary();
|
|
596
1761
|
const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
|
|
597
1762
|
console.log('');
|
|
598
|
-
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(`
|
|
1763
|
+
console.log(chalk.hex('#00F5FF').bold(' ── QA Complete (v15) ───────────────────────────────────────'));
|
|
1764
|
+
console.log(` Tests: ${chalk.white.bold(s.total)}`);
|
|
1765
|
+
console.log(` Passed: ${chalk.green.bold(s.passed)}`);
|
|
1766
|
+
console.log(` Failed: ${chalk.red.bold(s.failed)}`);
|
|
1767
|
+
console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
|
|
1768
|
+
console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
|
|
1769
|
+
console.log(` Screenshots: ${chalk.magenta.bold(this.#session.screenshots.length)} (${Object.keys(VIEWPORTS).length} viewports)`);
|
|
1770
|
+
console.log(` Broken Links:${chalk.white(this.#session.brokenLinks.length)}`);
|
|
1771
|
+
console.log(` 3rd Party: ${chalk.white(this.#session.thirdPartyScripts.length)} scripts`);
|
|
1772
|
+
console.log(` Load Test: ${this.#session.loadTestResults.length > 0 ? chalk.white(this.#session.loadTestResults[0].rps + ' req/s') : chalk.gray('not run')}`);
|
|
1773
|
+
console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
|
|
1774
|
+
console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Backlist (Real Browser)') : chalk.gray('HTTP-only')}`);
|
|
606
1775
|
console.log('');
|
|
607
1776
|
}
|
|
608
1777
|
}
|
|
609
1778
|
|
|
610
1779
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
611
|
-
// HTML Report Builder —
|
|
1780
|
+
// HTML Report Builder v15 — Ultra Rich
|
|
612
1781
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
613
1782
|
function buildHTMLReport(session) {
|
|
614
|
-
const summary
|
|
615
|
-
const passRate
|
|
616
|
-
const rateColor
|
|
1783
|
+
const summary = session.getSummary();
|
|
1784
|
+
const passRate = Number(summary.passRate);
|
|
1785
|
+
const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
|
|
617
1786
|
|
|
618
|
-
const sevCounts
|
|
619
|
-
session.bugs.forEach(b => {
|
|
1787
|
+
const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
1788
|
+
session.bugs.forEach(b => {
|
|
1789
|
+
const key = b.aiSeverity || b.severity;
|
|
1790
|
+
if (sevCounts[key] !== undefined) sevCounts[key]++;
|
|
1791
|
+
});
|
|
620
1792
|
|
|
621
1793
|
const coverage = {};
|
|
622
1794
|
for (const r of session.results) {
|
|
@@ -627,6 +1799,39 @@ function buildHTMLReport(session) {
|
|
|
627
1799
|
|
|
628
1800
|
const esc = (s) => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
629
1801
|
|
|
1802
|
+
// ── Screenshot gallery ───────────────────────────────────────────────────
|
|
1803
|
+
const screenshotsByType = {};
|
|
1804
|
+
for (const sc of session.screenshots) {
|
|
1805
|
+
const t = sc.type || 'other';
|
|
1806
|
+
if (!screenshotsByType[t]) screenshotsByType[t] = [];
|
|
1807
|
+
screenshotsByType[t].push(sc);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const screenshotCards = session.screenshots.length
|
|
1811
|
+
? session.screenshots.map(sc => {
|
|
1812
|
+
let imgTag = '';
|
|
1813
|
+
try {
|
|
1814
|
+
const data = fs.readFileSync(sc.path);
|
|
1815
|
+
imgTag = `<img src="data:image/png;base64,${data.toString('base64')}" alt="${esc(sc.type)}" loading="lazy">`;
|
|
1816
|
+
} catch {
|
|
1817
|
+
imgTag = `<div class="no-img">📸 ${esc(sc.name)}</div>`;
|
|
1818
|
+
}
|
|
1819
|
+
const typeColors = {
|
|
1820
|
+
desktop: '#00f5ff', mobile: '#bf40ff', 'dark-mode': '#f59e0b',
|
|
1821
|
+
};
|
|
1822
|
+
const typeColor = Object.entries(typeColors).find(([k]) => (sc.type||'').includes(k))?.[1] || '#64748b';
|
|
1823
|
+
return `<div class="screenshot-card">
|
|
1824
|
+
<div class="sc-header" style="border-bottom-color:${typeColor}33">
|
|
1825
|
+
<span class="sc-type" style="background:${typeColor}22;color:${typeColor}">${esc(sc.label || sc.type)}</span>
|
|
1826
|
+
<span class="sc-url">${esc((sc.url || '').split('/').slice(0,4).join('/'))}</span>
|
|
1827
|
+
</div>
|
|
1828
|
+
<div class="sc-img-wrap">${imgTag}</div>
|
|
1829
|
+
<div class="sc-path">${esc(sc.name)}</div>
|
|
1830
|
+
</div>`;
|
|
1831
|
+
}).join('')
|
|
1832
|
+
: '<p class="no-data">No screenshots (Playwright not available)</p>';
|
|
1833
|
+
|
|
1834
|
+
// ── Test rows ─────────────────────────────────────────────────────────────
|
|
630
1835
|
const testRows = session.results.map(r => `
|
|
631
1836
|
<tr class="result-row" data-type="${r.type}" data-status="${r.status}">
|
|
632
1837
|
<td>${esc(r.name)}</td>
|
|
@@ -637,29 +1842,35 @@ function buildHTMLReport(session) {
|
|
|
637
1842
|
<td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${esc(String(r.message).slice(0,500))}</pre></details>` : '✓'}</td>
|
|
638
1843
|
</tr>`).join('');
|
|
639
1844
|
|
|
640
|
-
|
|
641
|
-
|
|
1845
|
+
// ── Bug cards ─────────────────────────────────────────────────────────────
|
|
1846
|
+
const bugCards = session.bugs.length
|
|
1847
|
+
? session.bugs.map(b => `
|
|
1848
|
+
<div class="bug-card sev-border-${(b.aiSeverity||b.severity||'p3').toLowerCase()}" data-severity="${b.aiSeverity||b.severity}">
|
|
642
1849
|
<div class="bug-header">
|
|
643
1850
|
<span class="bug-id">${esc(b.id)}</span>
|
|
644
1851
|
<span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
|
|
645
|
-
<span class="badge">${b.type||'general'}</span>
|
|
1852
|
+
<span class="badge">${b.aiCategory||b.type||'general'}</span>
|
|
646
1853
|
${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
|
|
647
1854
|
</div>
|
|
648
1855
|
<div class="bug-title">${esc(b.title)}</div>
|
|
649
1856
|
${b.url ? `<div class="bug-url"><a href="${esc(b.url)}" target="_blank">${esc(b.url)}</a></div>` : ''}
|
|
650
1857
|
${b.aiRecommendation ? `<div class="bug-rec">💡 ${esc(b.aiRecommendation)}</div>` : ''}
|
|
651
1858
|
${b.evidence ? `<details><summary>Evidence</summary><pre>${esc(JSON.stringify(b.evidence,null,2).slice(0,800))}</pre></details>` : ''}
|
|
652
|
-
</div>`).join('')
|
|
1859
|
+
</div>`).join('')
|
|
1860
|
+
: '<p class="no-data">No bugs detected 🎉</p>';
|
|
653
1861
|
|
|
1862
|
+
// ── Route rows ────────────────────────────────────────────────────────────
|
|
654
1863
|
const routeRows = session.routeMap.map(r => `
|
|
655
1864
|
<tr>
|
|
656
1865
|
<td><code class="url">${esc(r.url)}</code></td>
|
|
657
1866
|
<td><span class="badge">${r.type}</span></td>
|
|
658
1867
|
<td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
|
|
1868
|
+
<td>${r.responseTime ? `${r.responseTime}ms` : '–'}</td>
|
|
659
1869
|
<td>${r.forms?.length || 0}</td>
|
|
660
1870
|
<td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
|
|
661
1871
|
</tr>`).join('');
|
|
662
1872
|
|
|
1873
|
+
// ── Security rows ─────────────────────────────────────────────────────────
|
|
663
1874
|
const secRows = session.secFindings.map(f => `
|
|
664
1875
|
<tr class="${f.pass ? '' : 'fail-row'}">
|
|
665
1876
|
<td>${esc(f.check)}</td>
|
|
@@ -670,52 +1881,39 @@ function buildHTMLReport(session) {
|
|
|
670
1881
|
<td>${f.recommendation ? `<span class="rec">${esc(f.recommendation)}</span>` : '–'}</td>
|
|
671
1882
|
</tr>`).join('');
|
|
672
1883
|
|
|
1884
|
+
// ── SEO section ───────────────────────────────────────────────────────────
|
|
673
1885
|
const seoSection = session.seoResults.map(r => `
|
|
674
1886
|
<div class="seo-page">
|
|
675
|
-
<div class="seo-header"
|
|
676
|
-
<
|
|
1887
|
+
<div class="seo-header">
|
|
1888
|
+
<a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
1889
|
+
<span>${r.checks.filter(c=>c.pass).length}/${r.checks.length} passed</span>
|
|
1890
|
+
</div>
|
|
677
1891
|
<table>
|
|
678
1892
|
<thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr></thead>
|
|
679
|
-
<tbody>${(r.checks||[]).map(c => `<tr
|
|
1893
|
+
<tbody>${(r.checks||[]).map(c => `<tr>
|
|
1894
|
+
<td>${esc(c.name)}</td><td>${c.category||'–'}</td>
|
|
680
1895
|
<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
|
|
1896
|
+
<td>${esc((c.detail||'').slice(0,100))}</td>
|
|
1897
|
+
</tr>`).join('')}</tbody>
|
|
682
1898
|
</table>
|
|
683
1899
|
</div>`).join('') || '<p class="no-data">No SEO scans</p>';
|
|
684
1900
|
|
|
1901
|
+
// ── A11y section ──────────────────────────────────────────────────────────
|
|
685
1902
|
const a11ySection = session.a11yResults.map(r => `
|
|
686
1903
|
<div class="a11y-page">
|
|
687
|
-
<div class="a11y-header"
|
|
1904
|
+
<div class="a11y-header">
|
|
1905
|
+
<a href="${esc(r.url)}" target="_blank">${esc(r.url)}</a>
|
|
688
1906
|
<span>Score: <strong>${r.score??'–'}%</strong></span>
|
|
689
|
-
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
</div>`).join('') || '<p class="no-data">No violations ✓</p>'}
|
|
1907
|
+
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
|
|
1908
|
+
</div>
|
|
1909
|
+
${(r.violations||[]).map(v => `<div class="violation impact-${v.impact}">
|
|
1910
|
+
<div class="violation-header"><span class="impact-badge">${v.impact}</span><strong>${esc(v.description)}</strong></div>
|
|
1911
|
+
<p>${esc(v.help)}</p>
|
|
1912
|
+
</div>`).join('') || '<p class="no-data">No violations ✓</p>'}
|
|
696
1913
|
</div>`).join('') || '<p class="no-data">No accessibility scans</p>';
|
|
697
1914
|
|
|
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) {
|
|
1915
|
+
// ── Performance section ───────────────────────────────────────────────────
|
|
1916
|
+
const vitalCard = (name, value, threshold, unit) => {
|
|
719
1917
|
const na = value === null || value === undefined;
|
|
720
1918
|
const pass2 = !na && value <= threshold;
|
|
721
1919
|
const cls = na ? 'vital-na' : pass2 ? 'vital-pass' : 'vital-fail';
|
|
@@ -726,22 +1924,284 @@ function buildHTMLReport(session) {
|
|
|
726
1924
|
<div class="vital-value" style="color:${color}">${disp}</div>
|
|
727
1925
|
<div class="vital-threshold">≤${threshold}${unit}</div>
|
|
728
1926
|
</div>`;
|
|
729
|
-
}
|
|
1927
|
+
};
|
|
1928
|
+
|
|
1929
|
+
const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => {
|
|
1930
|
+
const domChecksHtml = m.domChecks?.length ? `
|
|
1931
|
+
<h4 style="color:#94a3b8;margin-top:1.5rem">DOM Checks</h4>
|
|
1932
|
+
<table><thead><tr><th>Check</th><th>Status</th><th>Value</th></tr></thead>
|
|
1933
|
+
<tbody>${m.domChecks.map(c => `<tr><td>${esc(c.name)}</td>
|
|
1934
|
+
<td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
|
|
1935
|
+
<td>${esc(c.value||'')}</td></tr>`).join('')}</tbody></table>` : '';
|
|
1936
|
+
|
|
1937
|
+
const interactionsHtml = m.interactions?.length ? `
|
|
1938
|
+
<h4 style="color:#94a3b8;margin-top:1.5rem">Interaction Tests</h4>
|
|
1939
|
+
<table><thead><tr><th>Test</th><th>Status</th><th>Value</th></tr></thead>
|
|
1940
|
+
<tbody>${m.interactions.map(i => `<tr><td>${esc(i.name)}</td>
|
|
1941
|
+
<td><span class="status ${i.pass?'status-pass':'status-fail'}">${i.pass?'PASS':'FAIL'}</span></td>
|
|
1942
|
+
<td>${esc(i.value||'')}</td></tr>`).join('')}</tbody></table>` : '';
|
|
1943
|
+
|
|
1944
|
+
const resourceHtml = m.resourceStats?.byType ? `
|
|
1945
|
+
<h4 style="color:#94a3b8;margin-top:1.5rem">Resource Breakdown</h4>
|
|
1946
|
+
<table><thead><tr><th>Type</th><th>Count</th><th>Total Size</th><th>Total Time</th></tr></thead>
|
|
1947
|
+
<tbody>${Object.entries(m.resourceStats.byType).map(([t, d]) => `<tr>
|
|
1948
|
+
<td><span class="badge">${esc(t)}</span></td><td>${d.count}</td>
|
|
1949
|
+
<td>${formatBytes(d.size)}</td><td>${Math.round(d.time)}ms</td></tr>`).join('')}</tbody></table>` : '';
|
|
1950
|
+
|
|
1951
|
+
return `<div class="perf-card">
|
|
1952
|
+
<h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Real Vitals</span>' : ''}</h3>
|
|
1953
|
+
<div class="vitals-grid">
|
|
1954
|
+
${vitalCard('TTFB', m.ttfb, 800, 'ms')}
|
|
1955
|
+
${vitalCard('LCP', m.lcp, 2500, 'ms')}
|
|
1956
|
+
${vitalCard('FCP', m.fcp, 1800, 'ms')}
|
|
1957
|
+
${vitalCard('CLS', m.cls, 0.1, '')}
|
|
1958
|
+
${vitalCard('TBT', m.tbt, 200, 'ms')}
|
|
1959
|
+
${vitalCard('DOM Load', m.domLoad, 3000, 'ms')}
|
|
1960
|
+
${vitalCard('DNS', m.dnsLookup, 100, 'ms')}
|
|
1961
|
+
${vitalCard('TCP', m.tcpConnect, 200, 'ms')}
|
|
1962
|
+
</div>
|
|
1963
|
+
${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
|
|
1964
|
+
${resourceHtml}${domChecksHtml}${interactionsHtml}
|
|
1965
|
+
</div>`;
|
|
1966
|
+
}).join('') || '<p class="no-data">No performance data</p>';
|
|
1967
|
+
|
|
1968
|
+
// ── NEW v15: Load Test section ────────────────────────────────────────────
|
|
1969
|
+
const loadTestSection = session.loadTestResults.length
|
|
1970
|
+
? session.loadTestResults.map(lt => `
|
|
1971
|
+
<div class="load-test-card ${lt.passed ? 'lt-pass' : 'lt-fail'}">
|
|
1972
|
+
<h3>${esc(lt.url)} <span class="badge">${lt.concurrency} concurrent</span></h3>
|
|
1973
|
+
<div class="vitals-grid">
|
|
1974
|
+
${vitalCard('RPS', lt.rps, 10, '')}
|
|
1975
|
+
${vitalCard('Avg Lat', lt.latency?.avg, 500, 'ms')}
|
|
1976
|
+
${vitalCard('p95', lt.latency?.p95, 2000, 'ms')}
|
|
1977
|
+
${vitalCard('p99', lt.latency?.p99, 5000, 'ms')}
|
|
1978
|
+
${vitalCard('Error%', lt.errorRate, 5, '%')}
|
|
1979
|
+
</div>
|
|
1980
|
+
<p style="color:#94a3b8;font-size:.8rem;margin-top:.75rem">${lt.requests} requests · ${lt.errors} errors · ${lt.timeouts} timeouts · ${formatDuration(lt.duration)}</p>
|
|
1981
|
+
<p style="color:#94a3b8;font-size:.78rem">Status codes: ${Object.entries(lt.responses||{}).map(([k,v]) => `${k}: ${v}`).join(', ')}</p>
|
|
1982
|
+
</div>`).join('')
|
|
1983
|
+
: '<p class="no-data">Load test not run (use runUrlQA with loadTest:true)</p>';
|
|
1984
|
+
|
|
1985
|
+
// ── NEW v15: Viewport section ─────────────────────────────────────────────
|
|
1986
|
+
const vpSection = Object.keys(session.viewportResults || {}).length
|
|
1987
|
+
? `<div class="viewport-grid">${Object.entries(session.viewportResults).map(([key, vp]) => `
|
|
1988
|
+
<div class="vp-card ${vp.passed ? '' : 'vp-fail'}">
|
|
1989
|
+
<div class="vp-label">${esc(vp.label || key)}</div>
|
|
1990
|
+
<div class="vp-dims">${vp.width}×${vp.height}</div>
|
|
1991
|
+
<div class="vp-status"><span class="status ${vp.passed?'status-pass':'status-fail'}">${vp.passed?'PASS':'FAIL'}</span></div>
|
|
1992
|
+
${(vp.issues||[]).map(i => `<div class="vp-issue">⚠ ${esc(i)}</div>`).join('')}
|
|
1993
|
+
</div>`).join('')}</div>`
|
|
1994
|
+
: '<p class="no-data">No viewport tests (Playwright required)</p>';
|
|
1995
|
+
|
|
1996
|
+
// ── NEW v15: Broken links section ─────────────────────────────────────────
|
|
1997
|
+
const brokenLinksSection = session.brokenLinks.length
|
|
1998
|
+
? session.brokenLinks.map(bl => `
|
|
1999
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2000
|
+
<div class="card-title">Broken Links on ${esc(bl.sourceUrl)} <span>${bl.broken}/${bl.total} broken</span></div>
|
|
2001
|
+
${bl.links?.filter(l => !l.ok).length ? `<table>
|
|
2002
|
+
<thead><tr><th>URL</th><th>Status</th><th>Error</th></tr></thead>
|
|
2003
|
+
<tbody>${bl.links.filter(l => !l.ok).map(l => `<tr>
|
|
2004
|
+
<td class="url">${esc(l.url)}</td>
|
|
2005
|
+
<td class="fail">${l.status || 'timeout'}</td>
|
|
2006
|
+
<td class="fail">${esc(l.error || '')}</td>
|
|
2007
|
+
</tr>`).join('')}</tbody>
|
|
2008
|
+
</table>` : '<p class="no-data">No broken links 🎉</p>'}
|
|
2009
|
+
</div>`).join('')
|
|
2010
|
+
: '<p class="no-data">No broken link scans run</p>';
|
|
2011
|
+
|
|
2012
|
+
// ── NEW v15: Cookie audit ─────────────────────────────────────────────────
|
|
2013
|
+
const cookieSection = session.cookieAudit.length
|
|
2014
|
+
? session.cookieAudit.map(ca => `
|
|
2015
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2016
|
+
<div class="card-title">Cookies for ${esc(ca.url)} <span>${ca.total} cookies · ${ca.issues} with issues</span></div>
|
|
2017
|
+
${ca.cookies?.length ? `<table>
|
|
2018
|
+
<thead><tr><th>Name</th><th>Secure</th><th>HttpOnly</th><th>SameSite</th><th>Issues</th></tr></thead>
|
|
2019
|
+
<tbody>${ca.cookies.map(c => `<tr class="${c.issues.length ? 'fail-row' : ''}">
|
|
2020
|
+
<td><code>${esc(c.name)}</code></td>
|
|
2021
|
+
<td>${c.secure ? '✓' : '<span class="fail">✗</span>'}</td>
|
|
2022
|
+
<td>${c.httpOnly ? '✓' : '<span class="fail">✗</span>'}</td>
|
|
2023
|
+
<td>${c.sameSite || '<span class="fail">missing</span>'}</td>
|
|
2024
|
+
<td>${c.issues.map(i => `<span class="sev sev-p2">${esc(i)}</span>`).join(' ')}</td>
|
|
2025
|
+
</tr>`).join('')}</tbody>
|
|
2026
|
+
</table>` : '<p class="no-data">No cookies set</p>'}
|
|
2027
|
+
</div>`).join('')
|
|
2028
|
+
: '<p class="no-data">No cookie audits</p>';
|
|
2029
|
+
|
|
2030
|
+
// ── NEW v15: Third-party scripts ──────────────────────────────────────────
|
|
2031
|
+
const thirdPartySection = session.thirdPartyScripts.length
|
|
2032
|
+
? `<table>
|
|
2033
|
+
<thead><tr><th>Vendor</th><th>Domain</th><th>Count</th><th>URL</th></tr></thead>
|
|
2034
|
+
<tbody>${session.thirdPartyScripts.map(s => `<tr>
|
|
2035
|
+
<td><strong>${esc(s.vendor)}</strong></td>
|
|
2036
|
+
<td>${esc(s.domain)}</td>
|
|
2037
|
+
<td>${s.count || 1}</td>
|
|
2038
|
+
<td class="url">${esc((s.url||'').slice(0, 80))}</td>
|
|
2039
|
+
</tr>`).join('')}</tbody>
|
|
2040
|
+
</table>`
|
|
2041
|
+
: '<p class="no-data">No third-party scripts detected</p>';
|
|
2042
|
+
|
|
2043
|
+
// ── NEW v15: User Flow section ────────────────────────────────────────────
|
|
2044
|
+
const userFlowSection = session.userFlowResults.length
|
|
2045
|
+
? session.userFlowResults.map(f => `
|
|
2046
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2047
|
+
<div class="card-title">User Flow: ${esc(f.url)} <span>${f.passed}/${f.steps.length} steps · ${f.passRate}%</span></div>
|
|
2048
|
+
<table>
|
|
2049
|
+
<thead><tr><th>Step</th><th>Status</th><th>Duration</th><th>Error</th></tr></thead>
|
|
2050
|
+
<tbody>${f.steps.map(s => `<tr>
|
|
2051
|
+
<td>${esc(s.name)}</td>
|
|
2052
|
+
<td><span class="status ${s.pass?'status-pass':'status-fail'}">${s.pass?'PASS':'FAIL'}</span></td>
|
|
2053
|
+
<td>${s.duration}ms</td>
|
|
2054
|
+
<td>${s.error ? `<span class="fail">${esc(s.error)}</span>` : '–'}</td>
|
|
2055
|
+
</tr>`).join('')}</tbody>
|
|
2056
|
+
</table>
|
|
2057
|
+
</div>`).join('')
|
|
2058
|
+
: '<p class="no-data">No user flow simulations</p>';
|
|
2059
|
+
|
|
2060
|
+
// ── NEW v15: Memory section ───────────────────────────────────────────────
|
|
2061
|
+
const memorySection = session.memorySnapshots.length
|
|
2062
|
+
? session.memorySnapshots.map(m => `
|
|
2063
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2064
|
+
<div class="card-title">Memory Analysis: ${esc(m.url)}</div>
|
|
2065
|
+
${m.note ? `<p class="perf-note">${esc(m.note)}</p>` : ''}
|
|
2066
|
+
<div class="vitals-grid">
|
|
2067
|
+
${vitalCard('Growth', m.growthMB, 5, 'MB')}
|
|
2068
|
+
${m.snapshots[0] ? vitalCard('Init Heap', Math.round(m.snapshots[0].used/1024/1024), 50, 'MB') : ''}
|
|
2069
|
+
${m.snapshots[1] ? vitalCard('After Nav', Math.round(m.snapshots[1].used/1024/1024), 60, 'MB') : ''}
|
|
2070
|
+
</div>
|
|
2071
|
+
${m.hasLeak ? `<div class="bug-rec" style="margin-top:.75rem">⚠️ Possible memory leak: +${m.growthMB}MB after navigation. Check for event listener leaks and detached DOM nodes.</div>` : '<p style="color:#22c55e;margin-top:.75rem">✓ No significant memory growth detected</p>'}
|
|
2072
|
+
</div>`).join('')
|
|
2073
|
+
: '<p class="no-data">No memory tests (Playwright required)</p>';
|
|
2074
|
+
|
|
2075
|
+
// ── NEW v15: Dark mode section ────────────────────────────────────────────
|
|
2076
|
+
const darkModeSection = session.darkModeResults.length
|
|
2077
|
+
? session.darkModeResults.map(dm => `
|
|
2078
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2079
|
+
<div class="card-title">Dark Mode: ${esc(dm.url)}</div>
|
|
2080
|
+
<div class="metrics" style="grid-template-columns:repeat(3,1fr)">
|
|
2081
|
+
<div class="mc"><div class="ml">Supports Dark Mode</div><div class="mv" style="font-size:1.2rem;color:${dm.supportsDark?'#22c55e':'#ef4444'}">${dm.supportsDark ? '✓ Yes' : '✗ No'}</div></div>
|
|
2082
|
+
<div class="mc"><div class="ml">Media Query Found</div><div class="mv" style="font-size:1.2rem;color:${dm.hasMediaQuery?'#22c55e':'#64748b'}">${dm.hasMediaQuery ? '✓' : '–'}</div></div>
|
|
2083
|
+
<div class="mc"><div class="ml">Background Changes</div><div class="mv" style="font-size:1.2rem;color:${dm.differentFromLight?'#22c55e':'#64748b'}">${dm.differentFromLight ? '✓' : '–'}</div></div>
|
|
2084
|
+
</div>
|
|
2085
|
+
${!dm.supportsDark ? `<div class="bug-rec">💡 Consider adding dark mode with <code>@media (prefers-color-scheme: dark)</code></div>` : ''}
|
|
2086
|
+
</div>`).join('')
|
|
2087
|
+
: '<p class="no-data">No dark mode tests (Playwright required)</p>';
|
|
2088
|
+
|
|
2089
|
+
// ── NEW v15: Redirect chains section ─────────────────────────────────────
|
|
2090
|
+
const redirectSection = session.redirectChains.length
|
|
2091
|
+
? session.redirectChains.map(rc => `
|
|
2092
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2093
|
+
<div class="card-title">Redirect Chain: ${esc(rc.url)} <span>${rc.hops} hop${rc.hops !== 1 ? 's' : ''}</span></div>
|
|
2094
|
+
${rc.hasRedirectLoop ? '<div class="bug-rec" style="background:rgba(239,68,68,.1)">🔴 Redirect loop detected!</div>' : ''}
|
|
2095
|
+
${rc.isHTTPtoHTTPS ? '<p style="color:#22c55e;margin-bottom:.5rem">✓ HTTP → HTTPS redirect in place</p>' : ''}
|
|
2096
|
+
<table>
|
|
2097
|
+
<thead><tr><th>URL</th><th>Status</th><th>Location</th></tr></thead>
|
|
2098
|
+
<tbody>${rc.chain.map(c => `<tr>
|
|
2099
|
+
<td class="url">${esc(c.url)}</td>
|
|
2100
|
+
<td class="${c.status >= 300 && c.status < 400 ? 'status-flaky' : c.status >= 400 ? 'fail' : 'pass'}">${c.status}</td>
|
|
2101
|
+
<td class="url">${esc(c.location || '–')}</td>
|
|
2102
|
+
</tr>`).join('')}</tbody>
|
|
2103
|
+
</table>
|
|
2104
|
+
</div>`).join('')
|
|
2105
|
+
: '<p class="no-data">No redirect chains analyzed</p>';
|
|
2106
|
+
|
|
2107
|
+
// ── Form tests section ────────────────────────────────────────────────────
|
|
2108
|
+
const formTestSection = session.formTests.length
|
|
2109
|
+
? session.formTests.map(f => `
|
|
2110
|
+
<div class="card" style="margin-bottom:1rem">
|
|
2111
|
+
<div class="card-title">Form #${f.formIndex + 1} — ${esc(f.url)}</div>
|
|
2112
|
+
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:.75rem">
|
|
2113
|
+
<span class="badge">${f.method} ${f.action || 'self'}</span>
|
|
2114
|
+
<span>${f.inputCount} inputs</span>
|
|
2115
|
+
<span class="${f.hasSubmit ? 'pass' : 'fail'}">${f.hasSubmit ? '✓ Has submit' : '✗ No submit'}</span>
|
|
2116
|
+
<span>Label coverage: ${f.labelCoverage}%</span>
|
|
2117
|
+
</div>
|
|
2118
|
+
${(f.issues||[]).map(i => `<div class="vp-issue">⚠ ${esc(i)}</div>`).join('')}
|
|
2119
|
+
</div>`).join('')
|
|
2120
|
+
: '<p class="no-data">No forms found or Playwright not available</p>';
|
|
2121
|
+
|
|
2122
|
+
// ── Cache headers section ─────────────────────────────────────────────────
|
|
2123
|
+
const cacheSection = session.cacheHeaders.length
|
|
2124
|
+
? `<table>
|
|
2125
|
+
<thead><tr><th>URL</th><th>Cache-Control</th><th>ETag</th><th>Max-Age</th><th>CDN Cache</th><th>Status</th></tr></thead>
|
|
2126
|
+
<tbody>${session.cacheHeaders.map(c => `<tr>
|
|
2127
|
+
<td class="url">${esc(c.url)}</td>
|
|
2128
|
+
<td><code style="font-size:.7rem">${esc((c.cacheControl||'none').slice(0,50))}</code></td>
|
|
2129
|
+
<td>${c.etag ? '✓' : '–'}</td>
|
|
2130
|
+
<td>${c.maxAge ? `${c.maxAge}s` : '–'}</td>
|
|
2131
|
+
<td>${esc(c.xCache || '–')}</td>
|
|
2132
|
+
<td><span class="status ${c.passed?'status-pass':'status-fail'}">${c.passed?'PASS':'FAIL'}</span></td>
|
|
2133
|
+
</tr>`).join('')}</tbody>
|
|
2134
|
+
</table>`
|
|
2135
|
+
: '<p class="no-data">No cache audits</p>';
|
|
2136
|
+
|
|
2137
|
+
// ── Error pages section ───────────────────────────────────────────────────
|
|
2138
|
+
const errorPageSection = session.errorPageTests.length
|
|
2139
|
+
? `<table>
|
|
2140
|
+
<thead><tr><th>Test</th><th>Actual Status</th><th>Custom Page</th><th>Status</th></tr></thead>
|
|
2141
|
+
<tbody>${session.errorPageTests.map(e => `<tr>
|
|
2142
|
+
<td>${esc(e.name)}</td>
|
|
2143
|
+
<td class="${e.isCorrectStatus ? 'pass' : 'fail'}">${e.actualStatus}</td>
|
|
2144
|
+
<td>${e.hasCustomPage ? '✓' : '–'}</td>
|
|
2145
|
+
<td><span class="status ${e.passed?'status-pass':'status-fail'}">${e.passed?'PASS':'FAIL'}</span></td>
|
|
2146
|
+
</tr>`).join('')}</tbody>
|
|
2147
|
+
</table>`
|
|
2148
|
+
: '<p class="no-data">No error page tests</p>';
|
|
2149
|
+
|
|
2150
|
+
// ── Console errors table ──────────────────────────────────────────────────
|
|
2151
|
+
const consoleSection = session.consoleErrors.length
|
|
2152
|
+
? `<table>
|
|
2153
|
+
<thead><tr><th>Type</th><th>Message</th><th>URL</th></tr></thead>
|
|
2154
|
+
<tbody>${session.consoleErrors.slice(0, 100).map(e => `<tr>
|
|
2155
|
+
<td><span class="badge">${esc(e.type)}</span></td>
|
|
2156
|
+
<td>${esc(e.text?.slice(0, 200) || '')}</td>
|
|
2157
|
+
<td class="url">${esc(e.url || '')}</td>
|
|
2158
|
+
</tr>`).join('')}</tbody>
|
|
2159
|
+
</table>`
|
|
2160
|
+
: '<p class="no-data">No console errors 🎉</p>';
|
|
2161
|
+
|
|
2162
|
+
// ── Network failures table ────────────────────────────────────────────────
|
|
2163
|
+
const networkSection = session.networkLog.length
|
|
2164
|
+
? `<table>
|
|
2165
|
+
<thead><tr><th>URL</th><th>Method</th><th>Failure</th></tr></thead>
|
|
2166
|
+
<tbody>${session.networkLog.slice(0, 100).map(e => `<tr>
|
|
2167
|
+
<td class="url">${esc(e.url || '')}</td>
|
|
2168
|
+
<td>${esc(e.method || '')}</td>
|
|
2169
|
+
<td class="fail">${esc(e.failure || e.error || `HTTP ${e.status}`)}</td>
|
|
2170
|
+
</tr>`).join('')}</tbody>
|
|
2171
|
+
</table>`
|
|
2172
|
+
: '<p class="no-data">No network failures 🎉</p>';
|
|
2173
|
+
|
|
2174
|
+
// Mixed content
|
|
2175
|
+
const mixedContentSection = session.mixedContentIssues.length
|
|
2176
|
+
? `<table>
|
|
2177
|
+
<thead><tr><th>URL</th><th>Issue</th></tr></thead>
|
|
2178
|
+
<tbody>${session.mixedContentIssues.map(m => `<tr>
|
|
2179
|
+
<td class="url">${esc(m.url)}</td>
|
|
2180
|
+
<td class="fail">${esc(m.text?.slice(0, 200))}</td>
|
|
2181
|
+
</tr>`).join('')}</tbody>
|
|
2182
|
+
</table>`
|
|
2183
|
+
: '<p class="no-data">No mixed content issues 🎉</p>';
|
|
730
2184
|
|
|
731
2185
|
const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
|
|
732
2186
|
.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
2187
|
|
|
734
|
-
const chartTypes
|
|
735
|
-
const chartPass2
|
|
736
|
-
const chartFail2
|
|
737
|
-
const bugSevData
|
|
2188
|
+
const chartTypes = JSON.stringify(Object.keys(coverage));
|
|
2189
|
+
const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
|
|
2190
|
+
const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
|
|
2191
|
+
const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
|
|
2192
|
+
const pwBadge = session.playwrightMode
|
|
2193
|
+
? '<span style="background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44;padding:3px 12px;border-radius:20px;font-size:.7rem">🎭 Backlist Real Browser</span>'
|
|
2194
|
+
: '<span style="background:#1e293b;color:#64748b;padding:3px 12px;border-radius:20px;font-size:.7rem">HTTP-only</span>';
|
|
2195
|
+
|
|
2196
|
+
const vpCount = Object.keys(session.viewportResults || {}).length;
|
|
2197
|
+
const brokenCount = session.brokenLinks.reduce((a, bl) => a + (bl.broken || 0), 0);
|
|
738
2198
|
|
|
739
2199
|
return `<!DOCTYPE html>
|
|
740
2200
|
<html lang="en">
|
|
741
2201
|
<head>
|
|
742
2202
|
<meta charset="UTF-8">
|
|
743
2203
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
744
|
-
<title>Backlist QA Report — ${esc(session.id)}</title>
|
|
2204
|
+
<title>Backlist QA v15 Report — ${esc(session.id)}</title>
|
|
745
2205
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
|
746
2206
|
<style>
|
|
747
2207
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
|
|
@@ -750,103 +2210,144 @@ function buildHTMLReport(session) {
|
|
|
750
2210
|
body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
|
|
751
2211
|
a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
|
|
752
2212
|
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
|
-
.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
|
|
754
|
-
.header-meta{font-family:'JetBrains Mono',monospace;font-size:.
|
|
755
|
-
|
|
756
|
-
nav{
|
|
757
|
-
.nav-tab{padding:.
|
|
2213
|
+
.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}
|
|
2214
|
+
.header-meta{font-family:'JetBrains Mono',monospace;font-size:.72rem;color:var(--dim);margin-top:.3rem}
|
|
2215
|
+
nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 1rem;display:flex;overflow-x:auto;gap:0;scrollbar-width:none}
|
|
2216
|
+
nav::-webkit-scrollbar{display:none}
|
|
2217
|
+
.nav-tab{padding:.65rem 1rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.78rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s;font-family:'Syne',sans-serif}
|
|
758
2218
|
.nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
|
|
759
2219
|
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
|
760
2220
|
.tab-panel{display:none}.tab-panel.active{display:block}
|
|
761
|
-
.
|
|
762
|
-
.
|
|
763
|
-
.
|
|
2221
|
+
.pw-banner{background:rgba(191,64,255,.08);border:1px solid #bf40ff44;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.82rem;color:#c084fc;display:flex;align-items:center;gap:.5rem}
|
|
2222
|
+
.real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.82rem;color:var(--cyan);display:flex;align-items:center;gap:.5rem}
|
|
2223
|
+
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.6rem;margin-bottom:1.5rem}
|
|
2224
|
+
.mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:.9rem;transition:.2s;cursor:default}
|
|
764
2225
|
.mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
|
|
765
|
-
.ml{font-size:.
|
|
766
|
-
.mv{font-size:1.
|
|
2226
|
+
.ml{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
|
|
2227
|
+
.mv{font-size:1.7rem;font-weight:800;margin-top:4px;font-family:'JetBrains Mono',monospace}
|
|
767
2228
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
|
|
768
2229
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
769
|
-
.card-title{font-size:.
|
|
770
|
-
.chart-wrap{position:relative;height:
|
|
771
|
-
.search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
|
|
772
|
-
.search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.
|
|
773
|
-
table{width:100%;border-collapse:collapse;font-size:.
|
|
774
|
-
th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.
|
|
775
|
-
td{padding:.
|
|
2230
|
+
.card-title{font-size:.88rem;font-weight:700;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
|
2231
|
+
.chart-wrap{position:relative;height:220px}
|
|
2232
|
+
.search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem;flex-wrap:wrap}
|
|
2233
|
+
.search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.82rem;flex:1;font-family:'Syne',sans-serif;min-width:120px}
|
|
2234
|
+
table{width:100%;border-collapse:collapse;font-size:.78rem}
|
|
2235
|
+
th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
|
|
2236
|
+
td{padding:.4rem .75rem;border-bottom:1px solid #0f0f1e;vertical-align:top;word-break:break-word}
|
|
776
2237
|
tr.fail-row td{background:rgba(239,68,68,.04)}
|
|
777
2238
|
.pass{color:var(--green)}.fail{color:var(--red)}
|
|
778
|
-
.status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.
|
|
2239
|
+
.status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.68rem;font-weight:700;font-family:'JetBrains Mono',monospace}
|
|
779
2240
|
.status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}.status-flaky{background:#422006;color:#fbbf24}.status-skip{background:#1e293b;color:#94a3b8}
|
|
780
|
-
.sev{padding:2px 7px;border-radius:3px;font-size:.
|
|
781
|
-
.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
|
-
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.
|
|
783
|
-
.
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
2241
|
+
.sev{padding:2px 7px;border-radius:3px;font-size:.68rem;font-weight:800}
|
|
2242
|
+
.sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}.sev-info{background:#1e293b;color:#64748b}
|
|
2243
|
+
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.68rem;background:#1e293b;color:#94a3b8}
|
|
2244
|
+
.pw-badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.68rem;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
|
|
2245
|
+
.url{font-family:'JetBrains Mono',monospace;font-size:.72rem;color:var(--cyan);word-break:break-all}
|
|
2246
|
+
code{font-family:'JetBrains Mono',monospace;font-size:.72rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
|
|
2247
|
+
pre{white-space:pre-wrap;word-break:break-all;font-size:.7rem;padding:.75rem;background:#080814;border-radius:6px;overflow-x:auto;max-height:300px;font-family:'JetBrains Mono',monospace}
|
|
2248
|
+
details summary{cursor:pointer;color:var(--cyan);font-size:.76rem;user-select:none}
|
|
787
2249
|
.bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
|
|
788
2250
|
.bug-card:hover{border-left-color:var(--cyan)}
|
|
789
2251
|
.sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
|
|
790
2252
|
.sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
|
|
791
2253
|
.sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
|
|
792
2254
|
.bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
|
|
793
|
-
.bug-id{font-family:'JetBrains Mono',monospace;font-size:.
|
|
2255
|
+
.bug-id{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--dim)}
|
|
794
2256
|
.bug-title{font-weight:700;margin-bottom:.3rem}
|
|
795
|
-
.bug-url{font-size:.
|
|
796
|
-
.bug-rec{font-size:.
|
|
797
|
-
.ai-badge{font-size:.
|
|
798
|
-
.rec{font-size:.
|
|
2257
|
+
.bug-url{font-size:.73rem;margin-bottom:.3rem}
|
|
2258
|
+
.bug-rec{font-size:.76rem;color:#86efac;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px;margin-top:.5rem}
|
|
2259
|
+
.ai-badge{font-size:.67rem;padding:2px 7px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
|
|
2260
|
+
.rec{font-size:.73rem;color:#86efac}
|
|
799
2261
|
.no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
|
|
800
2262
|
.url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
|
|
801
|
-
.url-label{font-size:.
|
|
802
|
-
.
|
|
803
|
-
.
|
|
804
|
-
.
|
|
805
|
-
.
|
|
806
|
-
.
|
|
2263
|
+
.url-label{font-size:.68rem;color:var(--dim);text-transform:uppercase;min-width:90px}
|
|
2264
|
+
.screenshot-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem;margin-top:1rem}
|
|
2265
|
+
.screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:.2s}
|
|
2266
|
+
.screenshot-card:hover{border-color:var(--purple);transform:translateY(-2px);box-shadow:0 8px 24px rgba(191,64,255,.12)}
|
|
2267
|
+
.sc-header{display:flex;justify-content:space-between;align-items:center;padding:.65rem 1rem;border-bottom:2px solid transparent}
|
|
2268
|
+
.sc-type{font-size:.68rem;padding:2px 8px;border-radius:4px;text-transform:uppercase;font-weight:700}
|
|
2269
|
+
.sc-url{font-size:.7rem;color:var(--dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;max-width:200px}
|
|
2270
|
+
.sc-img-wrap{background:#000;min-height:160px;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
|
2271
|
+
.sc-img-wrap img{width:100%;height:auto;display:block;max-height:350px;object-fit:cover}
|
|
2272
|
+
.no-img{color:var(--dim);font-style:italic;padding:2rem;text-align:center}
|
|
2273
|
+
.sc-path{font-family:'JetBrains Mono',monospace;font-size:.65rem;color:var(--dim);padding:.4rem 1rem;background:#080810}
|
|
2274
|
+
.vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:.65rem;margin:.75rem 0}
|
|
2275
|
+
.vital-card{border-radius:8px;padding:.875rem;text-align:center;border:1px solid var(--border)}
|
|
2276
|
+
.vital-value{font-size:1.4rem;font-weight:800;margin:.2rem 0;font-family:'JetBrains Mono',monospace}
|
|
2277
|
+
.vital-label{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
|
|
2278
|
+
.vital-threshold{font-size:.65rem;color:var(--dim);margin-top:2px}
|
|
807
2279
|
.vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
|
|
808
2280
|
.vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
|
|
809
2281
|
.vital-na{background:var(--surface)}
|
|
810
2282
|
.perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
811
2283
|
.perf-card h3{color:var(--cyan);margin-bottom:.5rem}
|
|
812
|
-
.perf-note{font-size:.
|
|
2284
|
+
.perf-note{font-size:.76rem;color:var(--dim);font-style:italic;margin-top:.75rem}
|
|
2285
|
+
.load-test-card{border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
2286
|
+
.lt-pass{background:rgba(34,197,94,.05);border-color:#22c55e44}
|
|
2287
|
+
.lt-fail{background:rgba(239,68,68,.05);border-color:#ef444444}
|
|
813
2288
|
.seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
|
|
814
|
-
.seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.
|
|
2289
|
+
.seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.83rem}
|
|
815
2290
|
.violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
|
|
816
2291
|
.impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
|
|
817
2292
|
.impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
|
|
818
2293
|
.impact-moderate{border-left-color:#3b82f6;background:rgba(59,130,246,.05)}
|
|
2294
|
+
.impact-minor{border-left-color:#64748b;background:rgba(100,116,139,.04)}
|
|
819
2295
|
.violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
|
|
820
|
-
.impact-badge{font-size:.
|
|
821
|
-
.err-cell details{font-size:.
|
|
822
|
-
|
|
823
|
-
|
|
2296
|
+
.impact-badge{font-size:.68rem;padding:2px 6px;border-radius:4px;background:#1e293b;color:#94a3b8}
|
|
2297
|
+
.err-cell details{font-size:.76rem}
|
|
2298
|
+
.viewport-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:.75rem;margin-top:1rem}
|
|
2299
|
+
.vp-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;text-align:center}
|
|
2300
|
+
.vp-fail{border-color:#ef444444;background:rgba(239,68,68,.05)}
|
|
2301
|
+
.vp-label{font-size:.78rem;font-weight:700;margin-bottom:.25rem}
|
|
2302
|
+
.vp-dims{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim);margin-bottom:.5rem}
|
|
2303
|
+
.vp-status{margin-bottom:.5rem}
|
|
2304
|
+
.vp-issue{font-size:.7rem;color:#f87171;margin-top:.25rem}
|
|
2305
|
+
footer{text-align:center;color:var(--dim);font-size:.68rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem;font-family:'JetBrains Mono',monospace}
|
|
2306
|
+
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
|
|
824
2307
|
</style>
|
|
825
2308
|
</head>
|
|
826
2309
|
<body>
|
|
827
2310
|
<header>
|
|
828
2311
|
<div>
|
|
829
|
-
<div class="logo">⚡ Backlist Enterprise QA</div>
|
|
2312
|
+
<div class="logo">⚡ Backlist Enterprise QA v15</div>
|
|
830
2313
|
<div class="header-meta">
|
|
831
2314
|
Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
|
|
832
2315
|
</div>
|
|
833
2316
|
</div>
|
|
834
|
-
|
|
2317
|
+
${pwBadge}
|
|
835
2318
|
</header>
|
|
836
2319
|
|
|
837
2320
|
<nav>
|
|
838
2321
|
<button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
|
|
2322
|
+
<button class="nav-tab" onclick="showTab('screenshots',this)">📸 Screenshots (${session.screenshots.length})</button>
|
|
2323
|
+
<button class="nav-tab" onclick="showTab('viewports',this)">📱 Viewports (${vpCount})</button>
|
|
839
2324
|
<button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
|
|
840
2325
|
<button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
|
|
841
2326
|
<button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
|
|
842
2327
|
<button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
|
|
843
2328
|
<button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
|
|
2329
|
+
<button class="nav-tab" onclick="showTab('loadtest',this)">🔥 Load Test</button>
|
|
844
2330
|
<button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
|
|
845
2331
|
<button class="nav-tab" onclick="showTab('seo',this)">🔎 SEO</button>
|
|
2332
|
+
<button class="nav-tab" onclick="showTab('darkmode',this)">🌙 Dark Mode</button>
|
|
2333
|
+
<button class="nav-tab" onclick="showTab('userflow',this)">🧑💻 User Flow</button>
|
|
2334
|
+
<button class="nav-tab" onclick="showTab('forms',this)">📝 Forms</button>
|
|
2335
|
+
<button class="nav-tab" onclick="showTab('cookies',this)">🍪 Cookies</button>
|
|
2336
|
+
<button class="nav-tab" onclick="showTab('memory',this)">🧠 Memory</button>
|
|
2337
|
+
<button class="nav-tab" onclick="showTab('brokenlinks',this)">🔗 Links (${brokenCount} broken)</button>
|
|
2338
|
+
<button class="nav-tab" onclick="showTab('redirects',this)">↪ Redirects</button>
|
|
2339
|
+
<button class="nav-tab" onclick="showTab('thirdparty',this)">📦 3rd Party (${session.thirdPartyScripts.length})</button>
|
|
2340
|
+
<button class="nav-tab" onclick="showTab('cache',this)">💾 Cache</button>
|
|
2341
|
+
<button class="nav-tab" onclick="showTab('errorpages',this)">🚫 Error Pages</button>
|
|
2342
|
+
<button class="nav-tab" onclick="showTab('mixed',this)">⚠️ Mixed Content</button>
|
|
2343
|
+
<button class="nav-tab" onclick="showTab('console',this)">🖥️ Console (${session.consoleErrors.length})</button>
|
|
2344
|
+
<button class="nav-tab" onclick="showTab('network',this)">📡 Network</button>
|
|
846
2345
|
</nav>
|
|
847
2346
|
|
|
848
2347
|
<div class="container">
|
|
849
|
-
|
|
2348
|
+
|
|
2349
|
+
${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real Browser Mode</strong> — Screenshots, Web Vitals, DOM tests, Interactions, User Flow, Memory, Dark Mode, All Viewports</div>' : ''}
|
|
2350
|
+
<div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live Chromium browser testing.</div>
|
|
850
2351
|
|
|
851
2352
|
<!-- OVERVIEW -->
|
|
852
2353
|
<div id="tab-overview" class="tab-panel active">
|
|
@@ -859,11 +2360,15 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
859
2360
|
<div class="mc"><div class="ml">Bugs Found</div><div class="mv" style="color:#c084fc">${session.bugs.length}</div></div>
|
|
860
2361
|
<div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
|
|
861
2362
|
<div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
|
|
2363
|
+
<div class="mc"><div class="ml">Screenshots</div><div class="mv" style="color:#c084fc">${session.screenshots.length}</div></div>
|
|
2364
|
+
<div class="mc"><div class="ml">Viewports</div><div class="mv">${vpCount}</div></div>
|
|
862
2365
|
<div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
|
|
863
|
-
<div class="mc"><div class="ml">
|
|
2366
|
+
<div class="mc"><div class="ml">Broken Links</div><div class="mv" style="color:${brokenCount > 0 ? 'var(--red)' : 'var(--green)'}">${brokenCount}</div></div>
|
|
864
2367
|
<div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
|
|
865
|
-
<div class="mc"><div class="ml">
|
|
866
|
-
<div class="mc"><div class="ml">
|
|
2368
|
+
<div class="mc"><div class="ml">3rd Party</div><div class="mv">${session.thirdPartyScripts.length}</div></div>
|
|
2369
|
+
<div class="mc"><div class="ml">Console Errs</div><div class="mv" style="color:${session.consoleErrors.length > 0 ? 'var(--yellow)' : 'var(--green)'}">${session.consoleErrors.length}</div></div>
|
|
2370
|
+
<div class="mc"><div class="ml">Dark Mode</div><div class="mv" style="font-size:1rem">${session.darkModeResults.some(d => d.supportsDark) ? '✓' : '–'}</div></div>
|
|
2371
|
+
<div class="mc"><div class="ml">Duration</div><div class="mv" style="font-size:.95rem;padding-top:.4rem">${formatDuration(summary.duration)}</div></div>
|
|
867
2372
|
</div>
|
|
868
2373
|
<div class="grid2">
|
|
869
2374
|
<div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
|
|
@@ -871,6 +2376,22 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
871
2376
|
</div>
|
|
872
2377
|
</div>
|
|
873
2378
|
|
|
2379
|
+
<!-- SCREENSHOTS -->
|
|
2380
|
+
<div id="tab-screenshots" class="tab-panel">
|
|
2381
|
+
<div class="card">
|
|
2382
|
+
<div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured (${vpCount} viewports + dark mode)</span></div>
|
|
2383
|
+
<div class="screenshot-gallery">${screenshotCards}</div>
|
|
2384
|
+
</div>
|
|
2385
|
+
</div>
|
|
2386
|
+
|
|
2387
|
+
<!-- VIEWPORTS -->
|
|
2388
|
+
<div id="tab-viewports" class="tab-panel">
|
|
2389
|
+
<div class="card">
|
|
2390
|
+
<div class="card-title">Multi-Viewport Testing <span>${vpCount} viewports</span></div>
|
|
2391
|
+
${vpSection}
|
|
2392
|
+
</div>
|
|
2393
|
+
</div>
|
|
2394
|
+
|
|
874
2395
|
<!-- TESTS -->
|
|
875
2396
|
<div id="tab-tests" class="tab-panel">
|
|
876
2397
|
<div class="search-bar">
|
|
@@ -903,6 +2424,10 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
903
2424
|
<option value="P0">P0 Critical</option><option value="P1">P1 High</option>
|
|
904
2425
|
<option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
|
|
905
2426
|
</select>
|
|
2427
|
+
<select id="bugCat" onchange="filterBugs()">
|
|
2428
|
+
<option value="">All categories</option>
|
|
2429
|
+
${[...new Set(session.bugs.map(b=>b.aiCategory||b.type||'general'))].map(c=>`<option value="${esc(c)}">${c}</option>`).join('')}
|
|
2430
|
+
</select>
|
|
906
2431
|
</div>
|
|
907
2432
|
<div id="bugList">${bugCards}</div>
|
|
908
2433
|
</div>
|
|
@@ -910,10 +2435,10 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
910
2435
|
<!-- ROUTES -->
|
|
911
2436
|
<div id="tab-routes" class="tab-panel">
|
|
912
2437
|
<div class="card">
|
|
913
|
-
<div class="card-title">Discovered Routes <span>${session.routeMap.length}
|
|
2438
|
+
<div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
|
|
914
2439
|
<table>
|
|
915
|
-
<thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
|
|
916
|
-
<tbody>${routeRows || '<tr><td colspan="
|
|
2440
|
+
<thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Time</th><th>Forms</th><th>Result</th></tr></thead>
|
|
2441
|
+
<tbody>${routeRows || '<tr><td colspan="6" class="no-data">No routes discovered</td></tr>'}</tbody>
|
|
917
2442
|
</table>
|
|
918
2443
|
</div>
|
|
919
2444
|
</div>
|
|
@@ -921,7 +2446,7 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
921
2446
|
<!-- SECURITY -->
|
|
922
2447
|
<div id="tab-security" class="tab-panel">
|
|
923
2448
|
<div class="card">
|
|
924
|
-
<div class="card-title">Security Scan
|
|
2449
|
+
<div class="card-title">Security Scan <span>${session.secFindings.length} checks · ${session.secFindings.filter(f=>!f.pass).length} issues</span></div>
|
|
925
2450
|
<table>
|
|
926
2451
|
<thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
|
|
927
2452
|
<tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
|
|
@@ -931,25 +2456,121 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
931
2456
|
|
|
932
2457
|
<!-- PERFORMANCE -->
|
|
933
2458
|
<div id="tab-performance" class="tab-panel">
|
|
934
|
-
<div class="card-title" style="padding:.5rem 0 1rem">
|
|
2459
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals (Playwright Chromium)</div>
|
|
935
2460
|
${perfSection}
|
|
936
2461
|
</div>
|
|
937
2462
|
|
|
938
|
-
<!--
|
|
2463
|
+
<!-- LOAD TEST -->
|
|
2464
|
+
<div id="tab-loadtest" class="tab-panel">
|
|
2465
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Load Testing — Concurrent Requests</div>
|
|
2466
|
+
${loadTestSection}
|
|
2467
|
+
</div>
|
|
2468
|
+
|
|
2469
|
+
<!-- A11Y -->
|
|
939
2470
|
<div id="tab-a11y" class="tab-panel">
|
|
940
|
-
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility
|
|
2471
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG 2.1 HTML Analysis (15 rules)</div>
|
|
941
2472
|
${a11ySection}
|
|
942
2473
|
</div>
|
|
943
2474
|
|
|
944
2475
|
<!-- SEO -->
|
|
945
2476
|
<div id="tab-seo" class="tab-panel">
|
|
946
|
-
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis —
|
|
2477
|
+
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent (21 checks)</div>
|
|
947
2478
|
${seoSection}
|
|
948
2479
|
</div>
|
|
949
2480
|
|
|
2481
|
+
<!-- DARK MODE -->
|
|
2482
|
+
<div id="tab-darkmode" class="tab-panel">
|
|
2483
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Dark Mode Testing</div>
|
|
2484
|
+
${darkModeSection}
|
|
2485
|
+
</div>
|
|
2486
|
+
|
|
2487
|
+
<!-- USER FLOW -->
|
|
2488
|
+
<div id="tab-userflow" class="tab-panel">
|
|
2489
|
+
<div class="card-title" style="padding:.5rem 0 1rem">User Flow Simulation</div>
|
|
2490
|
+
${userFlowSection}
|
|
2491
|
+
</div>
|
|
2492
|
+
|
|
2493
|
+
<!-- FORMS -->
|
|
2494
|
+
<div id="tab-forms" class="tab-panel">
|
|
2495
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Form Testing</div>
|
|
2496
|
+
${formTestSection}
|
|
2497
|
+
</div>
|
|
2498
|
+
|
|
2499
|
+
<!-- COOKIES -->
|
|
2500
|
+
<div id="tab-cookies" class="tab-panel">
|
|
2501
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Cookie Security Audit</div>
|
|
2502
|
+
${cookieSection}
|
|
950
2503
|
</div>
|
|
951
2504
|
|
|
952
|
-
|
|
2505
|
+
<!-- MEMORY -->
|
|
2506
|
+
<div id="tab-memory" class="tab-panel">
|
|
2507
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Memory Leak Detection</div>
|
|
2508
|
+
${memorySection}
|
|
2509
|
+
</div>
|
|
2510
|
+
|
|
2511
|
+
<!-- BROKEN LINKS -->
|
|
2512
|
+
<div id="tab-brokenlinks" class="tab-panel">
|
|
2513
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Broken Link Scanner</div>
|
|
2514
|
+
${brokenLinksSection}
|
|
2515
|
+
</div>
|
|
2516
|
+
|
|
2517
|
+
<!-- REDIRECTS -->
|
|
2518
|
+
<div id="tab-redirects" class="tab-panel">
|
|
2519
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Redirect Chain Analysis</div>
|
|
2520
|
+
${redirectSection}
|
|
2521
|
+
</div>
|
|
2522
|
+
|
|
2523
|
+
<!-- THIRD PARTY -->
|
|
2524
|
+
<div id="tab-thirdparty" class="tab-panel">
|
|
2525
|
+
<div class="card">
|
|
2526
|
+
<div class="card-title">Third-Party Script Audit <span>${session.thirdPartyScripts.length} external scripts</span></div>
|
|
2527
|
+
${thirdPartySection}
|
|
2528
|
+
</div>
|
|
2529
|
+
</div>
|
|
2530
|
+
|
|
2531
|
+
<!-- CACHE -->
|
|
2532
|
+
<div id="tab-cache" class="tab-panel">
|
|
2533
|
+
<div class="card">
|
|
2534
|
+
<div class="card-title">Cache Headers Audit</div>
|
|
2535
|
+
${cacheSection}
|
|
2536
|
+
</div>
|
|
2537
|
+
</div>
|
|
2538
|
+
|
|
2539
|
+
<!-- ERROR PAGES -->
|
|
2540
|
+
<div id="tab-errorpages" class="tab-panel">
|
|
2541
|
+
<div class="card">
|
|
2542
|
+
<div class="card-title">Error Page Testing</div>
|
|
2543
|
+
${errorPageSection}
|
|
2544
|
+
</div>
|
|
2545
|
+
</div>
|
|
2546
|
+
|
|
2547
|
+
<!-- MIXED CONTENT -->
|
|
2548
|
+
<div id="tab-mixed" class="tab-panel">
|
|
2549
|
+
<div class="card">
|
|
2550
|
+
<div class="card-title">Mixed Content Issues</div>
|
|
2551
|
+
${mixedContentSection}
|
|
2552
|
+
</div>
|
|
2553
|
+
</div>
|
|
2554
|
+
|
|
2555
|
+
<!-- CONSOLE -->
|
|
2556
|
+
<div id="tab-console" class="tab-panel">
|
|
2557
|
+
<div class="card">
|
|
2558
|
+
<div class="card-title">Console Errors & Warnings <span>${session.consoleErrors.length} entries</span></div>
|
|
2559
|
+
${consoleSection}
|
|
2560
|
+
</div>
|
|
2561
|
+
</div>
|
|
2562
|
+
|
|
2563
|
+
<!-- NETWORK -->
|
|
2564
|
+
<div id="tab-network" class="tab-panel">
|
|
2565
|
+
<div class="card">
|
|
2566
|
+
<div class="card-title">Network Failures <span>${session.networkLog.length} failures</span></div>
|
|
2567
|
+
${networkSection}
|
|
2568
|
+
</div>
|
|
2569
|
+
</div>
|
|
2570
|
+
|
|
2571
|
+
</div>
|
|
2572
|
+
|
|
2573
|
+
<footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${session.screenshots.length} screenshots · ${vpCount} viewports · ${new Date().toLocaleString()}</footer>
|
|
953
2574
|
|
|
954
2575
|
<script>
|
|
955
2576
|
function showTab(name, el) {
|
|
@@ -959,43 +2580,41 @@ function showTab(name, el) {
|
|
|
959
2580
|
el?.classList.add('active');
|
|
960
2581
|
}
|
|
961
2582
|
function filterTests() {
|
|
962
|
-
const s
|
|
2583
|
+
const s = (document.getElementById('testSearch')?.value||'').toLowerCase();
|
|
963
2584
|
const st = document.getElementById('testStatus')?.value||'';
|
|
964
2585
|
const ty = document.getElementById('testType')?.value||'';
|
|
965
2586
|
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';
|
|
2587
|
+
row.style.display = (row.textContent.toLowerCase().includes(s) && (!st || row.dataset.status===st) && (!ty || row.dataset.type===ty)) ? '' : 'none';
|
|
970
2588
|
});
|
|
971
2589
|
}
|
|
972
2590
|
function filterBugs() {
|
|
973
2591
|
const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
|
|
974
2592
|
const sv = document.getElementById('bugSev')?.value||'';
|
|
2593
|
+
const ca = document.getElementById('bugCat')?.value||'';
|
|
975
2594
|
document.querySelectorAll('#bugList .bug-card').forEach(card => {
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
card.style.display =
|
|
2595
|
+
const matchSev = !sv || card.dataset.severity===sv;
|
|
2596
|
+
const matchCat = !ca || card.textContent.toLowerCase().includes(ca.toLowerCase());
|
|
2597
|
+
card.style.display = (card.textContent.toLowerCase().includes(s) && matchSev && matchCat) ? '' : 'none';
|
|
979
2598
|
});
|
|
980
2599
|
}
|
|
981
2600
|
const chartCfg = {
|
|
982
|
-
plugins:{legend:{labels:{color:'#94a3b8',font:{size:
|
|
983
|
-
scales:{x:{ticks:{color:'#64748b'},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1},grid:{color:'#1e293b'},beginAtZero:true}}
|
|
2601
|
+
plugins:{legend:{labels:{color:'#94a3b8',font:{size:10}}}},
|
|
2602
|
+
scales:{x:{ticks:{color:'#64748b',font:{size:10}},grid:{color:'#1e293b'}},y:{ticks:{color:'#64748b',stepSize:1,font:{size:10}},grid:{color:'#1e293b'},beginAtZero:true}}
|
|
984
2603
|
};
|
|
985
2604
|
new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
|
|
986
2605
|
{label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
|
|
987
2606
|
{label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
|
|
988
2607
|
]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
|
|
989
|
-
new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:
|
|
2608
|
+
new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:10}}}}}});
|
|
990
2609
|
</script>
|
|
991
2610
|
</body>
|
|
992
2611
|
</html>`;
|
|
993
2612
|
}
|
|
994
2613
|
|
|
995
2614
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
996
|
-
// Main QA Runner
|
|
2615
|
+
// Main QA Runner v15 — All phases
|
|
997
2616
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
998
|
-
async function runQAEngine(session) {
|
|
2617
|
+
async function runQAEngine(session, opts = {}) {
|
|
999
2618
|
const dash = new TerminalDashboard(session);
|
|
1000
2619
|
dash.start();
|
|
1001
2620
|
|
|
@@ -1012,45 +2631,208 @@ async function runQAEngine(session) {
|
|
|
1012
2631
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1013
2632
|
if (!url) continue;
|
|
1014
2633
|
dash.log(`Crawling ${label}: ${url}`);
|
|
1015
|
-
const t0
|
|
2634
|
+
const t0 = Date.now();
|
|
1016
2635
|
const routes = await crawlSite(url, {
|
|
1017
|
-
maxPages:
|
|
2636
|
+
maxPages: 60,
|
|
1018
2637
|
onRoute: (route) => {
|
|
1019
2638
|
session.routeMap.push(route);
|
|
1020
|
-
dash.
|
|
2639
|
+
dash.setSubPhase(`Found: ${route.url} (${route.type})`);
|
|
1021
2640
|
},
|
|
1022
2641
|
});
|
|
1023
|
-
addResult({ name: `[${label}] Route Discovery`, type: 'discovery',
|
|
2642
|
+
addResult({ name: `[${label}] Route Discovery`, type: 'discovery',
|
|
1024
2643
|
status: routes.length > 0 ? 'PASS' : 'FAIL',
|
|
1025
2644
|
message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
|
|
1026
2645
|
}
|
|
1027
2646
|
|
|
1028
|
-
// ── Phase 2:
|
|
1029
|
-
dash.setPhase('
|
|
2647
|
+
// ── Phase 2: Redirect Chain Analysis (v15) ───────────────────────────
|
|
2648
|
+
dash.setPhase('↪ Phase 2: Redirect Chain Analysis');
|
|
2649
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2650
|
+
if (!url) continue;
|
|
2651
|
+
dash.setCurrentTest(`Redirect: ${url}`);
|
|
2652
|
+
const rc = await analyzeRedirectChain(url);
|
|
2653
|
+
session.redirectChains.push(rc);
|
|
2654
|
+
addResult({ name: `[${label}] Redirect Chain`, type: 'redirect',
|
|
2655
|
+
status: rc.hasRedirectLoop ? 'FAIL' : 'PASS',
|
|
2656
|
+
message: `${rc.hops} hops → ${rc.finalUrl}`, url, label });
|
|
2657
|
+
if (rc.hasRedirectLoop) session.addBug({ title: `Redirect loop: ${url}`, severity: 'P1', type: 'network', url });
|
|
2658
|
+
if (!rc.isHTTPtoHTTPS && url.startsWith('http://')) {
|
|
2659
|
+
session.addBug({ title: `No HTTP→HTTPS redirect: ${url}`, severity: 'P2', type: 'security', url });
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// ── Phase 3: Playwright Real Browser ────────────────────────────────
|
|
2664
|
+
dash.setPhase('🎭 Phase 3: Playwright Real Browser Tests');
|
|
2665
|
+
const chromium = await getPlaywright();
|
|
2666
|
+
|
|
2667
|
+
if (chromium) {
|
|
2668
|
+
session.playwrightMode = true;
|
|
2669
|
+
dash.log(chalk.hex('#BF40FF')(' 🎭 Backlist available! Real browser mode ACTIVE'));
|
|
2670
|
+
|
|
2671
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2672
|
+
if (!url) continue;
|
|
2673
|
+
dash.setCurrentTest(`🎭 Browser: ${url}`);
|
|
2674
|
+
|
|
2675
|
+
const pwResult = await runPlaywrightScan(url, session, dash);
|
|
2676
|
+
|
|
2677
|
+
if (pwResult && !pwResult.error) {
|
|
2678
|
+
const { results: pw } = pwResult;
|
|
2679
|
+
|
|
2680
|
+
session.perfMetrics[label] = {
|
|
2681
|
+
...session.perfMetrics[label],
|
|
2682
|
+
...pw.vitals,
|
|
2683
|
+
slowResources: pw.networkFails.filter(n => n.duration > 1000),
|
|
2684
|
+
resourceStats: pw.resourceStats,
|
|
2685
|
+
domChecks: pw.domChecks,
|
|
2686
|
+
interactions: pw.interactions,
|
|
2687
|
+
playwrightMode: true,
|
|
2688
|
+
};
|
|
2689
|
+
|
|
2690
|
+
for (const check of pw.domChecks || []) {
|
|
2691
|
+
addResult({ name: `DOM: ${check.name}`, type: 'browser-dom',
|
|
2692
|
+
status: check.pass ? 'PASS' : 'FAIL', message: check.value, url, label });
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
for (const i of pw.interactions || []) {
|
|
2696
|
+
addResult({ name: `Interaction: ${i.name}`, type: 'browser-interaction',
|
|
2697
|
+
status: i.pass ? 'PASS' : 'FAIL', message: i.value, url, label });
|
|
2698
|
+
if (!i.pass) session.addBug({ title: `Interaction Failed: ${i.name}`, severity: 'P2', type: 'javascript', url, evidence: { value: i.value } });
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// Viewport results
|
|
2702
|
+
for (const [vk, vp] of Object.entries(pw.viewportResults || {})) {
|
|
2703
|
+
if (!vp.error) {
|
|
2704
|
+
addResult({ name: `Viewport: ${vp.label}`, type: 'viewport',
|
|
2705
|
+
status: vp.passed ? 'PASS' : 'FAIL', message: (vp.issues||[]).join(', ') || 'OK', url, label });
|
|
2706
|
+
if (!vp.passed) session.addBug({ title: `Viewport Issue (${vp.label}): ${(vp.issues||[]).join(', ')}`, severity: 'P2', type: 'viewport', url, evidence: { viewport: vk, issues: vp.issues } });
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// User flow
|
|
2711
|
+
if (pw.userFlow?.steps) {
|
|
2712
|
+
for (const step of pw.userFlow.steps) {
|
|
2713
|
+
addResult({ name: `Flow: ${step.name}`, type: 'user-flow',
|
|
2714
|
+
status: step.pass ? 'PASS' : 'FAIL', message: step.error || `${step.duration}ms`, url, label });
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// Dark mode
|
|
2719
|
+
if (pw.darkMode && !pw.darkMode.error) {
|
|
2720
|
+
addResult({ name: `[${label}] Dark Mode Support`, type: 'dark-mode',
|
|
2721
|
+
status: pw.darkMode.supportsDark ? 'PASS' : 'FAIL',
|
|
2722
|
+
message: pw.darkMode.supportsDark ? 'prefers-color-scheme supported' : 'No dark mode support', url, label });
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// Memory
|
|
2726
|
+
if (pw.memoryLeak) {
|
|
2727
|
+
addResult({ name: `[${label}] Memory Leak Check`, type: 'memory',
|
|
2728
|
+
status: pw.memoryLeak.hasLeak ? 'FAIL' : 'PASS',
|
|
2729
|
+
message: `Heap growth: ${pw.memoryLeak.growthMB}MB`, url, label });
|
|
2730
|
+
if (pw.memoryLeak.hasLeak) session.addBug({ title: `Memory leak: +${pw.memoryLeak.growthMB}MB`, severity: pw.memoryLeak.severity, type: 'performance', url, evidence: pw.memoryLeak });
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// Form tests
|
|
2734
|
+
for (const f of pw.forms || []) {
|
|
2735
|
+
addResult({ name: `Form #${f.formIndex+1}: ${f.action||'self'}`, type: 'form',
|
|
2736
|
+
status: f.passed ? 'PASS' : 'FAIL', message: (f.issues||[]).join(', ') || 'OK', url, label });
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// Third-party
|
|
2740
|
+
if (pw.thirdParty?.length > 0) {
|
|
2741
|
+
addResult({ name: `[${label}] Third-party scripts`, type: 'third-party',
|
|
2742
|
+
status: 'PASS', message: `${pw.thirdParty.length} external scripts: ${pw.thirdParty.map(t=>t.vendor).join(', ')}`, url, label });
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// JS errors
|
|
2746
|
+
for (const err of pw.jsErrors || []) {
|
|
2747
|
+
addResult({ name: `JS Error: ${err.message?.slice(0,60)}`, type: 'javascript',
|
|
2748
|
+
status: 'FAIL', message: err.message, url, label, severity: 'P2' });
|
|
2749
|
+
session.addBug({ title: `JS Error: ${err.message?.slice(0,80)}`, severity: 'P2', type: 'javascript', url, evidence: { message: err.message } });
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// Network failures
|
|
2753
|
+
for (const fail of pw.networkFails || []) {
|
|
2754
|
+
addResult({ name: `Network Fail: ${fail.url?.split('/').pop()?.slice(0,40)}`, type: 'network',
|
|
2755
|
+
status: 'FAIL', message: fail.failure || `HTTP ${fail.status}`, url: fail.url, label });
|
|
2756
|
+
session.addBug({ title: `Network Failure: ${fail.url?.split('/').pop()}`, severity: fail.status >= 500 ? 'P1' : 'P2', type: 'network', url: fail.url });
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// Mixed content
|
|
2760
|
+
for (const mc of pw.mixedContent || []) {
|
|
2761
|
+
addResult({ name: `Mixed Content`, type: 'security', status: 'FAIL', message: mc, url, label });
|
|
2762
|
+
session.addBug({ title: `Mixed Content detected`, severity: 'P1', type: 'security', url, evidence: { text: mc } });
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
// Web Vitals
|
|
2766
|
+
const { lcp, fcp, cls, tbt, ttfb } = pw.vitals || {};
|
|
2767
|
+
const vitalTests = [
|
|
2768
|
+
{ name: 'TTFB', val: ttfb || pw.vitals?.ttfb, threshold: 800 },
|
|
2769
|
+
{ name: 'LCP', val: lcp, threshold: 2500 },
|
|
2770
|
+
{ name: 'FCP', val: fcp, threshold: 1800 },
|
|
2771
|
+
{ name: 'CLS', val: cls, threshold: 0.1 },
|
|
2772
|
+
{ name: 'TBT', val: tbt, threshold: 200 },
|
|
2773
|
+
];
|
|
2774
|
+
for (const vt of vitalTests) {
|
|
2775
|
+
if (vt.val !== undefined && vt.val !== null) {
|
|
2776
|
+
addResult({ name: `[${label}] ${vt.name}`, type: 'performance',
|
|
2777
|
+
status: vt.val <= vt.threshold ? 'PASS' : 'FAIL',
|
|
2778
|
+
message: `${vt.name}: ${vt.val}`, url, label });
|
|
2779
|
+
if (vt.val > vt.threshold && vt.name === 'LCP') {
|
|
2780
|
+
session.addBug({ title: `Poor LCP: ${lcp}ms`, severity: lcp > 4000 ? 'P1' : 'P2', type: 'performance', url, evidence: { lcp } });
|
|
2781
|
+
}
|
|
2782
|
+
if (vt.val > vt.threshold && vt.name === 'CLS') {
|
|
2783
|
+
session.addBug({ title: `High CLS: ${cls}`, severity: 'P2', type: 'performance', url, evidence: { cls } });
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'PASS',
|
|
2789
|
+
message: `${pw.screenshots?.length||0} screenshots, ${pw.domChecks?.length||0} DOM checks, ${Object.keys(pw.viewportResults||{}).length} viewports`, url, label });
|
|
2790
|
+
|
|
2791
|
+
} else {
|
|
2792
|
+
addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'FAIL',
|
|
2793
|
+
message: pwResult?.error || 'Playwright scan failed', url, label });
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
} else {
|
|
2797
|
+
dash.log(chalk.yellow(' ⚠ Playwright not installed — HTTP-only mode'));
|
|
2798
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2799
|
+
if (!url) continue;
|
|
2800
|
+
const t0 = Date.now();
|
|
2801
|
+
const r = await httpProbe(url, { timeout: 15000 });
|
|
2802
|
+
const ttfb = Date.now() - t0;
|
|
2803
|
+
session.perfMetrics[label] = { ttfb, bodySize: r.bodySize, statusCode: r.status, slowResources: [],
|
|
2804
|
+
note: 'Install Playwright for real Web Vitals, screenshots, dark mode, viewport tests' };
|
|
2805
|
+
addResult({ name: `[${label}] TTFB`, type: 'performance',
|
|
2806
|
+
status: ttfb <= 800 ? 'PASS' : 'FAIL', message: `TTFB: ${ttfb}ms`, url, label });
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// ── Phase 4: API Validation ──────────────────────────────────────────
|
|
2811
|
+
dash.setPhase('📡 Phase 4: API Validation & Contract Testing');
|
|
1030
2812
|
const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
|
|
1031
2813
|
dash.log(`Validating ${apiRoutes.length} API endpoints...`);
|
|
1032
2814
|
for (const route of apiRoutes) {
|
|
1033
2815
|
dash.setCurrentTest(`API: ${route.url}`);
|
|
1034
|
-
const
|
|
1035
|
-
session.
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
description:
|
|
2816
|
+
const contract = await testAPIContract(route.url);
|
|
2817
|
+
session.apiContracts.push(contract);
|
|
2818
|
+
session.apiLog.push({ ...contract, id: shortId() });
|
|
2819
|
+
addResult({ name: `API: ${route.url}`, type: 'api',
|
|
2820
|
+
status: contract.passed ? 'PASS' : 'FAIL',
|
|
2821
|
+
message: `${contract.status} (${contract.responseTime}ms)${contract.issues.length ? ' · ' + contract.issues.join(', ') : ''}`,
|
|
2822
|
+
url: route.url, duration: contract.responseTime });
|
|
2823
|
+
if (!contract.passed) session.addBug({ title: `API Issue: ${route.url}`, severity: contract.status >= 500 ? 'P0' : 'P1', type: 'api',
|
|
2824
|
+
description: contract.issues.join(', '), evidence: { status: contract.status, issues: contract.issues } });
|
|
1043
2825
|
}
|
|
1044
2826
|
|
|
1045
|
-
// ── Phase
|
|
1046
|
-
dash.setPhase('🛡️ Phase
|
|
2827
|
+
// ── Phase 5: Security ────────────────────────────────────────────────
|
|
2828
|
+
dash.setPhase('🛡️ Phase 5: Security Scan (20+ checks)');
|
|
1047
2829
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1048
2830
|
if (!url) continue;
|
|
1049
2831
|
dash.setCurrentTest(`Security: ${url}`);
|
|
1050
2832
|
const findings = await runSecurityScan(url);
|
|
1051
2833
|
session.secFindings.push(...findings);
|
|
1052
2834
|
for (const f of findings) {
|
|
1053
|
-
addResult({ name: `Security: ${f.check}`, type: 'security',
|
|
2835
|
+
addResult({ name: `Security: ${f.check}`, type: 'security',
|
|
1054
2836
|
status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
|
|
1055
2837
|
if (!f.pass && ['P0','P1'].includes(f.severity)) {
|
|
1056
2838
|
session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
|
|
@@ -1059,70 +2841,130 @@ async function runQAEngine(session) {
|
|
|
1059
2841
|
}
|
|
1060
2842
|
}
|
|
1061
2843
|
|
|
1062
|
-
// ── Phase
|
|
1063
|
-
dash.setPhase('
|
|
2844
|
+
// ── Phase 6: Cookie Audit (v15) ──────────────────────────────────────
|
|
2845
|
+
dash.setPhase('🍪 Phase 6: Cookie Security Audit');
|
|
1064
2846
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1065
2847
|
if (!url) continue;
|
|
1066
|
-
dash.setCurrentTest(`
|
|
1067
|
-
const
|
|
1068
|
-
session.
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
category: 'resource', status: 'FAIL', message: `${res.duration}ms (${formatBytes(res.size)})`, url, label });
|
|
2848
|
+
dash.setCurrentTest(`Cookies: ${url}`);
|
|
2849
|
+
const cookieResult = await runCookieAudit(url);
|
|
2850
|
+
session.cookieAudit.push(cookieResult);
|
|
2851
|
+
for (const cookie of cookieResult.cookies) {
|
|
2852
|
+
addResult({ name: `Cookie: ${cookie.name}`, type: 'security',
|
|
2853
|
+
status: cookie.issues.length === 0 ? 'PASS' : 'FAIL',
|
|
2854
|
+
message: cookie.issues.join(', ') || 'Secure', url, label, severity: cookie.severity });
|
|
2855
|
+
if (cookie.issues.length > 0 && cookie.severity !== 'INFO') {
|
|
2856
|
+
session.addBug({ title: `Cookie "${cookie.name}": ${cookie.issues.join(', ')}`, severity: cookie.severity, type: 'security', url, evidence: { cookie: cookie.name, issues: cookie.issues } });
|
|
2857
|
+
}
|
|
1077
2858
|
}
|
|
1078
2859
|
}
|
|
1079
2860
|
|
|
1080
|
-
// ── Phase
|
|
1081
|
-
dash.setPhase('
|
|
1082
|
-
const
|
|
1083
|
-
|
|
2861
|
+
// ── Phase 7: Cache Headers (v15) ─────────────────────────────────────
|
|
2862
|
+
dash.setPhase('💾 Phase 7: Cache Headers Audit');
|
|
2863
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2864
|
+
if (!url) continue;
|
|
2865
|
+
const cacheResult = await auditCacheHeaders(url);
|
|
2866
|
+
session.cacheHeaders.push(cacheResult);
|
|
2867
|
+
addResult({ name: `[${label}] Cache Headers`, type: 'performance',
|
|
2868
|
+
status: cacheResult.passed ? 'PASS' : 'FAIL',
|
|
2869
|
+
message: cacheResult.cacheControl || 'No cache headers', url, label });
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
// ── Phase 8: Broken Links (v15) ──────────────────────────────────────
|
|
2873
|
+
dash.setPhase('🔗 Phase 8: Broken Link Scanner');
|
|
2874
|
+
const pageRoutes8 = session.routeMap.filter(r => r.type === 'page').slice(0, 5);
|
|
2875
|
+
for (const route of pageRoutes8) {
|
|
2876
|
+
dash.setCurrentTest(`Links: ${route.url}`);
|
|
2877
|
+
const blResult = await scanBrokenLinks(route.url, { maxLinks: 60 });
|
|
2878
|
+
session.brokenLinks.push(blResult);
|
|
2879
|
+
addResult({ name: `Broken Links: ${route.url}`, type: 'broken-links',
|
|
2880
|
+
status: blResult.broken === 0 ? 'PASS' : 'FAIL',
|
|
2881
|
+
message: `${blResult.broken}/${blResult.total} links broken`, url: route.url });
|
|
2882
|
+
for (const bl of blResult.links.filter(l => !l.ok).slice(0, 5)) {
|
|
2883
|
+
session.addBug({ title: `Broken link: ${bl.url}`, severity: 'P2', type: 'network',
|
|
2884
|
+
url: route.url, evidence: { brokenUrl: bl.url, status: bl.status } });
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// ── Phase 9: Error Pages (v15) ───────────────────────────────────────
|
|
2889
|
+
dash.setPhase('🚫 Phase 9: Error Page Testing');
|
|
2890
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2891
|
+
if (!url) continue;
|
|
2892
|
+
const errResults = await testErrorPages(url);
|
|
2893
|
+
session.errorPageTests.push(...errResults);
|
|
2894
|
+
for (const er of errResults) {
|
|
2895
|
+
addResult({ name: `${er.name}: ${url}`, type: 'error-page',
|
|
2896
|
+
status: er.passed ? 'PASS' : 'FAIL', message: er.issues.join(', ') || 'OK', url, label });
|
|
2897
|
+
if (!er.passed) session.addBug({ title: `${er.name} issue: ${er.issues.join(', ')}`, severity: 'P2', type: 'api', url, evidence: { test: er.name, status: er.actualStatus, issues: er.issues } });
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// ── Phase 10: Accessibility ──────────────────────────────────────────
|
|
2902
|
+
dash.setPhase('♿ Phase 10: Accessibility Check (WCAG 2.1)');
|
|
2903
|
+
const pageRoutes10 = session.routeMap.filter(r => r.type === 'page' || r.type === 'auth').slice(0, 10);
|
|
2904
|
+
for (const route of pageRoutes10) {
|
|
1084
2905
|
dash.setCurrentTest(`A11y: ${route.url}`);
|
|
1085
2906
|
const result = await runA11yScan(route.url);
|
|
1086
2907
|
session.a11yResults.push({ url: route.url, ...result });
|
|
1087
2908
|
for (const v of result.violations) {
|
|
1088
|
-
addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility',
|
|
1089
|
-
status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' :
|
|
1090
|
-
|
|
1091
|
-
if (['critical','serious'].includes(v.impact)) session.addBug({
|
|
1092
|
-
title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
|
|
1093
|
-
type: 'accessibility', description: v.help, url: route.url,
|
|
1094
|
-
recommendation: v.helpUrl });
|
|
2909
|
+
addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility',
|
|
2910
|
+
status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' : 'P1', url: route.url });
|
|
2911
|
+
if (['critical','serious'].includes(v.impact)) session.addBug({ title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1', type: 'accessibility', description: v.help, url: route.url });
|
|
1095
2912
|
}
|
|
1096
|
-
for (const pass of result.passes.slice(0,
|
|
2913
|
+
for (const pass of result.passes.slice(0, 4)) {
|
|
1097
2914
|
addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
|
|
1098
2915
|
}
|
|
1099
2916
|
}
|
|
1100
2917
|
|
|
1101
|
-
// ── Phase
|
|
1102
|
-
dash.setPhase('🔎 Phase
|
|
2918
|
+
// ── Phase 11: SEO ────────────────────────────────────────────────────
|
|
2919
|
+
dash.setPhase('🔎 Phase 11: SEO Validation (21 checks)');
|
|
1103
2920
|
const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
|
|
1104
2921
|
for (const route of seoRoutes) {
|
|
1105
2922
|
dash.setCurrentTest(`SEO: ${route.url}`);
|
|
1106
2923
|
const result = await runSEOScan(route.url);
|
|
1107
2924
|
session.seoResults.push({ url: route.url, ...result });
|
|
1108
2925
|
for (const c of result.checks) {
|
|
1109
|
-
addResult({ name: `SEO: ${c.name}`, type: 'seo',
|
|
2926
|
+
addResult({ name: `SEO: ${c.name}`, type: 'seo',
|
|
1110
2927
|
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
|
|
1111
|
-
if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
|
|
1112
|
-
|
|
1113
|
-
|
|
2928
|
+
if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({ title: `SEO: ${c.name}`, severity: c.severity, type: 'seo', description: c.detail, url: route.url });
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// ── Phase 12: Load Test (v15) ────────────────────────────────────────
|
|
2933
|
+
if (opts.loadTest !== false) {
|
|
2934
|
+
dash.setPhase('🔥 Phase 12: Load Testing');
|
|
2935
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2936
|
+
if (!url) continue;
|
|
2937
|
+
dash.setCurrentTest(`Load test: ${url} (10 concurrent, 10s)`);
|
|
2938
|
+
dash.log(chalk.yellow(` 🔥 Load testing ${url}...`));
|
|
2939
|
+
const lt = await runLoadTest(url, { concurrency: 10, duration: 10000, rampUp: 2000 });
|
|
2940
|
+
session.loadTestResults.push(lt);
|
|
2941
|
+
addResult({ name: `[${label}] Load Test`, type: 'load-test',
|
|
2942
|
+
status: lt.passed ? 'PASS' : 'FAIL',
|
|
2943
|
+
message: `${lt.rps} req/s · p95=${lt.latency.p95}ms · ${lt.errorRate}% errors`, url, label });
|
|
2944
|
+
if (!lt.passed) session.addBug({ title: `Load test failed: ${lt.errorRate}% error rate or slow p95`, severity: lt.errorRate > 20 ? 'P1' : 'P2', type: 'performance', url, evidence: { rps: lt.rps, p95: lt.latency.p95, errorRate: lt.errorRate } });
|
|
2945
|
+
dash.log(chalk.green(` ✓ Load test: ${lt.rps} req/s, p95=${lt.latency.p95}ms`));
|
|
1114
2946
|
}
|
|
1115
2947
|
}
|
|
1116
2948
|
|
|
1117
|
-
// ── Phase
|
|
1118
|
-
dash.setPhase('
|
|
2949
|
+
// ── Phase 13: HTTP Version (v15) ─────────────────────────────────────
|
|
2950
|
+
dash.setPhase('🌐 Phase 13: HTTP Version & Protocol Check');
|
|
2951
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2952
|
+
if (!url) continue;
|
|
2953
|
+
const http = await inspectHTTPVersion(url);
|
|
2954
|
+
session.httpVersions[label] = http;
|
|
2955
|
+
addResult({ name: `[${label}] HTTPS`, type: 'security',
|
|
2956
|
+
status: http.isHTTPS ? 'PASS' : 'FAIL', message: http.isHTTPS ? 'HTTPS in use' : 'HTTP only', url, label });
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
// ── Phase 14: AI Classification ──────────────────────────────────────
|
|
2960
|
+
dash.setPhase('🤖 Phase 14: AI Bug Classification');
|
|
1119
2961
|
dash.log(`Classifying ${session.bugs.length} bugs...`);
|
|
1120
2962
|
for (const bug of session.bugs) {
|
|
1121
2963
|
const cls = classifyBug(bug);
|
|
1122
|
-
bug.aiSeverity
|
|
1123
|
-
bug.aiCategory
|
|
2964
|
+
bug.aiSeverity = cls.severity;
|
|
2965
|
+
bug.aiCategory = cls.category;
|
|
1124
2966
|
bug.aiRecommendation = cls.recommendation;
|
|
1125
|
-
bug.aiConfidence
|
|
2967
|
+
bug.aiConfidence = cls.confidence;
|
|
1126
2968
|
}
|
|
1127
2969
|
session.bugs.sort((a, b) => {
|
|
1128
2970
|
const o = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
@@ -1141,22 +2983,33 @@ async function runQAEngine(session) {
|
|
|
1141
2983
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1142
2984
|
async function generateReports(session) {
|
|
1143
2985
|
await fs.ensureDir(REPORT_DIR);
|
|
1144
|
-
const base
|
|
1145
|
-
const htmlPath
|
|
1146
|
-
const jsonPath
|
|
1147
|
-
const summary
|
|
2986
|
+
const base = session.id.toLowerCase();
|
|
2987
|
+
const htmlPath = path.join(REPORT_DIR, `${base}.html`);
|
|
2988
|
+
const jsonPath = path.join(REPORT_DIR, `${base}.json`);
|
|
2989
|
+
const summary = session.getSummary();
|
|
1148
2990
|
|
|
1149
2991
|
await fs.writeFile(htmlPath, buildHTMLReport(session), 'utf8');
|
|
1150
2992
|
await fs.writeJson(jsonPath, {
|
|
1151
|
-
meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
|
|
2993
|
+
meta: { version: VERSION, runId: session.id, generatedAt: new Date().toISOString(),
|
|
2994
|
+
dataSource: session.playwrightMode ? 'playwright-real-browser' : 'http-only' },
|
|
1152
2995
|
urls: session.urls, summary, results: session.results, bugs: session.bugs,
|
|
1153
2996
|
routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
|
|
1154
2997
|
perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
|
|
2998
|
+
cookieAudit: session.cookieAudit, loadTestResults: session.loadTestResults,
|
|
2999
|
+
brokenLinks: session.brokenLinks, redirectChains: session.redirectChains,
|
|
3000
|
+
memorySnapshots: session.memorySnapshots, darkModeResults: session.darkModeResults,
|
|
3001
|
+
viewportResults: session.viewportResults, userFlowResults: session.userFlowResults,
|
|
3002
|
+
thirdPartyScripts: session.thirdPartyScripts, cacheHeaders: session.cacheHeaders,
|
|
3003
|
+
errorPageTests: session.errorPageTests, formTests: session.formTests,
|
|
3004
|
+
mixedContentIssues: session.mixedContentIssues, cspViolations: session.cspViolations,
|
|
3005
|
+
httpVersions: session.httpVersions,
|
|
3006
|
+
screenshots: session.screenshots.map(s => ({ ...s, path: undefined })),
|
|
3007
|
+
playwrightMode: session.playwrightMode,
|
|
1155
3008
|
ci: {
|
|
1156
|
-
exitCode
|
|
1157
|
-
p0Bugs
|
|
1158
|
-
p1Bugs
|
|
1159
|
-
passRate
|
|
3009
|
+
exitCode: summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
|
|
3010
|
+
p0Bugs: session.bugs.filter(b => b.severity === 'P0').length,
|
|
3011
|
+
p1Bugs: session.bugs.filter(b => b.severity === 'P1').length,
|
|
3012
|
+
passRate: summary.passRate,
|
|
1160
3013
|
},
|
|
1161
3014
|
}, { spaces: 2 });
|
|
1162
3015
|
|
|
@@ -1170,6 +3023,7 @@ export async function initQASystem() {
|
|
|
1170
3023
|
await fs.ensureDir(QA_DIR);
|
|
1171
3024
|
await fs.ensureDir(REPORT_DIR);
|
|
1172
3025
|
await fs.ensureDir(SCREENSHOT_DIR);
|
|
3026
|
+
await fs.ensureDir(BASELINE_DIR);
|
|
1173
3027
|
if (!await fs.pathExists(HISTORY_FILE)) {
|
|
1174
3028
|
await fs.writeJson(HISTORY_FILE, { runs: [], version: VERSION }, { spaces: 2 });
|
|
1175
3029
|
}
|
|
@@ -1182,6 +3036,8 @@ async function saveToHistory(session, htmlPath, jsonPath) {
|
|
|
1182
3036
|
history.runs.unshift({
|
|
1183
3037
|
id: session.id, startedAt: session.startedAt, urls: session.urls,
|
|
1184
3038
|
summary, version: VERSION, bugCount: session.bugs.length,
|
|
3039
|
+
screenshotCount: session.screenshots.length,
|
|
3040
|
+
playwrightMode: session.playwrightMode,
|
|
1185
3041
|
htmlPath, jsonPath,
|
|
1186
3042
|
});
|
|
1187
3043
|
if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
|
|
@@ -1189,10 +3045,9 @@ async function saveToHistory(session, htmlPath, jsonPath) {
|
|
|
1189
3045
|
}
|
|
1190
3046
|
|
|
1191
3047
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1192
|
-
// Public API
|
|
3048
|
+
// Public API — runUrlQA (main entry point)
|
|
1193
3049
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1194
|
-
|
|
1195
|
-
export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
3050
|
+
export async function runUrlQA({ localUrl, stagingUrl, prodUrl, loadTest = true } = {}) {
|
|
1196
3051
|
const urls = {};
|
|
1197
3052
|
if (localUrl) urls.localhost = localUrl;
|
|
1198
3053
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
@@ -1200,17 +3055,31 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
|
1200
3055
|
|
|
1201
3056
|
if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
|
|
1202
3057
|
|
|
3058
|
+
const chromium = await getPlaywright();
|
|
3059
|
+
console.log('');
|
|
3060
|
+
console.log(chalk.hex('#00F5FF').bold(` ⚡ Backlist QA Engine v${VERSION}`));
|
|
3061
|
+
console.log(chalk.hex('#00F5FF')(' ─────────────────────────────────────────'));
|
|
3062
|
+
if (chromium) {
|
|
3063
|
+
console.log(chalk.hex('#BF40FF')(' 🎭 Backlist: Real Browser Mode ACTIVE'));
|
|
3064
|
+
console.log(chalk.gray(' Multi-viewport · Dark mode · Memory · User flows'));
|
|
3065
|
+
console.log(chalk.gray(' Screenshots (7 viewports) · Web Vitals · Forms'));
|
|
3066
|
+
} else {
|
|
3067
|
+
console.log(chalk.yellow(' ⚠ Playwright not found — HTTP-only mode'));
|
|
3068
|
+
console.log(chalk.gray(' Run: npm install playwright && npx playwright install chromium'));
|
|
3069
|
+
}
|
|
3070
|
+
console.log('');
|
|
3071
|
+
|
|
1203
3072
|
const session = new QASession(urls);
|
|
1204
|
-
await runQAEngine(session);
|
|
3073
|
+
await runQAEngine(session, { loadTest });
|
|
1205
3074
|
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1206
3075
|
await saveToHistory(session, htmlPath, jsonPath);
|
|
1207
3076
|
|
|
1208
3077
|
const summary = session.getSummary();
|
|
1209
|
-
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs`));
|
|
3078
|
+
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
|
|
1210
3079
|
console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
|
|
1211
3080
|
console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
|
|
3081
|
+
if (session.screenshots.length > 0) console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
|
|
1212
3082
|
|
|
1213
|
-
// Auto-open report
|
|
1214
3083
|
try {
|
|
1215
3084
|
const { exec } = await import('node:child_process');
|
|
1216
3085
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -1221,14 +3090,14 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
|
1221
3090
|
return { session, htmlPath, jsonPath };
|
|
1222
3091
|
}
|
|
1223
3092
|
|
|
1224
|
-
export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
|
|
3093
|
+
export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl, loadTest = false } = {}) {
|
|
1225
3094
|
const run = async () => {
|
|
1226
3095
|
const urls = {};
|
|
1227
3096
|
if (localUrl) urls.localhost = localUrl;
|
|
1228
3097
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
1229
3098
|
if (prodUrl) urls.production = prodUrl;
|
|
1230
3099
|
const session = new QASession(urls);
|
|
1231
|
-
await runQAEngine(session);
|
|
3100
|
+
await runQAEngine(session, { loadTest });
|
|
1232
3101
|
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1233
3102
|
await saveToHistory(session, htmlPath, jsonPath);
|
|
1234
3103
|
console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
@@ -1249,11 +3118,15 @@ export async function runManualQA() {
|
|
|
1249
3118
|
const action = await p.select({
|
|
1250
3119
|
message: 'Manual QA mode:',
|
|
1251
3120
|
options: [
|
|
1252
|
-
{ value: 'full',
|
|
1253
|
-
{ value: '
|
|
1254
|
-
{ value: '
|
|
1255
|
-
{ value: '
|
|
1256
|
-
{ value: '
|
|
3121
|
+
{ value: 'full', label: '🌐 Full Scan (All 14 phases + Playwright)' },
|
|
3122
|
+
{ value: 'browser', label: '🎭 Browser-only (screenshots, vitals, dark mode, viewports)' },
|
|
3123
|
+
{ value: 'security', label: '🛡️ Security only (20+ checks)' },
|
|
3124
|
+
{ value: 'seo', label: '🔎 SEO only (21 checks)' },
|
|
3125
|
+
{ value: 'a11y', label: '♿ Accessibility only (WCAG 2.1)' },
|
|
3126
|
+
{ value: 'perf', label: '⚡ Performance + Load Test' },
|
|
3127
|
+
{ value: 'links', label: '🔗 Broken Link Scanner' },
|
|
3128
|
+
{ value: 'cookies', label: '🍪 Cookie Audit' },
|
|
3129
|
+
{ value: 'userflow', label: '🧑💻 User Flow Simulation' },
|
|
1257
3130
|
],
|
|
1258
3131
|
});
|
|
1259
3132
|
if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
|
|
@@ -1265,31 +3138,63 @@ export async function runManualQA() {
|
|
|
1265
3138
|
const sess = new QASession({ localhost: url });
|
|
1266
3139
|
|
|
1267
3140
|
if (action === 'full') {
|
|
1268
|
-
await runQAEngine(sess);
|
|
3141
|
+
await runQAEngine(sess, { loadTest: true });
|
|
1269
3142
|
} else {
|
|
1270
3143
|
const dash = new TerminalDashboard(sess);
|
|
1271
3144
|
dash.start();
|
|
1272
3145
|
try {
|
|
1273
|
-
if (action === '
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
3146
|
+
if (action === 'browser') {
|
|
3147
|
+
const chromium = await getPlaywright();
|
|
3148
|
+
if (!chromium) { dash.log(chalk.red('Playwright not installed!')); }
|
|
3149
|
+
else {
|
|
3150
|
+
sess.playwrightMode = true;
|
|
3151
|
+
await runPlaywrightScan(url, sess, dash);
|
|
3152
|
+
}
|
|
3153
|
+
} else if (action === 'security') {
|
|
3154
|
+
const findings = await runSecurityScan(url);
|
|
3155
|
+
sess.secFindings.push(...findings);
|
|
3156
|
+
findings.forEach(f => sess.addResult({ id: shortId(), name: `Security: ${f.check}`, type: 'security', status: f.pass ? 'PASS' : 'FAIL', message: f.detail, timestamp: timestamp() }));
|
|
1278
3157
|
} else if (action === 'seo') {
|
|
1279
3158
|
const r = await runSEOScan(url);
|
|
1280
3159
|
sess.seoResults.push({ url, ...r });
|
|
1281
|
-
r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
|
|
1282
|
-
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
|
|
3160
|
+
r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo', status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
|
|
1283
3161
|
} else if (action === 'a11y') {
|
|
1284
3162
|
const r = await runA11yScan(url);
|
|
1285
3163
|
sess.a11yResults.push({ url, ...r });
|
|
1286
|
-
r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
|
|
1287
|
-
|
|
3164
|
+
r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility', status: 'FAIL', message: v.help, timestamp: timestamp() }));
|
|
3165
|
+
r.passes.forEach(p => sess.addResult({ id: shortId(), name: `A11y ✓: ${p.description}`, type: 'accessibility', status: 'PASS', timestamp: timestamp() }));
|
|
1288
3166
|
} else if (action === 'perf') {
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
3167
|
+
const chromium2 = await getPlaywright();
|
|
3168
|
+
if (chromium2) {
|
|
3169
|
+
sess.playwrightMode = true;
|
|
3170
|
+
await runPlaywrightScan(url, sess, dash);
|
|
3171
|
+
}
|
|
3172
|
+
const lt = await runLoadTest(url, { concurrency: 10, duration: 8000 });
|
|
3173
|
+
sess.loadTestResults.push(lt);
|
|
3174
|
+
sess.addResult({ id: shortId(), name: `Load Test: ${lt.rps} req/s`, type: 'load-test', status: lt.passed ? 'PASS' : 'FAIL', message: `p95=${lt.latency.p95}ms errors=${lt.errorRate}%`, timestamp: timestamp() });
|
|
3175
|
+
} else if (action === 'links') {
|
|
3176
|
+
dash.log(chalk.cyan('Scanning for broken links...'));
|
|
3177
|
+
const bl = await scanBrokenLinks(url, { maxLinks: 100 });
|
|
3178
|
+
sess.brokenLinks.push(bl);
|
|
3179
|
+
sess.addResult({ id: shortId(), name: `Broken Links: ${bl.broken}/${bl.total}`, type: 'broken-links', status: bl.broken === 0 ? 'PASS' : 'FAIL', message: `${bl.broken} broken links found`, timestamp: timestamp() });
|
|
3180
|
+
} else if (action === 'cookies') {
|
|
3181
|
+
const ca = await runCookieAudit(url);
|
|
3182
|
+
sess.cookieAudit.push(ca);
|
|
3183
|
+
ca.cookies.forEach(c => sess.addResult({ id: shortId(), name: `Cookie: ${c.name}`, type: 'security', status: c.issues.length === 0 ? 'PASS' : 'FAIL', message: c.issues.join(', ') || 'OK', timestamp: timestamp() }));
|
|
3184
|
+
} else if (action === 'userflow') {
|
|
3185
|
+
const chromium3 = await getPlaywright();
|
|
3186
|
+
if (chromium3) {
|
|
3187
|
+
sess.playwrightMode = true;
|
|
3188
|
+
const browser = await chromium3.launch({ headless: true, args: ['--no-sandbox'] });
|
|
3189
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
3190
|
+
const page = await context.newPage();
|
|
3191
|
+
const flow = await simulateUserFlow(page, url);
|
|
3192
|
+
sess.userFlowResults.push(flow);
|
|
3193
|
+
flow.steps.forEach(s => sess.addResult({ id: shortId(), name: `Flow: ${s.name}`, type: 'user-flow', status: s.pass ? 'PASS' : 'FAIL', message: s.error || `${s.duration}ms`, timestamp: timestamp() }));
|
|
3194
|
+
await page.close(); await context.close(); await browser.close();
|
|
3195
|
+
} else {
|
|
3196
|
+
dash.log(chalk.red('Playwright required for user flow simulation'));
|
|
3197
|
+
}
|
|
1293
3198
|
}
|
|
1294
3199
|
} finally { dash.stop(); }
|
|
1295
3200
|
}
|
|
@@ -1320,13 +3225,14 @@ export async function viewQAHistory() {
|
|
|
1320
3225
|
if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
|
|
1321
3226
|
|
|
1322
3227
|
console.log('');
|
|
1323
|
-
console.log(chalk.hex('#00F5FF').bold(' QA History'));
|
|
1324
|
-
console.log(chalk.gray('
|
|
3228
|
+
console.log(chalk.hex('#00F5FF').bold(' QA History — v15'));
|
|
3229
|
+
console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
|
|
1325
3230
|
for (const run of history.runs.slice(0, 15)) {
|
|
1326
3231
|
const rate = run.summary?.passRate ?? '–';
|
|
1327
3232
|
const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
|
|
1328
3233
|
const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
|
|
1329
|
-
|
|
3234
|
+
const pwIcon = run.playwrightMode ? chalk.hex('#BF40FF')('🎭') : chalk.gray('⚡');
|
|
3235
|
+
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')} ${chalk.magenta((run.screenshotCount||0)+' 📸')} ${pwIcon} ${chalk.dim(urls.slice(0,40))}`);
|
|
1330
3236
|
}
|
|
1331
3237
|
console.log('');
|
|
1332
3238
|
|
|
@@ -1335,7 +3241,7 @@ export async function viewQAHistory() {
|
|
|
1335
3241
|
options: [
|
|
1336
3242
|
...history.runs.slice(0, 8).map(r => ({
|
|
1337
3243
|
value: r.htmlPath || r.id,
|
|
1338
|
-
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs`,
|
|
3244
|
+
label: `${r.id} — ${new Date(r.startedAt).toLocaleString()} — ${r.bugCount} bugs${r.playwrightMode ? ' 🎭' : ''}`,
|
|
1339
3245
|
})),
|
|
1340
3246
|
{ value: '__back', label: '↩ Back' },
|
|
1341
3247
|
],
|