create-backlist 10.1.0 → 10.1.2
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 -1057
- 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 +1863 -564
- 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,15 +13,28 @@ 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
40
|
export const timestamp = () => new Date().toISOString();
|
|
@@ -58,8 +75,15 @@ async function getPlaywright() {
|
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
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
|
+
|
|
61
85
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
62
|
-
// QA Session
|
|
86
|
+
// QA Session v15 — Extended with new trackers
|
|
63
87
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
88
|
export class QASession {
|
|
65
89
|
id;
|
|
@@ -77,6 +101,30 @@ export class QASession {
|
|
|
77
101
|
a11yResults = [];
|
|
78
102
|
seoResults = [];
|
|
79
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 = {};
|
|
80
128
|
|
|
81
129
|
constructor(urls = {}) {
|
|
82
130
|
this.id = `QA-${shortId().toUpperCase()}`;
|
|
@@ -108,19 +156,21 @@ export class QASession {
|
|
|
108
156
|
}
|
|
109
157
|
|
|
110
158
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
-
// HTTP Probe — real HTTP requests
|
|
159
|
+
// HTTP Probe — real HTTP requests with v15 extras
|
|
112
160
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
-
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} } = {}) {
|
|
161
|
+
async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {}, body: reqBody = null, followRedirects = true } = {}) {
|
|
114
162
|
const t0 = Date.now();
|
|
115
163
|
try {
|
|
116
164
|
const ctrl = new AbortController();
|
|
117
165
|
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
118
|
-
const
|
|
166
|
+
const fetchOpts = {
|
|
119
167
|
method,
|
|
120
168
|
signal : ctrl.signal,
|
|
121
|
-
headers : { 'User-Agent': 'Backlist-QA/
|
|
122
|
-
redirect: 'follow',
|
|
123
|
-
}
|
|
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);
|
|
124
174
|
clearTimeout(timer);
|
|
125
175
|
|
|
126
176
|
const rt = Date.now() - t0;
|
|
@@ -137,7 +187,7 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
137
187
|
return {
|
|
138
188
|
ok: res.status >= 200 && res.status < 400,
|
|
139
189
|
status: res.status, contentType, headers: hdrs,
|
|
140
|
-
body: body.slice(0,
|
|
190
|
+
body: body.slice(0, 5000), parsed, bodySize,
|
|
141
191
|
responseTime: rt, url, method, error: null,
|
|
142
192
|
};
|
|
143
193
|
} catch (err) {
|
|
@@ -151,13 +201,726 @@ async function httpProbe(url, { method = 'GET', timeout = 12000, headers = {} }
|
|
|
151
201
|
}
|
|
152
202
|
|
|
153
203
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
161
924
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
162
925
|
async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
163
926
|
const chromium = await getPlaywright();
|
|
@@ -166,39 +929,51 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
166
929
|
return null;
|
|
167
930
|
}
|
|
168
931
|
|
|
169
|
-
dash?.log(chalk.cyan(` 🎭
|
|
932
|
+
dash?.log(chalk.cyan(` 🎭 backlist browser launching for ${url}...`));
|
|
170
933
|
|
|
171
934
|
let browser, context, page;
|
|
172
935
|
const results = {
|
|
173
|
-
consoleErrors
|
|
174
|
-
networkFails
|
|
175
|
-
screenshots
|
|
176
|
-
vitals
|
|
177
|
-
interactions
|
|
178
|
-
domChecks
|
|
179
|
-
jsErrors
|
|
936
|
+
consoleErrors : [],
|
|
937
|
+
networkFails : [],
|
|
938
|
+
screenshots : [],
|
|
939
|
+
vitals : {},
|
|
940
|
+
interactions : [],
|
|
941
|
+
domChecks : [],
|
|
942
|
+
jsErrors : [],
|
|
180
943
|
networkRequests: [],
|
|
944
|
+
darkMode : {},
|
|
945
|
+
viewportResults: {},
|
|
946
|
+
fonts : {},
|
|
947
|
+
thirdParty : [],
|
|
948
|
+
forms : [],
|
|
949
|
+
memoryLeak : {},
|
|
950
|
+
userFlow : {},
|
|
951
|
+
mixedContent : {},
|
|
952
|
+
cspViolations : [],
|
|
181
953
|
};
|
|
182
954
|
|
|
183
955
|
try {
|
|
184
956
|
browser = await chromium.launch({
|
|
185
957
|
headless: options.headless !== false,
|
|
186
|
-
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
|
958
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--enable-precise-memory-info'],
|
|
187
959
|
});
|
|
188
960
|
|
|
189
961
|
context = await browser.newContext({
|
|
190
962
|
viewport: { width: 1280, height: 900 },
|
|
191
|
-
userAgent: 'Backlist-QA/
|
|
963
|
+
userAgent: 'Backlist-QA/15.0 (Playwright)',
|
|
192
964
|
ignoreHTTPSErrors: true,
|
|
193
|
-
recordVideo: options.recordVideo ? { dir: SCREENSHOT_DIR } : undefined,
|
|
194
965
|
});
|
|
195
966
|
|
|
196
967
|
page = await context.newPage();
|
|
197
968
|
|
|
198
|
-
// ──
|
|
969
|
+
// ── Mixed Content & CSP violations ──────────────────────────────────
|
|
970
|
+
const mixedContent = [];
|
|
971
|
+
const cspViolations2 = [];
|
|
199
972
|
page.on('console', (msg) => {
|
|
200
973
|
const type = msg.type();
|
|
201
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);
|
|
202
977
|
if (['error', 'warning'].includes(type)) {
|
|
203
978
|
const entry = { type, text, timestamp: Date.now(), url: page.url() };
|
|
204
979
|
results.consoleErrors.push(entry);
|
|
@@ -220,10 +995,8 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
220
995
|
});
|
|
221
996
|
page.on('requestfailed', (req) => {
|
|
222
997
|
const entry = {
|
|
223
|
-
url
|
|
224
|
-
|
|
225
|
-
failure : req.failure()?.errorText || 'unknown',
|
|
226
|
-
timestamp: Date.now(),
|
|
998
|
+
url: req.url(), method: req.method(),
|
|
999
|
+
failure: req.failure()?.errorText || 'unknown', timestamp: Date.now(),
|
|
227
1000
|
};
|
|
228
1001
|
results.networkFails.push(entry);
|
|
229
1002
|
session.networkLog.push(entry);
|
|
@@ -232,11 +1005,9 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
232
1005
|
const start = requestTimings.get(res.url()) || Date.now();
|
|
233
1006
|
const duration = Date.now() - start;
|
|
234
1007
|
const entry = {
|
|
235
|
-
url
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
size : parseInt(res.headers()['content-length'] || '0'),
|
|
239
|
-
type : res.headers()['content-type'] || '',
|
|
1008
|
+
url: res.url(), status: res.status(), duration,
|
|
1009
|
+
size: parseInt(res.headers()['content-length'] || '0'),
|
|
1010
|
+
type: res.headers()['content-type'] || '',
|
|
240
1011
|
};
|
|
241
1012
|
results.networkRequests.push(entry);
|
|
242
1013
|
if (res.status() >= 400) {
|
|
@@ -247,8 +1018,7 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
247
1018
|
// ── Navigate ─────────────────────────────────────────────────────────
|
|
248
1019
|
const navStart = Date.now();
|
|
249
1020
|
const response = await page.goto(url, {
|
|
250
|
-
waitUntil: 'networkidle',
|
|
251
|
-
timeout : 30000,
|
|
1021
|
+
waitUntil: 'networkidle', timeout: 30000,
|
|
252
1022
|
}).catch(err => ({ error: err.message }));
|
|
253
1023
|
const navDuration = Date.now() - navStart;
|
|
254
1024
|
|
|
@@ -257,93 +1027,74 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
257
1027
|
return { error: response.error, results };
|
|
258
1028
|
}
|
|
259
1029
|
|
|
260
|
-
// ── Screenshot: Desktop ──────────────────────────────────────────────
|
|
261
1030
|
await fs.ensureDir(SCREENSHOT_DIR);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 ────────────────────────────────────────────────
|
|
281
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(() => {});
|
|
282
1066
|
const vitals = await page.evaluate(() => {
|
|
283
1067
|
return new Promise((resolve) => {
|
|
284
|
-
const v
|
|
1068
|
+
const v = { lcp: null, fcp: null, cls: 0, tbt: 0, ttfb: null };
|
|
285
1069
|
let clsVal = 0;
|
|
286
|
-
|
|
287
|
-
// Navigation timing (TTFB)
|
|
288
1070
|
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
289
1071
|
if (navEntry) v.ttfb = Math.round(navEntry.responseStart - navEntry.requestStart);
|
|
290
|
-
|
|
291
|
-
// FCP
|
|
292
|
-
const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
|
|
293
|
-
if (fcpEntry) v.fcp = Math.round(fcpEntry.startTime);
|
|
294
|
-
|
|
295
|
-
// Paint entries
|
|
296
1072
|
const paintEntries = performance.getEntriesByType('paint');
|
|
297
|
-
paintEntries.forEach(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
|
|
304
|
-
const entries = list.getEntries();
|
|
305
|
-
const last = entries[entries.length - 1];
|
|
306
|
-
if (last) v.lcp = Math.round(last.startTime);
|
|
307
|
-
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
308
|
-
} catch {}
|
|
309
|
-
|
|
310
|
-
// CLS Observer
|
|
311
|
-
try {
|
|
312
|
-
new PerformanceObserver((list) => {
|
|
313
|
-
for (const entry of list.getEntries()) {
|
|
314
|
-
if (!entry.hadRecentInput) clsVal += entry.value;
|
|
315
|
-
}
|
|
316
|
-
v.cls = parseFloat(clsVal.toFixed(4));
|
|
317
|
-
}).observe({ type: 'layout-shift', buffered: true });
|
|
318
|
-
} catch {}
|
|
319
|
-
|
|
320
|
-
// Long tasks (TBT estimation)
|
|
321
|
-
try {
|
|
322
|
-
new PerformanceObserver((list) => {
|
|
323
|
-
for (const entry of list.getEntries()) {
|
|
324
|
-
if (entry.duration > 50) v.tbt += Math.round(entry.duration - 50);
|
|
325
|
-
}
|
|
326
|
-
}).observe({ type: 'longtask', buffered: true });
|
|
327
|
-
} catch {}
|
|
328
|
-
|
|
329
|
-
// Wait for all observers
|
|
330
|
-
setTimeout(() => {
|
|
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; }
|
|
331
1080
|
v.cls = parseFloat(clsVal.toFixed(4));
|
|
332
|
-
|
|
333
|
-
|
|
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);
|
|
334
1086
|
});
|
|
335
1087
|
}).catch(() => ({}));
|
|
336
1088
|
|
|
337
|
-
// Merge with navigation timing
|
|
338
1089
|
const navTiming = await page.evaluate(() => {
|
|
339
1090
|
const nav = performance.getEntriesByType('navigation')[0];
|
|
340
1091
|
if (!nav) return {};
|
|
341
1092
|
return {
|
|
342
|
-
ttfb
|
|
343
|
-
domLoad
|
|
344
|
-
fullLoad
|
|
345
|
-
dnsLookup
|
|
346
|
-
tcpConnect
|
|
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),
|
|
347
1098
|
transferSize: nav.transferSize,
|
|
348
1099
|
};
|
|
349
1100
|
}).catch(() => ({}));
|
|
@@ -351,73 +1102,90 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
351
1102
|
results.vitals = { ...vitals, ...navTiming, navDuration };
|
|
352
1103
|
dash?.log(chalk.green(` ✓ Vitals: TTFB=${navTiming.ttfb||'?'}ms LCP=${vitals.lcp||'?'}ms FCP=${vitals.fcp||'?'}ms CLS=${vitals.cls??'?'}`));
|
|
353
1104
|
|
|
354
|
-
// ──
|
|
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 ────────────────────────────────────────────────────
|
|
355
1115
|
dash?.log(chalk.cyan(' 🔍 Running DOM checks...'));
|
|
1116
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
|
|
356
1117
|
const domChecks = await page.evaluate(() => {
|
|
357
1118
|
const checks = [];
|
|
358
|
-
|
|
359
|
-
// Title
|
|
360
1119
|
const title = document.title;
|
|
361
1120
|
checks.push({ name: 'Page title', pass: !!title && title.length > 0, value: title?.slice(0, 80) });
|
|
362
|
-
|
|
363
|
-
// H1
|
|
364
1121
|
const h1s = document.querySelectorAll('h1');
|
|
365
1122
|
checks.push({ name: 'Single H1', pass: h1s.length === 1, value: `${h1s.length} H1 tags` });
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const imgs = document.querySelectorAll('img');
|
|
369
|
-
const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
|
|
1123
|
+
const imgs = document.querySelectorAll('img');
|
|
1124
|
+
const noAlt = [...imgs].filter(i => !i.getAttribute('alt')).length;
|
|
370
1125
|
checks.push({ name: 'Images alt text', pass: noAlt === 0, value: `${noAlt}/${imgs.length} missing alt` });
|
|
371
|
-
|
|
372
|
-
// Buttons accessible
|
|
373
1126
|
const btns = document.querySelectorAll('button');
|
|
374
1127
|
const noText = [...btns].filter(b => !b.textContent?.trim() && !b.getAttribute('aria-label')).length;
|
|
375
1128
|
checks.push({ name: 'Buttons accessible', pass: noText === 0, value: `${noText} buttons missing label` });
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const links = document.querySelectorAll('a');
|
|
379
|
-
const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
|
|
1129
|
+
const links = document.querySelectorAll('a');
|
|
1130
|
+
const noHref = [...links].filter(l => !l.href || l.href === '#' || l.href === window.location.href + '#').length;
|
|
380
1131
|
checks.push({ name: 'Links have href', pass: noHref === 0, value: `${noHref}/${links.length} empty links` });
|
|
381
|
-
|
|
382
|
-
// Forms with submit
|
|
383
1132
|
const forms = document.querySelectorAll('form');
|
|
384
1133
|
const noSubmit = [...forms].filter(f => !f.querySelector('[type="submit"], button')).length;
|
|
385
1134
|
checks.push({ name: 'Forms have submit', pass: noSubmit === 0 || forms.length === 0, value: `${forms.length} forms` });
|
|
386
|
-
|
|
387
|
-
// Meta viewport
|
|
388
1135
|
const vp = document.querySelector('meta[name="viewport"]');
|
|
389
1136
|
checks.push({ name: 'Viewport meta', pass: !!vp, value: vp?.content || 'missing' });
|
|
390
|
-
|
|
391
|
-
// Color contrast check (heuristic)
|
|
392
|
-
const body = document.body;
|
|
393
|
-
const bodyStyle = window.getComputedStyle(body);
|
|
1137
|
+
const bodyStyle = window.getComputedStyle(document.body);
|
|
394
1138
|
checks.push({ name: 'Body has styles', pass: !!bodyStyle.backgroundColor || !!bodyStyle.color, value: 'CSS applied' });
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
});
|
|
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; } });
|
|
400
1157
|
checks.push({ name: 'Internal links count', pass: true, value: `${internalLinks.length} internal links` });
|
|
401
|
-
|
|
402
1158
|
return checks;
|
|
403
1159
|
}).catch(() => []);
|
|
404
1160
|
|
|
405
1161
|
results.domChecks = domChecks;
|
|
406
1162
|
dash?.log(chalk.green(` ✓ DOM: ${domChecks.filter(c => c.pass).length}/${domChecks.length} checks passed`));
|
|
407
1163
|
|
|
408
|
-
// ──
|
|
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 ─────────────────────────────────────────────
|
|
409
1183
|
dash?.log(chalk.cyan(' 🖱️ Testing interactions...'));
|
|
410
1184
|
const interactions = [];
|
|
411
|
-
|
|
412
|
-
// Test all clickable buttons
|
|
413
|
-
const buttonCount = await page.locator('button:visible').count().catch(() => 0);
|
|
1185
|
+
const buttonCount = await page.locator('button:visible').count().catch(() => 0);
|
|
414
1186
|
interactions.push({ name: 'Visible buttons found', pass: true, value: `${buttonCount} buttons` });
|
|
415
|
-
|
|
416
|
-
// Test form inputs exist
|
|
417
1187
|
const inputCount = await page.locator('input:visible').count().catch(() => 0);
|
|
418
1188
|
interactions.push({ name: 'Form inputs found', pass: true, value: `${inputCount} inputs` });
|
|
419
|
-
|
|
420
|
-
// Test scroll behavior
|
|
421
1189
|
try {
|
|
422
1190
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
423
1191
|
await page.waitForTimeout(300);
|
|
@@ -426,8 +1194,6 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
426
1194
|
} catch (err) {
|
|
427
1195
|
interactions.push({ name: 'Page scroll', pass: false, value: err.message });
|
|
428
1196
|
}
|
|
429
|
-
|
|
430
|
-
// Test keyboard navigation (Tab key)
|
|
431
1197
|
try {
|
|
432
1198
|
await page.keyboard.press('Tab');
|
|
433
1199
|
await page.waitForTimeout(100);
|
|
@@ -436,28 +1202,39 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
436
1202
|
} catch {
|
|
437
1203
|
interactions.push({ name: 'Keyboard navigation', pass: false, value: 'Tab focus failed' });
|
|
438
1204
|
}
|
|
439
|
-
|
|
440
|
-
// Hover test on first link
|
|
441
1205
|
try {
|
|
442
1206
|
const firstLink = page.locator('a:visible').first();
|
|
443
|
-
if (await firstLink.count() > 0) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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' }); }
|
|
450
1222
|
|
|
451
1223
|
results.interactions = interactions;
|
|
452
1224
|
dash?.log(chalk.green(` ✓ Interactions: ${interactions.filter(i => i.pass).length}/${interactions.length} passed`));
|
|
453
1225
|
|
|
454
|
-
// ──
|
|
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 ─────────────────────────────────────────────
|
|
455
1234
|
const resourceStats = await page.evaluate(() => {
|
|
456
1235
|
const entries = performance.getEntriesByType('resource');
|
|
457
1236
|
const byType = {};
|
|
458
1237
|
let totalSize = 0;
|
|
459
|
-
let totalTime = 0;
|
|
460
|
-
|
|
461
1238
|
for (const e of entries) {
|
|
462
1239
|
const t = e.initiatorType || 'other';
|
|
463
1240
|
if (!byType[t]) byType[t] = { count: 0, size: 0, time: 0, slow: [] };
|
|
@@ -465,15 +1242,18 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
465
1242
|
byType[t].size += e.transferSize || 0;
|
|
466
1243
|
byType[t].time += e.duration;
|
|
467
1244
|
totalSize += e.transferSize || 0;
|
|
468
|
-
|
|
469
|
-
if (e.duration > 500) {
|
|
470
|
-
byType[t].slow.push({ url: e.name.split('/').pop().slice(0, 60), duration: Math.round(e.duration), size: e.transferSize || 0 });
|
|
471
|
-
}
|
|
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 });
|
|
472
1246
|
}
|
|
473
|
-
return { byType, totalSize,
|
|
1247
|
+
return { byType, totalSize, count: entries.length };
|
|
474
1248
|
}).catch(() => ({}));
|
|
475
1249
|
|
|
476
|
-
results.resourceStats
|
|
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 })));
|
|
477
1257
|
|
|
478
1258
|
return { results, navDuration, error: null };
|
|
479
1259
|
|
|
@@ -490,7 +1270,7 @@ async function runPlaywrightScan(url, session, dash, options = {}) {
|
|
|
490
1270
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
491
1271
|
// Route Crawler — real HTTP crawl
|
|
492
1272
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
-
async function crawlSite(baseUrl, { maxPages =
|
|
1273
|
+
async function crawlSite(baseUrl, { maxPages = 60, onRoute } = {}) {
|
|
494
1274
|
const visited = new Set();
|
|
495
1275
|
const queue = [{ url: baseUrl, depth: 0 }];
|
|
496
1276
|
const routes = [];
|
|
@@ -501,16 +1281,17 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
501
1281
|
while (queue.length > 0 && routes.length < maxPages) {
|
|
502
1282
|
const { url, depth } = queue.shift();
|
|
503
1283
|
const n = norm(url);
|
|
504
|
-
if (!n || visited.has(n) || !sameOrigin(n) || depth >
|
|
1284
|
+
if (!n || visited.has(n) || !sameOrigin(n) || depth > 4) continue;
|
|
505
1285
|
visited.add(n);
|
|
506
1286
|
|
|
507
1287
|
const r = await httpProbe(n, { timeout: 10000 });
|
|
508
1288
|
const type = (() => {
|
|
509
|
-
if (r.status >= 400)
|
|
510
|
-
if (r.contentType.includes('json') || n.includes('/api/'))
|
|
511
|
-
if (n.endsWith('.xml') || n.endsWith('.txt'))
|
|
512
|
-
if (/\/(login|signin|auth)/i.test(n))
|
|
513
|
-
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';
|
|
514
1295
|
return 'page';
|
|
515
1296
|
})();
|
|
516
1297
|
|
|
@@ -523,7 +1304,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
523
1304
|
}
|
|
524
1305
|
}
|
|
525
1306
|
|
|
526
|
-
const forms
|
|
1307
|
+
const forms = [];
|
|
527
1308
|
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
528
1309
|
let fm;
|
|
529
1310
|
while ((fm = formRe.exec(r.body)) !== null) {
|
|
@@ -539,7 +1320,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
539
1320
|
forms.push({ action, method, fields });
|
|
540
1321
|
}
|
|
541
1322
|
|
|
542
|
-
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 };
|
|
543
1324
|
routes.push(route);
|
|
544
1325
|
if (onRoute) onRoute(route);
|
|
545
1326
|
|
|
@@ -549,8 +1330,15 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
549
1330
|
}
|
|
550
1331
|
}
|
|
551
1332
|
|
|
552
|
-
// Common paths probe
|
|
553
|
-
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
|
+
];
|
|
554
1342
|
for (const p2 of commonPaths) {
|
|
555
1343
|
try {
|
|
556
1344
|
const u = new URL(p2, baseUrl).toString();
|
|
@@ -559,7 +1347,7 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
559
1347
|
visited.add(n);
|
|
560
1348
|
const r = await httpProbe(u, { timeout: 5000 });
|
|
561
1349
|
if (r.status > 0 && r.status < 500) {
|
|
562
|
-
const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [] };
|
|
1350
|
+
const route = { id: shortId(), url: u, type: p2.includes('/api') ? 'api' : 'resource', status: r.status, depth: 0, links: [], forms: [], responseTime: r.responseTime };
|
|
563
1351
|
routes.push(route);
|
|
564
1352
|
if (onRoute) onRoute(route);
|
|
565
1353
|
}
|
|
@@ -570,36 +1358,41 @@ async function crawlSite(baseUrl, { maxPages = 50, onRoute } = {}) {
|
|
|
570
1358
|
}
|
|
571
1359
|
|
|
572
1360
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
573
|
-
// Security Scanner
|
|
1361
|
+
// Security Scanner v15 — Extended
|
|
574
1362
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
575
1363
|
async function runSecurityScan(url) {
|
|
576
1364
|
const findings = [];
|
|
577
1365
|
const r = await httpProbe(url);
|
|
578
1366
|
|
|
579
1367
|
if (!r.ok && r.status === 0) {
|
|
580
|
-
return [{
|
|
581
|
-
|
|
582
|
-
detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running',
|
|
583
|
-
}];
|
|
1368
|
+
return [{ check: 'Server reachable', pass: false, severity: 'P0', category: 'connectivity',
|
|
1369
|
+
detail: `Cannot reach ${url}: ${r.error}`, recommendation: 'Ensure server is running' }];
|
|
584
1370
|
}
|
|
585
1371
|
|
|
586
1372
|
const h = r.headers;
|
|
587
1373
|
|
|
588
1374
|
const headerChecks = [
|
|
589
|
-
{ id: 'csp', name: 'Content-Security-Policy',
|
|
1375
|
+
{ id: 'csp', name: 'Content-Security-Policy', header: 'content-security-policy', sev: 'P1',
|
|
590
1376
|
validate: v => !!v, rec: 'Add CSP header to prevent XSS' },
|
|
591
|
-
{ id: 'hsts', name: 'HSTS',
|
|
1377
|
+
{ id: 'hsts', name: 'HSTS', header: 'strict-transport-security', sev: 'P1',
|
|
592
1378
|
validate: v => !!v, rec: 'Add HSTS to enforce HTTPS' },
|
|
593
|
-
{ id: 'xframe', name: 'X-Frame-Options',
|
|
1379
|
+
{ id: 'xframe', name: 'X-Frame-Options', header: 'x-frame-options', sev: 'P1',
|
|
594
1380
|
validate: v => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()), rec: 'Set X-Frame-Options: DENY' },
|
|
595
|
-
{ id: 'xcto', name: 'X-Content-Type-Options',
|
|
1381
|
+
{ id: 'xcto', name: 'X-Content-Type-Options', header: 'x-content-type-options', sev: 'P2',
|
|
596
1382
|
validate: v => v === 'nosniff', rec: 'Set X-Content-Type-Options: nosniff' },
|
|
597
|
-
{ id: 'rp', name: 'Referrer-Policy',
|
|
1383
|
+
{ id: 'rp', name: 'Referrer-Policy', header: 'referrer-policy', sev: 'P2',
|
|
598
1384
|
validate: v => !!v, rec: 'Add Referrer-Policy header' },
|
|
599
|
-
{ 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',
|
|
600
1388
|
validate: v => !v || (!v.includes('/') && !/\d+\.\d+/.test(v)), rec: 'Genericize Server header' },
|
|
601
|
-
{ id: 'xpb', name: 'X-Powered-By hidden',
|
|
1389
|
+
{ id: 'xpb', name: 'X-Powered-By hidden', header: 'x-powered-by', sev: 'P2',
|
|
602
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' },
|
|
603
1396
|
];
|
|
604
1397
|
|
|
605
1398
|
for (const c of headerChecks) {
|
|
@@ -616,28 +1409,49 @@ async function runSecurityScan(url) {
|
|
|
616
1409
|
findings.push({
|
|
617
1410
|
check: 'HTTPS enforced', pass: isHTTPS, severity: isHTTPS ? 'INFO' : 'P1',
|
|
618
1411
|
category: 'encryption', detail: isHTTPS ? 'HTTPS in use' : 'HTTP — unencrypted',
|
|
619
|
-
recommendation: 'Use HTTPS with valid SSL',
|
|
1412
|
+
recommendation: 'Use HTTPS with valid SSL',
|
|
620
1413
|
});
|
|
621
1414
|
|
|
622
1415
|
const corsOrigin = h['access-control-allow-origin'];
|
|
623
1416
|
const corsCreds = h['access-control-allow-credentials'];
|
|
624
1417
|
const corsPass = !(corsOrigin === '*' && corsCreds === 'true');
|
|
625
1418
|
findings.push({
|
|
626
|
-
check: 'CORS wildcard + credentials', pass: corsPass,
|
|
627
|
-
severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
1419
|
+
check: 'CORS wildcard + credentials', pass: corsPass, severity: corsPass ? 'INFO' : 'P0', category: 'cors',
|
|
628
1420
|
detail: corsPass ? 'CORS config safe' : 'Wildcard CORS + credentials = critical vulnerability',
|
|
629
1421
|
recommendation: 'Never combine CORS * with allow-credentials',
|
|
630
|
-
evidence: { 'access-control-allow-origin': corsOrigin, 'access-control-allow-credentials': corsCreds },
|
|
631
1422
|
});
|
|
632
1423
|
|
|
633
|
-
|
|
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
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const base = new URL(url).origin;
|
|
634
1434
|
const sensitives = [
|
|
635
|
-
{ path: '/.env',
|
|
636
|
-
{ path: '/.
|
|
637
|
-
{ path: '/
|
|
638
|
-
{ path: '/
|
|
639
|
-
{ path: '/
|
|
640
|
-
{ 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' },
|
|
641
1455
|
];
|
|
642
1456
|
for (const s of sensitives) {
|
|
643
1457
|
try {
|
|
@@ -660,7 +1474,7 @@ async function runSecurityScan(url) {
|
|
|
660
1474
|
}
|
|
661
1475
|
|
|
662
1476
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
663
|
-
// SEO Scanner
|
|
1477
|
+
// SEO Scanner v15 — Extended
|
|
664
1478
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
665
1479
|
async function runSEOScan(url) {
|
|
666
1480
|
const t0 = Date.now();
|
|
@@ -674,60 +1488,71 @@ async function runSEOScan(url) {
|
|
|
674
1488
|
|
|
675
1489
|
const title = get(/<title[^>]*>([^<]+)<\/title>/i);
|
|
676
1490
|
checks.push({ name: 'Title tag', pass: !!title, severity: 'P1', category: 'meta',
|
|
677
|
-
detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>'
|
|
678
|
-
recommendation: 'Add unique title (50-60 chars)' });
|
|
679
|
-
|
|
1491
|
+
detail: title ? `"${title.slice(0,60)}"` : 'Missing <title>' });
|
|
680
1492
|
if (title) checks.push({ name: 'Title length', pass: title.length >= 30 && title.length <= 60,
|
|
681
|
-
severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)
|
|
682
|
-
recommendation: 'Keep title 30-60 chars' });
|
|
1493
|
+
severity: 'P2', category: 'meta', detail: `${title.length} chars (optimal 30-60)` });
|
|
683
1494
|
|
|
684
1495
|
const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
|
685
1496
|
|| get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
686
1497
|
checks.push({ name: 'Meta description', pass: !!desc, severity: 'P1', category: 'meta',
|
|
687
|
-
detail: desc ? `"${desc.slice(0,80)}"` : 'Missing meta description'
|
|
688
|
-
|
|
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)` });
|
|
689
1501
|
|
|
690
1502
|
const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
|
|
691
1503
|
checks.push({ name: 'H1 tag', pass: h1Count === 1, severity: 'P1', category: 'structure',
|
|
692
|
-
detail: h1Count === 0 ? 'No H1' : h1Count > 1 ? `${h1Count}
|
|
693
|
-
|
|
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` });
|
|
694
1509
|
|
|
695
1510
|
const hasVP = has(/<meta[^>]+name=["']viewport["']/i);
|
|
696
|
-
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile',
|
|
697
|
-
detail: hasVP ? 'Viewport found' : 'Missing viewport meta',
|
|
698
|
-
recommendation: 'Add viewport meta tag' });
|
|
1511
|
+
checks.push({ name: 'Viewport meta', pass: hasVP, severity: 'P1', category: 'mobile', detail: hasVP ? 'Viewport found' : 'Missing' });
|
|
699
1512
|
|
|
700
1513
|
const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
|
|
701
|
-
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo',
|
|
702
|
-
detail: lang ? `lang="${lang}"` : 'Missing lang attribute', recommendation: 'Add lang to <html>' });
|
|
1514
|
+
checks.push({ name: 'HTML lang', pass: !!lang, severity: 'P1', category: 'accessibility-seo', detail: lang ? `lang="${lang}"` : 'Missing' });
|
|
703
1515
|
|
|
704
1516
|
const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
|
|
705
|
-
checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo',
|
|
706
|
-
detail: canonical ? `Canonical: ${canonical}` : 'Missing canonical',
|
|
707
|
-
recommendation: 'Add <link rel="canonical">' });
|
|
1517
|
+
checks.push({ name: 'Canonical link', pass: !!canonical, severity: 'P2', category: 'technical-seo', detail: canonical ? `Canonical: ${canonical}` : 'Missing' });
|
|
708
1518
|
|
|
709
1519
|
const ogOk = has(/<meta[^>]+property=["']og:title["']/i) && has(/<meta[^>]+property=["']og:description["']/i);
|
|
710
|
-
checks.push({ name: 'Open Graph tags', pass: ogOk, severity: 'P2', category: 'social',
|
|
711
|
-
|
|
712
|
-
|
|
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' });
|
|
1526
|
+
|
|
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' });
|
|
713
1529
|
|
|
714
1530
|
const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
|
|
715
1531
|
const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
|
|
716
1532
|
checks.push({ name: 'Images alt text', pass: imgNoAlt === 0, severity: 'P2', category: 'accessibility-seo',
|
|
717
|
-
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt
|
|
718
|
-
recommendation: 'Add alt text to all images' });
|
|
1533
|
+
detail: imgNoAlt === 0 ? `All ${imgTotal} images have alt` : `${imgNoAlt}/${imgTotal} missing alt` });
|
|
719
1534
|
|
|
720
1535
|
checks.push({ name: 'Server response time', pass: rt < 800, severity: rt > 2000 ? 'P1' : 'P2',
|
|
721
|
-
category: 'performance-seo', detail: `TTFB: ${rt}ms (Google: <800ms)
|
|
722
|
-
|
|
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' });
|
|
723
1549
|
|
|
724
1550
|
const base = new URL(url).origin;
|
|
725
1551
|
for (const [file, name] of [['/robots.txt','robots.txt'],['/sitemap.xml','sitemap.xml']]) {
|
|
726
1552
|
try {
|
|
727
1553
|
const rr = await httpProbe(`${base}${file}`, { timeout: 4000 });
|
|
728
1554
|
checks.push({ name, pass: rr.ok, severity: 'P1', category: 'crawling',
|
|
729
|
-
detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}
|
|
730
|
-
recommendation: `Ensure ${name} exists` });
|
|
1555
|
+
detail: rr.ok ? `${name} accessible` : `${name} returned ${rr.status}` });
|
|
731
1556
|
} catch {
|
|
732
1557
|
checks.push({ name, pass: false, severity: 'P2', category: 'crawling', detail: `${name} unreachable` });
|
|
733
1558
|
}
|
|
@@ -737,7 +1562,7 @@ async function runSEOScan(url) {
|
|
|
737
1562
|
}
|
|
738
1563
|
|
|
739
1564
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
740
|
-
// Accessibility Scanner —
|
|
1565
|
+
// Accessibility Scanner v15 — Extended
|
|
741
1566
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
742
1567
|
async function runA11yScan(url) {
|
|
743
1568
|
const r = await httpProbe(url, { timeout: 12000 });
|
|
@@ -745,20 +1570,28 @@ async function runA11yScan(url) {
|
|
|
745
1570
|
const violations = [], passes = [];
|
|
746
1571
|
|
|
747
1572
|
const checks = [
|
|
748
|
-
{ id: 'html-lang', impact: 'serious', test: () => !/<html[^>]+lang=["'][^"']+["']/i.test(html),
|
|
749
|
-
{ id: 'img-alt', impact: 'critical', test: () => /<img(?![^>]*\balt=)[^>]*>/i.test(html),
|
|
750
|
-
{ id: 'document-title', impact: 'serious', test: () => !/<title[^>]*>[^<]+<\/title>/i.test(html),
|
|
751
|
-
{ id: 'viewport', impact: 'critical', test: () => /user-scalable=no|maximum-scale=1/i.test(html),
|
|
752
|
-
{ id: 'main-landmark', impact: 'moderate', test: () => !/<main[^>]*>/i.test(html),
|
|
753
|
-
{ id: 'h1-present', impact: 'moderate', test: () => !/<h1[^>]*>/i.test(html),
|
|
754
|
-
{ 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' },
|
|
755
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' },
|
|
756
1589
|
];
|
|
757
1590
|
|
|
758
1591
|
for (const c of checks) {
|
|
759
1592
|
if (c.test()) {
|
|
760
1593
|
violations.push({ id: c.id, description: c.desc, help: c.desc, impact: c.impact,
|
|
761
|
-
tags: ['wcag2a'], category: 'wcag2a', nodes: 1,
|
|
1594
|
+
tags: ['wcag2a'], category: 'wcag2a', nodes: 1,
|
|
762
1595
|
helpUrl: `https://dequeuniversity.com/rules/axe/4.9/${c.id}` });
|
|
763
1596
|
} else {
|
|
764
1597
|
passes.push({ id: c.id, description: c.pass, nodes: 1 });
|
|
@@ -766,56 +1599,60 @@ async function runA11yScan(url) {
|
|
|
766
1599
|
}
|
|
767
1600
|
|
|
768
1601
|
const score = passes.length > 0 ? Math.round(passes.length / (passes.length + violations.length) * 100) : 0;
|
|
769
|
-
return { pass: violations.length === 0, violations, passes, incomplete: [], score, url
|
|
1602
|
+
return { pass: violations.length === 0, violations, passes, incomplete: [], score, url };
|
|
770
1603
|
}
|
|
771
1604
|
|
|
772
1605
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
773
|
-
// AI Bug Classifier
|
|
1606
|
+
// AI Bug Classifier v15 — Enhanced patterns
|
|
774
1607
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
775
1608
|
const SEV_PATTERNS = {
|
|
776
|
-
P0: [/security|auth.*bypass|sql.inject|xss|rce|exposed.*secret|password.*leak|critical/i, /crash|fatal|500|server.*down|data.*loss/i],
|
|
777
|
-
P1: [/login.*fail|auth.*error|jwt|token.*invalid|api.*timeout|cors.*error/i, /lcp|performance.*poor|wcag.*critical|a11y.*serious/i],
|
|
778
|
-
P2: [/console.*error|js.*error|network.*fail|404|missing.*meta|seo.*issue/i],
|
|
779
|
-
P3: [/warning|minor|style|typo|cosmetic/i],
|
|
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],
|
|
780
1613
|
};
|
|
781
1614
|
const CAT_PATTERNS = {
|
|
782
|
-
security
|
|
783
|
-
performance
|
|
784
|
-
accessibility: /wcag|a11y|aria|alt.*text|contrast|keyboard/i,
|
|
785
|
-
seo
|
|
786
|
-
api
|
|
787
|
-
javascript
|
|
788
|
-
network
|
|
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,
|
|
789
1624
|
};
|
|
790
1625
|
function classifyBug(bug) {
|
|
791
1626
|
const text = `${bug.title} ${bug.description || ''}`;
|
|
792
1627
|
let severity = bug.severity || 'P3', confidence = 0.7;
|
|
793
1628
|
for (const [sev, pats] of Object.entries(SEV_PATTERNS)) {
|
|
794
|
-
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; }
|
|
795
1630
|
}
|
|
796
1631
|
let category = bug.type || 'general';
|
|
797
1632
|
for (const [cat, pat] of Object.entries(CAT_PATTERNS)) {
|
|
798
1633
|
if (pat.test(text)) { category = cat; break; }
|
|
799
1634
|
}
|
|
800
1635
|
const recs = {
|
|
801
|
-
security
|
|
802
|
-
performance
|
|
803
|
-
accessibility: 'Fix WCAG 2.1 AA violations with
|
|
804
|
-
seo
|
|
805
|
-
api
|
|
806
|
-
javascript
|
|
807
|
-
network
|
|
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',
|
|
808
1645
|
};
|
|
809
|
-
return { severity, category, recommendation: recs[category] || 'Review error details', confidence };
|
|
1646
|
+
return { severity, category, recommendation: recs[category] || 'Review and fix error details', confidence };
|
|
810
1647
|
}
|
|
811
1648
|
|
|
812
1649
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
813
|
-
// Terminal Dashboard
|
|
1650
|
+
// Terminal Dashboard v15 — Enhanced live display
|
|
814
1651
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
815
1652
|
class TerminalDashboard {
|
|
816
1653
|
#session; #lines = 0; #active = false; #timer = null;
|
|
817
1654
|
#phase = 'Initializing...'; #currentTest = ''; #log = []; #startTime = Date.now();
|
|
818
|
-
#pwMode = false;
|
|
1655
|
+
#pwMode = false; #subPhase = '';
|
|
819
1656
|
|
|
820
1657
|
constructor(s) { this.#session = s; this.#pwMode = s.playwrightMode; }
|
|
821
1658
|
|
|
@@ -823,7 +1660,7 @@ class TerminalDashboard {
|
|
|
823
1660
|
this.#active = true; this.#startTime = Date.now();
|
|
824
1661
|
process.stdout.write('\x1b[?25l');
|
|
825
1662
|
this.#render();
|
|
826
|
-
this.#timer = setInterval(() => this.#render(),
|
|
1663
|
+
this.#timer = setInterval(() => this.#render(), 400);
|
|
827
1664
|
}
|
|
828
1665
|
|
|
829
1666
|
stop() {
|
|
@@ -834,12 +1671,13 @@ class TerminalDashboard {
|
|
|
834
1671
|
this.#printFinal();
|
|
835
1672
|
}
|
|
836
1673
|
|
|
837
|
-
setPhase(p) { this.#phase = p; this.log(chalk.cyan(p)); }
|
|
1674
|
+
setPhase(p) { this.#phase = p; this.#subPhase = ''; this.log(chalk.cyan(p)); }
|
|
1675
|
+
setSubPhase(p) { this.#subPhase = p; }
|
|
838
1676
|
setCurrentTest(t) { this.#currentTest = t; }
|
|
839
1677
|
addResult() { this.#currentTest = ''; }
|
|
840
1678
|
log(msg) {
|
|
841
1679
|
this.#log.push(`${chalk.gray(new Date().toLocaleTimeString())} ${msg}`);
|
|
842
|
-
if (this.#log.length >
|
|
1680
|
+
if (this.#log.length > 10) this.#log.shift();
|
|
843
1681
|
}
|
|
844
1682
|
|
|
845
1683
|
#render() {
|
|
@@ -867,48 +1705,53 @@ class TerminalDashboard {
|
|
|
867
1705
|
const total = s.results.length;
|
|
868
1706
|
const rate = total > 0 ? Math.round(passed / total * 100) : 0;
|
|
869
1707
|
const heapMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
|
|
870
|
-
const w = Math.min(process.stdout.columns ||
|
|
1708
|
+
const w = Math.min(process.stdout.columns || 90, 92);
|
|
871
1709
|
const bar = '─'.repeat(w - 2);
|
|
872
1710
|
const c1 = chalk.hex('#00F5FF');
|
|
873
1711
|
const c2 = chalk.hex('#BF40FF');
|
|
874
1712
|
const pad = (s, n = w - 2) => String(s).slice(0, n).padEnd(n);
|
|
875
|
-
const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭
|
|
1713
|
+
const pwTag = this.#pwMode ? chalk.hex('#BF40FF')(' 🎭 BACKLIST') : chalk.gray(' HTTP');
|
|
1714
|
+
|
|
1715
|
+
const spin = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'][Math.floor(Date.now() / 100) % 10];
|
|
876
1716
|
|
|
877
1717
|
const pBar = (() => {
|
|
878
|
-
const f = Math.min(Math.round(rate / 100 *
|
|
1718
|
+
const f = Math.min(Math.round(rate / 100 * 30), 30);
|
|
879
1719
|
const col = rate >= 90 ? chalk.green : rate >= 70 ? chalk.yellow : chalk.red;
|
|
880
|
-
return col('█'.repeat(f)) + chalk.gray('░'.repeat(
|
|
1720
|
+
return col('█'.repeat(f)) + chalk.gray('░'.repeat(30 - f));
|
|
881
1721
|
})();
|
|
882
1722
|
|
|
883
1723
|
const out = [
|
|
884
1724
|
c1(`┌${bar}┐`),
|
|
885
|
-
c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} —
|
|
1725
|
+
c1('│') + c2.bold(pad(` ⚡ BACKLIST QA v${VERSION} — ULTRA LIVE TESTING EDITION${pwTag}`)) + c1('│'),
|
|
886
1726
|
c1(`├${bar}┤`),
|
|
887
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('│'),
|
|
888
1731
|
c1(`├${bar}┤`),
|
|
889
|
-
c1('│') + pad(` ${chalk.green('✓')} ${chalk.bold(passed)} passed ${chalk.red('✗')} ${chalk.bold(failed)} failed ${chalk.cyan('🐛')} ${chalk.bold(s.bugs.length)} bugs ${chalk.gray('⏱')} ${chalk.white(elapsed + 's')} ${chalk.gray('Heap')} ${chalk.white(heapMB + 'MB')}`) + c1('│'),
|
|
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('│'),
|
|
890
1733
|
c1('│') + pad(` [${pBar}] ${chalk.bold(rate + '%')} (${total} tests)`) + c1('│'),
|
|
891
1734
|
c1(`├${bar}┤`),
|
|
892
|
-
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('│'),
|
|
893
1736
|
c1(`├${bar}┤`),
|
|
894
|
-
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('│'),
|
|
895
1738
|
c1(`├${bar}┤`),
|
|
896
1739
|
];
|
|
897
1740
|
|
|
898
|
-
const recent = s.results.slice(-
|
|
1741
|
+
const recent = s.results.slice(-6);
|
|
899
1742
|
for (const r of recent) {
|
|
900
1743
|
const icon = r.status === 'PASS' ? chalk.green('✓') : r.status === 'FAIL' ? chalk.red('✗') : chalk.yellow('⚠');
|
|
901
|
-
out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(
|
|
1744
|
+
out.push(c1('│') + pad(` ${icon} ${chalk.gray('[' + (r.type||'').padEnd(14) + ']')} ${chalk.white((r.name||'').slice(0, w - 32))}`) + c1('│'));
|
|
902
1745
|
}
|
|
903
|
-
for (let i = recent.length; i <
|
|
1746
|
+
for (let i = recent.length; i < 6; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
904
1747
|
|
|
905
1748
|
out.push(c1(`├${bar}┤`));
|
|
906
|
-
for (const entry of this.#log.slice(-
|
|
1749
|
+
for (const entry of this.#log.slice(-5)) {
|
|
907
1750
|
out.push(c1('│') + (' ' + entry).slice(0, w - 2).padEnd(w - 2) + c1('│'));
|
|
908
1751
|
}
|
|
909
|
-
for (let i = this.#log.length; i <
|
|
1752
|
+
for (let i = this.#log.length; i < 5; i++) out.push(c1('│') + pad('') + c1('│'));
|
|
910
1753
|
out.push(c1(`└${bar}┘`));
|
|
911
|
-
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`));
|
|
912
1755
|
|
|
913
1756
|
return out;
|
|
914
1757
|
}
|
|
@@ -917,21 +1760,24 @@ class TerminalDashboard {
|
|
|
917
1760
|
const s = this.#session.getSummary();
|
|
918
1761
|
const col = Number(s.passRate) >= 90 ? chalk.green : Number(s.passRate) >= 70 ? chalk.yellow : chalk.red;
|
|
919
1762
|
console.log('');
|
|
920
|
-
console.log(chalk.hex('#00F5FF').bold(' ── QA Complete
|
|
1763
|
+
console.log(chalk.hex('#00F5FF').bold(' ── QA Complete (v15) ───────────────────────────────────────'));
|
|
921
1764
|
console.log(` Tests: ${chalk.white.bold(s.total)}`);
|
|
922
1765
|
console.log(` Passed: ${chalk.green.bold(s.passed)}`);
|
|
923
1766
|
console.log(` Failed: ${chalk.red.bold(s.failed)}`);
|
|
924
1767
|
console.log(` Pass rate: ${col.bold(s.passRate + '%')}`);
|
|
925
1768
|
console.log(` Bugs found: ${chalk.cyan.bold(this.#session.bugs.length)}`);
|
|
926
|
-
console.log(` Screenshots: ${chalk.
|
|
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')}`);
|
|
927
1773
|
console.log(` Duration: ${chalk.white(formatDuration(s.duration))}`);
|
|
928
|
-
console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭
|
|
1774
|
+
console.log(` Mode: ${this.#pwMode ? chalk.hex('#BF40FF').bold('🎭 Backlist (Real Browser)') : chalk.gray('HTTP-only')}`);
|
|
929
1775
|
console.log('');
|
|
930
1776
|
}
|
|
931
1777
|
}
|
|
932
1778
|
|
|
933
1779
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
934
|
-
// HTML Report Builder —
|
|
1780
|
+
// HTML Report Builder v15 — Ultra Rich
|
|
935
1781
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
936
1782
|
function buildHTMLReport(session) {
|
|
937
1783
|
const summary = session.getSummary();
|
|
@@ -954,25 +1800,33 @@ function buildHTMLReport(session) {
|
|
|
954
1800
|
const esc = (s) => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
955
1801
|
|
|
956
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
|
+
|
|
957
1810
|
const screenshotCards = session.screenshots.length
|
|
958
1811
|
? session.screenshots.map(sc => {
|
|
959
|
-
// Embed screenshot as base64 if possible, else show path
|
|
960
1812
|
let imgTag = '';
|
|
961
1813
|
try {
|
|
962
1814
|
const data = fs.readFileSync(sc.path);
|
|
963
|
-
|
|
964
|
-
imgTag = `<img src="data:image/png;base64,${b64}" alt="${esc(sc.type)} screenshot" loading="lazy">`;
|
|
1815
|
+
imgTag = `<img src="data:image/png;base64,${data.toString('base64')}" alt="${esc(sc.type)}" loading="lazy">`;
|
|
965
1816
|
} catch {
|
|
966
|
-
imgTag = `<div class="no-img"
|
|
1817
|
+
imgTag = `<div class="no-img">📸 ${esc(sc.name)}</div>`;
|
|
967
1818
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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>
|
|
973
1827
|
</div>
|
|
974
1828
|
<div class="sc-img-wrap">${imgTag}</div>
|
|
975
|
-
<div class="sc-path">${esc(sc.
|
|
1829
|
+
<div class="sc-path">${esc(sc.name)}</div>
|
|
976
1830
|
</div>`;
|
|
977
1831
|
}).join('')
|
|
978
1832
|
: '<p class="no-data">No screenshots (Playwright not available)</p>';
|
|
@@ -995,7 +1849,7 @@ function buildHTMLReport(session) {
|
|
|
995
1849
|
<div class="bug-header">
|
|
996
1850
|
<span class="bug-id">${esc(b.id)}</span>
|
|
997
1851
|
<span class="sev sev-${(b.aiSeverity||b.severity||'p3').toLowerCase()}">${b.aiSeverity||b.severity}</span>
|
|
998
|
-
<span class="badge">${b.type||'general'}</span>
|
|
1852
|
+
<span class="badge">${b.aiCategory||b.type||'general'}</span>
|
|
999
1853
|
${b.aiConfidence ? `<span class="ai-badge">🤖 ${Math.round((b.aiConfidence||0)*100)}%</span>` : ''}
|
|
1000
1854
|
</div>
|
|
1001
1855
|
<div class="bug-title">${esc(b.title)}</div>
|
|
@@ -1011,6 +1865,7 @@ function buildHTMLReport(session) {
|
|
|
1011
1865
|
<td><code class="url">${esc(r.url)}</code></td>
|
|
1012
1866
|
<td><span class="badge">${r.type}</span></td>
|
|
1013
1867
|
<td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
|
|
1868
|
+
<td>${r.responseTime ? `${r.responseTime}ms` : '–'}</td>
|
|
1014
1869
|
<td>${r.forms?.length || 0}</td>
|
|
1015
1870
|
<td>${r.error ? `<span class="fail">${esc(r.error)}</span>` : '✓'}</td>
|
|
1016
1871
|
</tr>`).join('');
|
|
@@ -1051,14 +1906,10 @@ function buildHTMLReport(session) {
|
|
|
1051
1906
|
<span>Score: <strong>${r.score??'–'}%</strong></span>
|
|
1052
1907
|
<span class="${r.pass?'pass':'fail'}">${r.violations?.length||0} violations</span>
|
|
1053
1908
|
</div>
|
|
1054
|
-
${(r.violations||[]).map(v =>
|
|
1055
|
-
<div class="violation impact
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
<strong>${esc(v.description)}</strong>
|
|
1059
|
-
</div>
|
|
1060
|
-
<p>${esc(v.help)}</p>
|
|
1061
|
-
</div>`).join('') || '<p class="no-data">No violations ✓</p>'}
|
|
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>'}
|
|
1062
1913
|
</div>`).join('') || '<p class="no-data">No accessibility scans</p>';
|
|
1063
1914
|
|
|
1064
1915
|
// ── Performance section ───────────────────────────────────────────────────
|
|
@@ -1076,46 +1927,29 @@ function buildHTMLReport(session) {
|
|
|
1076
1927
|
};
|
|
1077
1928
|
|
|
1078
1929
|
const perfSection = Object.entries(session.perfMetrics).map(([label, m]) => {
|
|
1079
|
-
const slowResHtml = (m.slowResources||[]).length ? `
|
|
1080
|
-
<h4 style="color:#f87171;margin-top:1rem">Slow Resources</h4>
|
|
1081
|
-
<table><thead><tr><th>URL</th><th>Time</th><th>Size</th></tr></thead>
|
|
1082
|
-
<tbody>${m.slowResources.map(r => `<tr>
|
|
1083
|
-
<td class="url">${esc((r.url||'').split('/').pop())}</td>
|
|
1084
|
-
<td class="fail">${r.duration}ms</td>
|
|
1085
|
-
<td>${formatBytes(r.size)}</td>
|
|
1086
|
-
</tr>`).join('')}</tbody></table>` : '';
|
|
1087
|
-
|
|
1088
|
-
const resourceTableHtml = m.resourceStats?.byType ? `
|
|
1089
|
-
<h4 style="color:#94a3b8;margin-top:1.5rem">Resource Breakdown</h4>
|
|
1090
|
-
<table><thead><tr><th>Type</th><th>Count</th><th>Total Size</th><th>Total Time</th></tr></thead>
|
|
1091
|
-
<tbody>${Object.entries(m.resourceStats.byType).map(([t, d]) => `<tr>
|
|
1092
|
-
<td><span class="badge">${esc(t)}</span></td>
|
|
1093
|
-
<td>${d.count}</td>
|
|
1094
|
-
<td>${formatBytes(d.size)}</td>
|
|
1095
|
-
<td>${Math.round(d.time)}ms</td>
|
|
1096
|
-
</tr>`).join('')}</tbody></table>` : '';
|
|
1097
|
-
|
|
1098
1930
|
const domChecksHtml = m.domChecks?.length ? `
|
|
1099
1931
|
<h4 style="color:#94a3b8;margin-top:1.5rem">DOM Checks</h4>
|
|
1100
1932
|
<table><thead><tr><th>Check</th><th>Status</th><th>Value</th></tr></thead>
|
|
1101
|
-
<tbody>${m.domChecks.map(c => `<tr>
|
|
1102
|
-
<td>${esc(c.name)}</td>
|
|
1933
|
+
<tbody>${m.domChecks.map(c => `<tr><td>${esc(c.name)}</td>
|
|
1103
1934
|
<td><span class="status ${c.pass?'status-pass':'status-fail'}">${c.pass?'PASS':'FAIL'}</span></td>
|
|
1104
|
-
<td>${esc(c.value||'')}</td
|
|
1105
|
-
</tr>`).join('')}</tbody></table>` : '';
|
|
1935
|
+
<td>${esc(c.value||'')}</td></tr>`).join('')}</tbody></table>` : '';
|
|
1106
1936
|
|
|
1107
1937
|
const interactionsHtml = m.interactions?.length ? `
|
|
1108
1938
|
<h4 style="color:#94a3b8;margin-top:1.5rem">Interaction Tests</h4>
|
|
1109
1939
|
<table><thead><tr><th>Test</th><th>Status</th><th>Value</th></tr></thead>
|
|
1110
|
-
<tbody>${m.interactions.map(i => `<tr>
|
|
1111
|
-
<td>${esc(i.name)}</td>
|
|
1940
|
+
<tbody>${m.interactions.map(i => `<tr><td>${esc(i.name)}</td>
|
|
1112
1941
|
<td><span class="status ${i.pass?'status-pass':'status-fail'}">${i.pass?'PASS':'FAIL'}</span></td>
|
|
1113
|
-
<td>${esc(i.value||'')}</td
|
|
1114
|
-
|
|
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>` : '';
|
|
1115
1950
|
|
|
1116
|
-
return
|
|
1117
|
-
|
|
1118
|
-
<h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Playwright</span>' : ''}</h3>
|
|
1951
|
+
return `<div class="perf-card">
|
|
1952
|
+
<h3>${esc(label)} ${m.playwrightMode ? '<span class="pw-badge">🎭 Real Vitals</span>' : ''}</h3>
|
|
1119
1953
|
<div class="vitals-grid">
|
|
1120
1954
|
${vitalCard('TTFB', m.ttfb, 800, 'ms')}
|
|
1121
1955
|
${vitalCard('LCP', m.lcp, 2500, 'ms')}
|
|
@@ -1124,15 +1958,195 @@ function buildHTMLReport(session) {
|
|
|
1124
1958
|
${vitalCard('TBT', m.tbt, 200, 'ms')}
|
|
1125
1959
|
${vitalCard('DOM Load', m.domLoad, 3000, 'ms')}
|
|
1126
1960
|
${vitalCard('DNS', m.dnsLookup, 100, 'ms')}
|
|
1961
|
+
${vitalCard('TCP', m.tcpConnect, 200, 'ms')}
|
|
1127
1962
|
</div>
|
|
1128
1963
|
${m.note ? `<p class="perf-note">ℹ️ ${esc(m.note)}</p>` : ''}
|
|
1129
|
-
${
|
|
1130
|
-
${resourceTableHtml}
|
|
1131
|
-
${domChecksHtml}
|
|
1132
|
-
${interactionsHtml}
|
|
1964
|
+
${resourceHtml}${domChecksHtml}${interactionsHtml}
|
|
1133
1965
|
</div>`;
|
|
1134
1966
|
}).join('') || '<p class="no-data">No performance data</p>';
|
|
1135
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
|
+
|
|
1136
2150
|
// ── Console errors table ──────────────────────────────────────────────────
|
|
1137
2151
|
const consoleSection = session.consoleErrors.length
|
|
1138
2152
|
? `<table>
|
|
@@ -1157,23 +2171,37 @@ function buildHTMLReport(session) {
|
|
|
1157
2171
|
</table>`
|
|
1158
2172
|
: '<p class="no-data">No network failures 🎉</p>';
|
|
1159
2173
|
|
|
1160
|
-
|
|
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>';
|
|
2184
|
+
|
|
2185
|
+
const urlsStr = Object.entries(session.urls).filter(([,v])=>v)
|
|
1161
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('');
|
|
1162
2187
|
|
|
1163
2188
|
const chartTypes = JSON.stringify(Object.keys(coverage));
|
|
1164
2189
|
const chartPass2 = JSON.stringify(Object.values(coverage).map(c=>c.pass));
|
|
1165
2190
|
const chartFail2 = JSON.stringify(Object.values(coverage).map(c=>c.fail));
|
|
1166
2191
|
const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
|
|
1167
|
-
const pwBadge
|
|
1168
|
-
? '<span style="background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44;padding:3px
|
|
1169
|
-
: '<span style="background:#1e293b;color:#64748b;padding:3px
|
|
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);
|
|
1170
2198
|
|
|
1171
2199
|
return `<!DOCTYPE html>
|
|
1172
2200
|
<html lang="en">
|
|
1173
2201
|
<head>
|
|
1174
2202
|
<meta charset="UTF-8">
|
|
1175
2203
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1176
|
-
<title>Backlist QA Report — ${esc(session.id)}</title>
|
|
2204
|
+
<title>Backlist QA v15 Report — ${esc(session.id)}</title>
|
|
1177
2205
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
|
1178
2206
|
<style>
|
|
1179
2207
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap');
|
|
@@ -1182,96 +2210,106 @@ function buildHTMLReport(session) {
|
|
|
1182
2210
|
body{font-family:'Syne',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh}
|
|
1183
2211
|
a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
|
|
1184
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)}
|
|
1185
|
-
.logo{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,var(--cyan),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent
|
|
1186
|
-
.header-meta{font-family:'JetBrains Mono',monospace;font-size:.
|
|
1187
|
-
nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0
|
|
1188
|
-
|
|
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}
|
|
1189
2218
|
.nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
|
|
1190
2219
|
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
|
1191
2220
|
.tab-panel{display:none}.tab-panel.active{display:block}
|
|
1192
|
-
.pw-banner{background:rgba(191,64,255,.08);border:1px solid #bf40ff44;border-radius:8px;padding:.75rem 1rem;margin-bottom:
|
|
1193
|
-
.real-banner{background:rgba(0,245,255,.06);border:1px solid #00f5ff33;border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem;font-size:.
|
|
1194
|
-
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(
|
|
1195
|
-
.mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding
|
|
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}
|
|
1196
2225
|
.mc:hover{border-color:var(--cyan);transform:translateY(-2px)}
|
|
1197
|
-
.ml{font-size:.
|
|
1198
|
-
.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}
|
|
1199
2228
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
|
|
1200
2229
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
1201
|
-
.card-title{font-size:.
|
|
1202
|
-
.chart-wrap{position:relative;height:
|
|
1203
|
-
.search-bar{display:flex;gap:.75rem;margin-bottom:1.25rem}
|
|
1204
|
-
.search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.
|
|
1205
|
-
table{width:100%;border-collapse:collapse;font-size:.
|
|
1206
|
-
th{text-align:left;color:var(--dim);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);font-size:.
|
|
1207
|
-
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}
|
|
1208
2237
|
tr.fail-row td{background:rgba(239,68,68,.04)}
|
|
1209
2238
|
.pass{color:var(--green)}.fail{color:var(--red)}
|
|
1210
|
-
.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}
|
|
1211
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}
|
|
1212
|
-
.sev{padding:2px 7px;border-radius:3px;font-size:.
|
|
1213
|
-
.sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
|
|
1214
|
-
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.
|
|
1215
|
-
.pw-badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:.
|
|
1216
|
-
.url{font-family:'JetBrains Mono',monospace;font-size:.
|
|
1217
|
-
code{font-family:'JetBrains Mono',monospace;font-size:.
|
|
1218
|
-
pre{white-space:pre-wrap;word-break:break-all;font-size:.
|
|
1219
|
-
details summary{cursor:pointer;color:var(--cyan);font-size:.
|
|
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}
|
|
1220
2249
|
.bug-card{border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem;background:var(--surface);border-left:3px solid var(--border);transition:.2s}
|
|
1221
2250
|
.bug-card:hover{border-left-color:var(--cyan)}
|
|
1222
2251
|
.sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
|
|
1223
2252
|
.sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
|
|
1224
2253
|
.sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
|
|
1225
2254
|
.bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
|
|
1226
|
-
.bug-id{font-family:'JetBrains Mono',monospace;font-size:.
|
|
2255
|
+
.bug-id{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--dim)}
|
|
1227
2256
|
.bug-title{font-weight:700;margin-bottom:.3rem}
|
|
1228
|
-
.bug-url{font-size:.
|
|
1229
|
-
.bug-rec{font-size:.
|
|
1230
|
-
.ai-badge{font-size:.
|
|
1231
|
-
.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}
|
|
1232
2261
|
.no-data{color:var(--dim);font-style:italic;padding:1.5rem 0;text-align:center}
|
|
1233
2262
|
.url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#0f0f1e;border-radius:6px;margin-bottom:.5rem}
|
|
1234
|
-
.url-label{font-size:.
|
|
1235
|
-
|
|
1236
|
-
.screenshot-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:1.25rem;margin-top:1rem}
|
|
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}
|
|
1237
2265
|
.screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:.2s}
|
|
1238
|
-
.screenshot-card:hover{border-color:var(--purple);transform:translateY(-
|
|
1239
|
-
.sc-header{display:flex;justify-content:space-between;align-items:center;padding:.
|
|
1240
|
-
.sc-type{font-size:.
|
|
1241
|
-
.sc-url{font-size:.
|
|
1242
|
-
.sc-img-wrap{background:#000;min-height:
|
|
1243
|
-
.sc-img-wrap img{width:100%;height:auto;display:block;max-height:
|
|
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}
|
|
1244
2272
|
.no-img{color:var(--dim);font-style:italic;padding:2rem;text-align:center}
|
|
1245
|
-
.sc-path{font-family:'JetBrains Mono',monospace;font-size:.
|
|
1246
|
-
|
|
1247
|
-
.
|
|
1248
|
-
.vital-
|
|
1249
|
-
.vital-
|
|
1250
|
-
.vital-
|
|
1251
|
-
.vital-threshold{font-size:.68rem;color:var(--dim);margin-top:2px}
|
|
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}
|
|
1252
2279
|
.vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
|
|
1253
2280
|
.vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
|
|
1254
2281
|
.vital-na{background:var(--surface)}
|
|
1255
2282
|
.perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
1256
2283
|
.perf-card h3{color:var(--cyan);margin-bottom:.5rem}
|
|
1257
|
-
.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}
|
|
1258
2288
|
.seo-page,.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
|
|
1259
|
-
.seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.
|
|
2289
|
+
.seo-header,.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem;font-size:.83rem}
|
|
1260
2290
|
.violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
|
|
1261
2291
|
.impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
|
|
1262
2292
|
.impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
|
|
1263
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)}
|
|
1264
2295
|
.violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.25rem}
|
|
1265
|
-
.impact-badge{font-size:.
|
|
1266
|
-
.err-cell details{font-size:.
|
|
1267
|
-
|
|
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}
|
|
1268
2306
|
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}.screenshot-gallery{grid-template-columns:1fr}}
|
|
1269
2307
|
</style>
|
|
1270
2308
|
</head>
|
|
1271
2309
|
<body>
|
|
1272
2310
|
<header>
|
|
1273
2311
|
<div>
|
|
1274
|
-
<div class="logo">⚡ Backlist Enterprise QA</div>
|
|
2312
|
+
<div class="logo">⚡ Backlist Enterprise QA v15</div>
|
|
1275
2313
|
<div class="header-meta">
|
|
1276
2314
|
Run: ${esc(session.id)} · ${new Date(session.startedAt).toLocaleString()} · ${formatDuration(summary.duration)} · v${VERSION}
|
|
1277
2315
|
</div>
|
|
@@ -1282,21 +2320,34 @@ footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-to
|
|
|
1282
2320
|
<nav>
|
|
1283
2321
|
<button class="nav-tab active" onclick="showTab('overview',this)">📊 Overview</button>
|
|
1284
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>
|
|
1285
2324
|
<button class="nav-tab" onclick="showTab('tests',this)">🧪 Tests (${summary.total})</button>
|
|
1286
2325
|
<button class="nav-tab" onclick="showTab('bugs',this)">🐛 Bugs (${session.bugs.length})</button>
|
|
1287
2326
|
<button class="nav-tab" onclick="showTab('routes',this)">🗺️ Routes (${session.routeMap.length})</button>
|
|
1288
2327
|
<button class="nav-tab" onclick="showTab('security',this)">🛡️ Security (${session.secFindings.length})</button>
|
|
1289
2328
|
<button class="nav-tab" onclick="showTab('performance',this)">⚡ Performance</button>
|
|
2329
|
+
<button class="nav-tab" onclick="showTab('loadtest',this)">🔥 Load Test</button>
|
|
1290
2330
|
<button class="nav-tab" onclick="showTab('a11y',this)">♿ A11y</button>
|
|
1291
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>
|
|
1292
2343
|
<button class="nav-tab" onclick="showTab('console',this)">🖥️ Console (${session.consoleErrors.length})</button>
|
|
1293
2344
|
<button class="nav-tab" onclick="showTab('network',this)">📡 Network</button>
|
|
1294
2345
|
</nav>
|
|
1295
2346
|
|
|
1296
2347
|
<div class="container">
|
|
1297
2348
|
|
|
1298
|
-
${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>
|
|
1299
|
-
<div class="real-banner">✅ <strong>100% Real Runtime Data</strong> — All results from actual HTTP requests and live
|
|
2349
|
+
${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>BACKLIST 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>
|
|
1300
2351
|
|
|
1301
2352
|
<!-- OVERVIEW -->
|
|
1302
2353
|
<div id="tab-overview" class="tab-panel active">
|
|
@@ -1310,10 +2361,14 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1310
2361
|
<div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
|
|
1311
2362
|
<div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
|
|
1312
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>
|
|
1313
2365
|
<div class="mc"><div class="ml">Routes Found</div><div class="mv">${session.routeMap.length}</div></div>
|
|
2366
|
+
<div class="mc"><div class="ml">Broken Links</div><div class="mv" style="color:${brokenCount > 0 ? 'var(--red)' : 'var(--green)'}">${brokenCount}</div></div>
|
|
1314
2367
|
<div class="mc"><div class="ml">Sec Checks</div><div class="mv">${session.secFindings.length}</div></div>
|
|
1315
|
-
<div class="mc"><div class="ml">
|
|
1316
|
-
<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>
|
|
1317
2372
|
</div>
|
|
1318
2373
|
<div class="grid2">
|
|
1319
2374
|
<div class="card"><div class="card-title">Tests by Category</div><div class="chart-wrap"><canvas id="coverageChart"></canvas></div></div>
|
|
@@ -1324,12 +2379,19 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1324
2379
|
<!-- SCREENSHOTS -->
|
|
1325
2380
|
<div id="tab-screenshots" class="tab-panel">
|
|
1326
2381
|
<div class="card">
|
|
1327
|
-
<div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured</span></div>
|
|
1328
|
-
${session.playwrightMode ? '' : '<div class="perf-note" style="margin-bottom:1rem">⚠️ Screenshots require Playwright. Install: <code>npm install playwright && npx playwright install chromium</code></div>'}
|
|
2382
|
+
<div class="card-title">Browser Screenshots <span>${session.screenshots.length} captured (${vpCount} viewports + dark mode)</span></div>
|
|
1329
2383
|
<div class="screenshot-gallery">${screenshotCards}</div>
|
|
1330
2384
|
</div>
|
|
1331
2385
|
</div>
|
|
1332
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
|
+
|
|
1333
2395
|
<!-- TESTS -->
|
|
1334
2396
|
<div id="tab-tests" class="tab-panel">
|
|
1335
2397
|
<div class="search-bar">
|
|
@@ -1362,6 +2424,10 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1362
2424
|
<option value="P0">P0 Critical</option><option value="P1">P1 High</option>
|
|
1363
2425
|
<option value="P2">P2 Medium</option><option value="P3">P3 Low</option>
|
|
1364
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>
|
|
1365
2431
|
</div>
|
|
1366
2432
|
<div id="bugList">${bugCards}</div>
|
|
1367
2433
|
</div>
|
|
@@ -1371,8 +2437,8 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1371
2437
|
<div class="card">
|
|
1372
2438
|
<div class="card-title">Discovered Routes <span>${session.routeMap.length} pages/APIs</span></div>
|
|
1373
2439
|
<table>
|
|
1374
|
-
<thead><tr><th>URL</th><th>Type</th><th>Status</th><th>Forms</th><th>Result</th></tr></thead>
|
|
1375
|
-
<tbody>${routeRows || '<tr><td colspan="
|
|
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>
|
|
1376
2442
|
</table>
|
|
1377
2443
|
</div>
|
|
1378
2444
|
</div>
|
|
@@ -1380,7 +2446,7 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1380
2446
|
<!-- SECURITY -->
|
|
1381
2447
|
<div id="tab-security" class="tab-panel">
|
|
1382
2448
|
<div class="card">
|
|
1383
|
-
<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>
|
|
1384
2450
|
<table>
|
|
1385
2451
|
<thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Fix</th></tr></thead>
|
|
1386
2452
|
<tbody>${secRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
|
|
@@ -1390,22 +2456,102 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1390
2456
|
|
|
1391
2457
|
<!-- PERFORMANCE -->
|
|
1392
2458
|
<div id="tab-performance" class="tab-panel">
|
|
1393
|
-
<div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals
|
|
2459
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Performance — Real Web Vitals (Playwright Chromium)</div>
|
|
1394
2460
|
${perfSection}
|
|
1395
2461
|
</div>
|
|
1396
2462
|
|
|
1397
|
-
<!--
|
|
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 -->
|
|
1398
2470
|
<div id="tab-a11y" class="tab-panel">
|
|
1399
|
-
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG HTML Analysis</div>
|
|
2471
|
+
<div class="card-title" style="padding:.5rem 0 1rem">Accessibility — WCAG 2.1 HTML Analysis (15 rules)</div>
|
|
1400
2472
|
${a11ySection}
|
|
1401
2473
|
</div>
|
|
1402
2474
|
|
|
1403
2475
|
<!-- SEO -->
|
|
1404
2476
|
<div id="tab-seo" class="tab-panel">
|
|
1405
|
-
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent</div>
|
|
2477
|
+
<div class="card-title" style="padding:.5rem 0 1rem">SEO Analysis — Googlebot User-Agent (21 checks)</div>
|
|
1406
2478
|
${seoSection}
|
|
1407
2479
|
</div>
|
|
1408
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}
|
|
2503
|
+
</div>
|
|
2504
|
+
|
|
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
|
+
|
|
1409
2555
|
<!-- CONSOLE -->
|
|
1410
2556
|
<div id="tab-console" class="tab-panel">
|
|
1411
2557
|
<div class="card">
|
|
@@ -1424,7 +2570,7 @@ ${session.playwrightMode ? '<div class="pw-banner">🎭 <strong>Playwright Real
|
|
|
1424
2570
|
|
|
1425
2571
|
</div>
|
|
1426
2572
|
|
|
1427
|
-
<footer>Backlist Enterprise QA v${VERSION} · ${summary.total} tests · ${session.bugs.length} bugs · ${session.routeMap.length} routes · ${session.screenshots.length} screenshots · ${new Date().toLocaleString()}</footer>
|
|
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>
|
|
1428
2574
|
|
|
1429
2575
|
<script>
|
|
1430
2576
|
function showTab(name, el) {
|
|
@@ -1444,28 +2590,31 @@ function filterTests() {
|
|
|
1444
2590
|
function filterBugs() {
|
|
1445
2591
|
const s = (document.getElementById('bugSearch')?.value||'').toLowerCase();
|
|
1446
2592
|
const sv = document.getElementById('bugSev')?.value||'';
|
|
2593
|
+
const ca = document.getElementById('bugCat')?.value||'';
|
|
1447
2594
|
document.querySelectorAll('#bugList .bug-card').forEach(card => {
|
|
1448
|
-
|
|
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';
|
|
1449
2598
|
});
|
|
1450
2599
|
}
|
|
1451
2600
|
const chartCfg = {
|
|
1452
|
-
plugins:{legend:{labels:{color:'#94a3b8',font:{size:
|
|
1453
|
-
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}}
|
|
1454
2603
|
};
|
|
1455
2604
|
new Chart(document.getElementById('coverageChart'),{type:'bar',data:{labels:${chartTypes},datasets:[
|
|
1456
2605
|
{label:'Passed',data:${chartPass2},backgroundColor:'#34d399',borderRadius:3},
|
|
1457
2606
|
{label:'Failed',data:${chartFail2},backgroundColor:'#f87171',borderRadius:3}
|
|
1458
2607
|
]},options:{responsive:true,maintainAspectRatio:false,...chartCfg,scales:{...chartCfg.scales,x:{...chartCfg.scales.x,stacked:true},y:{...chartCfg.scales.y,stacked:true}}}});
|
|
1459
|
-
new Chart(document.getElementById('bugChart'),{type:'doughnut',data:{labels:['P0 Critical','P1 High','P2 Medium','P3 Low'],datasets:[{data:${bugSevData},backgroundColor:['#ef4444','#f59e0b','#3b82f6','#64748b'],borderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#94a3b8',font:{size:
|
|
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}}}}}});
|
|
1460
2609
|
</script>
|
|
1461
2610
|
</body>
|
|
1462
2611
|
</html>`;
|
|
1463
2612
|
}
|
|
1464
2613
|
|
|
1465
2614
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1466
|
-
// Main QA Runner —
|
|
2615
|
+
// Main QA Runner v15 — All phases
|
|
1467
2616
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1468
|
-
async function runQAEngine(session) {
|
|
2617
|
+
async function runQAEngine(session, opts = {}) {
|
|
1469
2618
|
const dash = new TerminalDashboard(session);
|
|
1470
2619
|
dash.start();
|
|
1471
2620
|
|
|
@@ -1484,161 +2633,206 @@ async function runQAEngine(session) {
|
|
|
1484
2633
|
dash.log(`Crawling ${label}: ${url}`);
|
|
1485
2634
|
const t0 = Date.now();
|
|
1486
2635
|
const routes = await crawlSite(url, {
|
|
1487
|
-
maxPages:
|
|
2636
|
+
maxPages: 60,
|
|
1488
2637
|
onRoute: (route) => {
|
|
1489
2638
|
session.routeMap.push(route);
|
|
1490
|
-
dash.
|
|
2639
|
+
dash.setSubPhase(`Found: ${route.url} (${route.type})`);
|
|
1491
2640
|
},
|
|
1492
2641
|
});
|
|
1493
|
-
addResult({ name: `[${label}] Route Discovery`, type: 'discovery',
|
|
2642
|
+
addResult({ name: `[${label}] Route Discovery`, type: 'discovery',
|
|
1494
2643
|
status: routes.length > 0 ? 'PASS' : 'FAIL',
|
|
1495
2644
|
message: `Discovered ${routes.length} routes in ${Date.now()-t0}ms`, url, label });
|
|
1496
2645
|
}
|
|
1497
2646
|
|
|
1498
|
-
// ── Phase 2:
|
|
1499
|
-
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');
|
|
1500
2665
|
const chromium = await getPlaywright();
|
|
1501
2666
|
|
|
1502
2667
|
if (chromium) {
|
|
1503
2668
|
session.playwrightMode = true;
|
|
1504
|
-
dash.log(chalk.hex('#BF40FF')(' 🎭
|
|
2669
|
+
dash.log(chalk.hex('#BF40FF')(' 🎭 Backlist available! Real browser mode ACTIVE'));
|
|
1505
2670
|
|
|
1506
2671
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1507
2672
|
if (!url) continue;
|
|
1508
2673
|
dash.setCurrentTest(`🎭 Browser: ${url}`);
|
|
1509
|
-
dash.log(chalk.cyan(` Launching Chromium for ${label}...`));
|
|
1510
2674
|
|
|
1511
2675
|
const pwResult = await runPlaywrightScan(url, session, dash);
|
|
1512
2676
|
|
|
1513
2677
|
if (pwResult && !pwResult.error) {
|
|
1514
2678
|
const { results: pw } = pwResult;
|
|
1515
2679
|
|
|
1516
|
-
// Store playwright perf data merged with session
|
|
1517
2680
|
session.perfMetrics[label] = {
|
|
1518
2681
|
...session.perfMetrics[label],
|
|
1519
2682
|
...pw.vitals,
|
|
1520
|
-
slowResources
|
|
1521
|
-
resourceStats
|
|
1522
|
-
domChecks
|
|
1523
|
-
interactions
|
|
2683
|
+
slowResources: pw.networkFails.filter(n => n.duration > 1000),
|
|
2684
|
+
resourceStats: pw.resourceStats,
|
|
2685
|
+
domChecks: pw.domChecks,
|
|
2686
|
+
interactions: pw.interactions,
|
|
1524
2687
|
playwrightMode: true,
|
|
1525
2688
|
};
|
|
1526
2689
|
|
|
1527
|
-
// Add DOM check results
|
|
1528
2690
|
for (const check of pw.domChecks || []) {
|
|
1529
|
-
addResult({ name: `DOM: ${check.name}`, type: 'browser-dom',
|
|
2691
|
+
addResult({ name: `DOM: ${check.name}`, type: 'browser-dom',
|
|
1530
2692
|
status: check.pass ? 'PASS' : 'FAIL', message: check.value, url, label });
|
|
1531
2693
|
}
|
|
1532
2694
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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 } });
|
|
1540
2707
|
}
|
|
1541
2708
|
}
|
|
1542
2709
|
|
|
1543
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
evidence: { status: fail.status, failure: fail.failure } });
|
|
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
|
+
}
|
|
1550
2716
|
}
|
|
1551
2717
|
|
|
1552
|
-
//
|
|
1553
|
-
|
|
1554
|
-
addResult({ name: `
|
|
1555
|
-
status:
|
|
1556
|
-
|
|
1557
|
-
severity: 'P2', type: 'javascript', url, evidence: { message: err.message, stack: err.stack?.slice(0,200) } });
|
|
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 });
|
|
1558
2723
|
}
|
|
1559
2724
|
|
|
1560
|
-
//
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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 });
|
|
1565
2731
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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 });
|
|
1571
2737
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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 });
|
|
1575
2743
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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 } });
|
|
1581
2750
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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 });
|
|
1585
2757
|
}
|
|
1586
2758
|
|
|
1587
|
-
|
|
1588
|
-
|
|
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 });
|
|
1589
2790
|
|
|
1590
|
-
dash.log(chalk.green(` ✅ Playwright scan complete for ${label}`));
|
|
1591
2791
|
} else {
|
|
1592
|
-
dash.log(chalk.yellow(` ⚠ Playwright scan failed: ${pwResult?.error || 'unknown error'}`));
|
|
1593
2792
|
addResult({ name: `[${label}] Playwright Scan`, type: 'browser', status: 'FAIL',
|
|
1594
2793
|
message: pwResult?.error || 'Playwright scan failed', url, label });
|
|
1595
2794
|
}
|
|
1596
2795
|
}
|
|
1597
2796
|
} else {
|
|
1598
|
-
dash.log(chalk.yellow(' ⚠ Playwright not installed
|
|
1599
|
-
dash.log(chalk.gray(' Install: npm install playwright && npx playwright install chromium'));
|
|
1600
|
-
// Fallback: HTTP TTFB
|
|
2797
|
+
dash.log(chalk.yellow(' ⚠ Playwright not installed — HTTP-only mode'));
|
|
1601
2798
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1602
2799
|
if (!url) continue;
|
|
1603
2800
|
const t0 = Date.now();
|
|
1604
2801
|
const r = await httpProbe(url, { timeout: 15000 });
|
|
1605
2802
|
const ttfb = Date.now() - t0;
|
|
1606
|
-
session.perfMetrics[label] = { ttfb, bodySize: r.bodySize, statusCode: r.status,
|
|
1607
|
-
|
|
1608
|
-
addResult({ name: `[${label}] TTFB`, type: 'performance',
|
|
1609
|
-
status: ttfb <= 800 ? 'PASS' : 'FAIL',
|
|
1610
|
-
message: `TTFB: ${ttfb}ms (threshold: ≤800ms)`, url, label, duration: ttfb });
|
|
1611
|
-
if (ttfb > 800) session.addBug({ title: `Slow TTFB: ${ttfb}ms`, severity: ttfb > 2000 ? 'P1' : 'P2',
|
|
1612
|
-
type: 'performance', url, evidence: { ttfb }, recommendation: 'Optimize server response time' });
|
|
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 });
|
|
1613
2807
|
}
|
|
1614
2808
|
}
|
|
1615
2809
|
|
|
1616
|
-
// ── Phase
|
|
1617
|
-
dash.setPhase('📡 Phase
|
|
2810
|
+
// ── Phase 4: API Validation ──────────────────────────────────────────
|
|
2811
|
+
dash.setPhase('📡 Phase 4: API Validation & Contract Testing');
|
|
1618
2812
|
const apiRoutes = session.routeMap.filter(r => r.type === 'api' || r.url?.includes('/api/'));
|
|
1619
2813
|
dash.log(`Validating ${apiRoutes.length} API endpoints...`);
|
|
1620
2814
|
for (const route of apiRoutes) {
|
|
1621
2815
|
dash.setCurrentTest(`API: ${route.url}`);
|
|
1622
|
-
const
|
|
1623
|
-
session.
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
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 } });
|
|
1631
2825
|
}
|
|
1632
2826
|
|
|
1633
|
-
// ── Phase
|
|
1634
|
-
dash.setPhase('🛡️ Phase
|
|
2827
|
+
// ── Phase 5: Security ────────────────────────────────────────────────
|
|
2828
|
+
dash.setPhase('🛡️ Phase 5: Security Scan (20+ checks)');
|
|
1635
2829
|
for (const [label, url] of Object.entries(session.urls)) {
|
|
1636
2830
|
if (!url) continue;
|
|
1637
2831
|
dash.setCurrentTest(`Security: ${url}`);
|
|
1638
2832
|
const findings = await runSecurityScan(url);
|
|
1639
2833
|
session.secFindings.push(...findings);
|
|
1640
2834
|
for (const f of findings) {
|
|
1641
|
-
addResult({ name: `Security: ${f.check}`, type: 'security',
|
|
2835
|
+
addResult({ name: `Security: ${f.check}`, type: 'security',
|
|
1642
2836
|
status: f.pass ? 'PASS' : 'FAIL', message: f.detail, severity: f.severity, url, label });
|
|
1643
2837
|
if (!f.pass && ['P0','P1'].includes(f.severity)) {
|
|
1644
2838
|
session.addBug({ title: `Security: ${f.check}`, severity: f.severity, type: 'security',
|
|
@@ -1647,47 +2841,126 @@ async function runQAEngine(session) {
|
|
|
1647
2841
|
}
|
|
1648
2842
|
}
|
|
1649
2843
|
|
|
1650
|
-
// ── Phase
|
|
1651
|
-
dash.setPhase('
|
|
1652
|
-
const
|
|
1653
|
-
|
|
2844
|
+
// ── Phase 6: Cookie Audit (v15) ──────────────────────────────────────
|
|
2845
|
+
dash.setPhase('🍪 Phase 6: Cookie Security Audit');
|
|
2846
|
+
for (const [label, url] of Object.entries(session.urls)) {
|
|
2847
|
+
if (!url) continue;
|
|
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
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
|
|
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) {
|
|
1654
2905
|
dash.setCurrentTest(`A11y: ${route.url}`);
|
|
1655
2906
|
const result = await runA11yScan(route.url);
|
|
1656
2907
|
session.a11yResults.push({ url: route.url, ...result });
|
|
1657
2908
|
for (const v of result.violations) {
|
|
1658
|
-
addResult({ name: `A11y [${v.impact}]: ${v.description}`, type: 'accessibility',
|
|
1659
|
-
status: 'FAIL', message: v.help, severity: v.impact === 'critical' ? 'P0' :
|
|
1660
|
-
|
|
1661
|
-
if (['critical','serious'].includes(v.impact)) session.addBug({
|
|
1662
|
-
title: `A11y: ${v.description}`, severity: v.impact === 'critical' ? 'P0' : 'P1',
|
|
1663
|
-
type: 'accessibility', description: v.help, url: route.url, recommendation: v.helpUrl });
|
|
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 });
|
|
1664
2912
|
}
|
|
1665
|
-
for (const pass of result.passes.slice(0,
|
|
2913
|
+
for (const pass of result.passes.slice(0, 4)) {
|
|
1666
2914
|
addResult({ name: `A11y ✓: ${pass.description}`, type: 'accessibility', status: 'PASS', url: route.url });
|
|
1667
2915
|
}
|
|
1668
2916
|
}
|
|
1669
2917
|
|
|
1670
|
-
// ── Phase
|
|
1671
|
-
dash.setPhase('🔎 Phase
|
|
2918
|
+
// ── Phase 11: SEO ────────────────────────────────────────────────────
|
|
2919
|
+
dash.setPhase('🔎 Phase 11: SEO Validation (21 checks)');
|
|
1672
2920
|
const seoRoutes = session.routeMap.filter(r => r.type === 'page').slice(0, 10);
|
|
1673
2921
|
for (const route of seoRoutes) {
|
|
1674
2922
|
dash.setCurrentTest(`SEO: ${route.url}`);
|
|
1675
2923
|
const result = await runSEOScan(route.url);
|
|
1676
2924
|
session.seoResults.push({ url: route.url, ...result });
|
|
1677
2925
|
for (const c of result.checks) {
|
|
1678
|
-
addResult({ name: `SEO: ${c.name}`, type: 'seo',
|
|
2926
|
+
addResult({ name: `SEO: ${c.name}`, type: 'seo',
|
|
1679
2927
|
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, severity: c.severity, url: route.url });
|
|
1680
|
-
if (!c.pass && ['P0','P1'].includes(c.severity)) session.addBug({
|
|
1681
|
-
title: `SEO: ${c.name}`, severity: c.severity, type: 'seo',
|
|
1682
|
-
description: c.detail, url: route.url, recommendation: c.recommendation });
|
|
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 });
|
|
1683
2929
|
}
|
|
1684
2930
|
}
|
|
1685
2931
|
|
|
1686
|
-
// ── Phase
|
|
1687
|
-
|
|
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`));
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
|
|
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');
|
|
1688
2961
|
dash.log(`Classifying ${session.bugs.length} bugs...`);
|
|
1689
2962
|
for (const bug of session.bugs) {
|
|
1690
|
-
const cls
|
|
2963
|
+
const cls = classifyBug(bug);
|
|
1691
2964
|
bug.aiSeverity = cls.severity;
|
|
1692
2965
|
bug.aiCategory = cls.category;
|
|
1693
2966
|
bug.aiRecommendation = cls.recommendation;
|
|
@@ -1722,12 +2995,20 @@ async function generateReports(session) {
|
|
|
1722
2995
|
urls: session.urls, summary, results: session.results, bugs: session.bugs,
|
|
1723
2996
|
routeMap: session.routeMap, apiLog: session.apiLog, secFindings: session.secFindings,
|
|
1724
2997
|
perfMetrics: session.perfMetrics, a11yResults: session.a11yResults, seoResults: session.seoResults,
|
|
1725
|
-
|
|
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 })),
|
|
1726
3007
|
playwrightMode: session.playwrightMode,
|
|
1727
3008
|
ci: {
|
|
1728
3009
|
exitCode: summary.failed > 0 || session.bugs.some(b => b.severity === 'P0') ? 1 : 0,
|
|
1729
|
-
p0Bugs
|
|
1730
|
-
p1Bugs
|
|
3010
|
+
p0Bugs: session.bugs.filter(b => b.severity === 'P0').length,
|
|
3011
|
+
p1Bugs: session.bugs.filter(b => b.severity === 'P1').length,
|
|
1731
3012
|
passRate: summary.passRate,
|
|
1732
3013
|
},
|
|
1733
3014
|
}, { spaces: 2 });
|
|
@@ -1742,6 +3023,7 @@ export async function initQASystem() {
|
|
|
1742
3023
|
await fs.ensureDir(QA_DIR);
|
|
1743
3024
|
await fs.ensureDir(REPORT_DIR);
|
|
1744
3025
|
await fs.ensureDir(SCREENSHOT_DIR);
|
|
3026
|
+
await fs.ensureDir(BASELINE_DIR);
|
|
1745
3027
|
if (!await fs.pathExists(HISTORY_FILE)) {
|
|
1746
3028
|
await fs.writeJson(HISTORY_FILE, { runs: [], version: VERSION }, { spaces: 2 });
|
|
1747
3029
|
}
|
|
@@ -1765,7 +3047,7 @@ async function saveToHistory(session, htmlPath, jsonPath) {
|
|
|
1765
3047
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1766
3048
|
// Public API — runUrlQA (main entry point)
|
|
1767
3049
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1768
|
-
export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
3050
|
+
export async function runUrlQA({ localUrl, stagingUrl, prodUrl, loadTest = true } = {}) {
|
|
1769
3051
|
const urls = {};
|
|
1770
3052
|
if (localUrl) urls.localhost = localUrl;
|
|
1771
3053
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
@@ -1773,20 +3055,22 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
|
1773
3055
|
|
|
1774
3056
|
if (!Object.keys(urls).length) { console.log(chalk.red(' No URLs provided.')); return null; }
|
|
1775
3057
|
|
|
1776
|
-
// Check Playwright availability and warn
|
|
1777
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')(' ─────────────────────────────────────────'));
|
|
1778
3062
|
if (chromium) {
|
|
1779
|
-
console.log(chalk.hex('#BF40FF')(' 🎭
|
|
1780
|
-
console.log(chalk.gray('
|
|
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'));
|
|
1781
3066
|
} else {
|
|
1782
3067
|
console.log(chalk.yellow(' ⚠ Playwright not found — HTTP-only mode'));
|
|
1783
|
-
console.log(chalk.gray('
|
|
1784
|
-
console.log(chalk.gray(' For real Web Vitals, screenshots, and DOM tests'));
|
|
3068
|
+
console.log(chalk.gray(' Run: npm install playwright && npx playwright install chromium'));
|
|
1785
3069
|
}
|
|
1786
3070
|
console.log('');
|
|
1787
3071
|
|
|
1788
3072
|
const session = new QASession(urls);
|
|
1789
|
-
await runQAEngine(session);
|
|
3073
|
+
await runQAEngine(session, { loadTest });
|
|
1790
3074
|
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1791
3075
|
await saveToHistory(session, htmlPath, jsonPath);
|
|
1792
3076
|
|
|
@@ -1794,9 +3078,7 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
|
1794
3078
|
console.log(chalk.hex('#00F5FF').bold(` ✓ ${session.id} — ${summary.total} tests · ${summary.failed} failed · ${session.bugs.length} bugs · ${session.screenshots.length} screenshots`));
|
|
1795
3079
|
console.log(chalk.gray(` 📄 HTML: ${htmlPath}`));
|
|
1796
3080
|
console.log(chalk.gray(` 📋 JSON: ${jsonPath}`));
|
|
1797
|
-
if (session.screenshots.length > 0) {
|
|
1798
|
-
console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
|
|
1799
|
-
}
|
|
3081
|
+
if (session.screenshots.length > 0) console.log(chalk.hex('#BF40FF')(` 📸 Screenshots: ${SCREENSHOT_DIR}`));
|
|
1800
3082
|
|
|
1801
3083
|
try {
|
|
1802
3084
|
const { exec } = await import('node:child_process');
|
|
@@ -1808,14 +3090,14 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl } = {}) {
|
|
|
1808
3090
|
return { session, htmlPath, jsonPath };
|
|
1809
3091
|
}
|
|
1810
3092
|
|
|
1811
|
-
export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl } = {}) {
|
|
3093
|
+
export async function runAutomatedQA({ continuous = false, localUrl, stagingUrl, prodUrl, loadTest = false } = {}) {
|
|
1812
3094
|
const run = async () => {
|
|
1813
3095
|
const urls = {};
|
|
1814
3096
|
if (localUrl) urls.localhost = localUrl;
|
|
1815
3097
|
if (stagingUrl) urls.staging = stagingUrl;
|
|
1816
3098
|
if (prodUrl) urls.production = prodUrl;
|
|
1817
3099
|
const session = new QASession(urls);
|
|
1818
|
-
await runQAEngine(session);
|
|
3100
|
+
await runQAEngine(session, { loadTest });
|
|
1819
3101
|
const { htmlPath, jsonPath } = await generateReports(session);
|
|
1820
3102
|
await saveToHistory(session, htmlPath, jsonPath);
|
|
1821
3103
|
console.log(chalk.gray(` 📄 Report: ${htmlPath}`));
|
|
@@ -1836,12 +3118,15 @@ export async function runManualQA() {
|
|
|
1836
3118
|
const action = await p.select({
|
|
1837
3119
|
message: 'Manual QA mode:',
|
|
1838
3120
|
options: [
|
|
1839
|
-
{ value: 'full',
|
|
1840
|
-
{ value: 'browser',
|
|
1841
|
-
{ value: 'security',
|
|
1842
|
-
{ value: 'seo',
|
|
1843
|
-
{ value: 'a11y',
|
|
1844
|
-
{ value: 'perf',
|
|
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' },
|
|
1845
3130
|
],
|
|
1846
3131
|
});
|
|
1847
3132
|
if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
|
|
@@ -1853,48 +3138,62 @@ export async function runManualQA() {
|
|
|
1853
3138
|
const sess = new QASession({ localhost: url });
|
|
1854
3139
|
|
|
1855
3140
|
if (action === 'full') {
|
|
1856
|
-
await runQAEngine(sess);
|
|
3141
|
+
await runQAEngine(sess, { loadTest: true });
|
|
1857
3142
|
} else {
|
|
1858
3143
|
const dash = new TerminalDashboard(sess);
|
|
1859
3144
|
dash.start();
|
|
1860
3145
|
try {
|
|
1861
3146
|
if (action === 'browser') {
|
|
1862
3147
|
const chromium = await getPlaywright();
|
|
1863
|
-
if (!chromium) { dash.log(chalk.red('Playwright not installed!
|
|
3148
|
+
if (!chromium) { dash.log(chalk.red('Playwright not installed!')); }
|
|
1864
3149
|
else {
|
|
1865
3150
|
sess.playwrightMode = true;
|
|
1866
3151
|
await runPlaywrightScan(url, sess, dash);
|
|
1867
|
-
sess.perfMetrics.localhost = { ...sess.perfMetrics.localhost, playwrightMode: true };
|
|
1868
3152
|
}
|
|
1869
3153
|
} else if (action === 'security') {
|
|
1870
|
-
const
|
|
1871
|
-
sess.secFindings.push(...
|
|
1872
|
-
|
|
1873
|
-
status: finding.pass ? 'PASS' : 'FAIL', message: finding.detail, timestamp: timestamp() }));
|
|
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() }));
|
|
1874
3157
|
} else if (action === 'seo') {
|
|
1875
3158
|
const r = await runSEOScan(url);
|
|
1876
3159
|
sess.seoResults.push({ url, ...r });
|
|
1877
|
-
r.checks.forEach(c => sess.addResult({ id: shortId(), name: `SEO: ${c.name}`, type: 'seo',
|
|
1878
|
-
status: c.pass ? 'PASS' : 'FAIL', message: c.detail, timestamp: timestamp() }));
|
|
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() }));
|
|
1879
3161
|
} else if (action === 'a11y') {
|
|
1880
3162
|
const r = await runA11yScan(url);
|
|
1881
3163
|
sess.a11yResults.push({ url, ...r });
|
|
1882
|
-
r.violations.forEach(v => sess.addResult({ id: shortId(), name: `A11y: ${v.description}`, type: 'accessibility',
|
|
1883
|
-
|
|
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() }));
|
|
1884
3166
|
} else if (action === 'perf') {
|
|
1885
3167
|
const chromium2 = await getPlaywright();
|
|
1886
3168
|
if (chromium2) {
|
|
1887
3169
|
sess.playwrightMode = true;
|
|
1888
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();
|
|
1889
3195
|
} else {
|
|
1890
|
-
|
|
1891
|
-
const t0 = Date.now(); const r = await httpProbe(url, { timeout: 15000 });
|
|
1892
|
-
return { ttfb: Date.now()-t0, bodySize: r.bodySize, statusCode: r.status, slowResources: [],
|
|
1893
|
-
note: 'Install Playwright for real LCP/FCP/CLS metrics' };
|
|
1894
|
-
})();
|
|
1895
|
-
sess.perfMetrics.localhost = m;
|
|
1896
|
-
sess.addResult({ id: shortId(), name: `TTFB: ${m.ttfb}ms`, type: 'performance',
|
|
1897
|
-
status: m.ttfb <= 800 ? 'PASS' : 'FAIL', message: `${m.ttfb}ms`, timestamp: timestamp() });
|
|
3196
|
+
dash.log(chalk.red('Playwright required for user flow simulation'));
|
|
1898
3197
|
}
|
|
1899
3198
|
}
|
|
1900
3199
|
} finally { dash.stop(); }
|
|
@@ -1926,14 +3225,14 @@ export async function viewQAHistory() {
|
|
|
1926
3225
|
if (!history.runs?.length) { console.log(chalk.yellow('\n No QA history found.\n')); return; }
|
|
1927
3226
|
|
|
1928
3227
|
console.log('');
|
|
1929
|
-
console.log(chalk.hex('#00F5FF').bold(' QA History'));
|
|
1930
|
-
console.log(chalk.gray('
|
|
3228
|
+
console.log(chalk.hex('#00F5FF').bold(' QA History — v15'));
|
|
3229
|
+
console.log(chalk.gray(' ──────────────────────────────────────────────────────'));
|
|
1931
3230
|
for (const run of history.runs.slice(0, 15)) {
|
|
1932
3231
|
const rate = run.summary?.passRate ?? '–';
|
|
1933
3232
|
const col = Number(rate) >= 90 ? chalk.green : Number(rate) >= 70 ? chalk.yellow : chalk.red;
|
|
1934
3233
|
const urls = Object.values(run.urls||{}).filter(Boolean).join(', ');
|
|
1935
3234
|
const pwIcon = run.playwrightMode ? chalk.hex('#BF40FF')('🎭') : chalk.gray('⚡');
|
|
1936
|
-
console.log(` ${chalk.gray(run.id.padEnd(16))} ${chalk.gray(new Date(run.startedAt).toLocaleString().padEnd(22))} ${col((rate+'%').padStart(7))} ${chalk.cyan((run.bugCount||0)+' bugs')} ${pwIcon} ${chalk.dim(urls.slice(0,40))}`);
|
|
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))}`);
|
|
1937
3236
|
}
|
|
1938
3237
|
console.log('');
|
|
1939
3238
|
|
|
@@ -1960,4 +3259,4 @@ export async function viewQAHistory() {
|
|
|
1960
3259
|
} else {
|
|
1961
3260
|
console.log(chalk.yellow(' Report file not found.'));
|
|
1962
3261
|
}
|
|
1963
|
-
}
|
|
3262
|
+
}
|