create-backlist 10.0.6 → 10.0.8
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 +1 -1
- package/package.json +1 -1
- package/src/qa/browser/crawler.js +63 -47
- package/src/qa/browser/interactions.js +108 -46
- package/src/qa/qa-engine.js +221 -204
package/bin/index.js
CHANGED
|
@@ -272,7 +272,7 @@ async function printAnimatedBanner() {
|
|
|
272
272
|
' ║ / /_/ / / ___ |/ /___/ /| | / /____/ / ___/ / ║',
|
|
273
273
|
' ║/_____/ /_/ |_|\\____/_/ |_| /_____/___//____/ ║',
|
|
274
274
|
' ║ ║',
|
|
275
|
-
' ║ ⚡
|
|
275
|
+
' ║ ⚡ v10.0-ULTRA — BACKLIST NEXTOG ENTERPRICE CLI ⚡ ║',
|
|
276
276
|
' ║ Real Testing · AI Powered · Zero Fake Data · Playwright ║',
|
|
277
277
|
' ╚══════════════════════════════════════════════════════════════╝',
|
|
278
278
|
];
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Smart crawler with HTTP fallback when browser unavailable
|
|
2
|
+
import chalk from 'chalk';
|
|
2
3
|
import { URL } from 'node:url';
|
|
3
4
|
import { shortId } from '../qa-engine.js';
|
|
4
5
|
import { getBrowserLaunchOptions } from './installer.js';
|
|
@@ -19,9 +20,9 @@ export class HTTPCrawler {
|
|
|
19
20
|
while (queue.length > 0 && routes.length < maxPages) {
|
|
20
21
|
const { url, depth } = queue.shift();
|
|
21
22
|
const norm = this.#norm(url);
|
|
22
|
-
if (!norm || this.#visited.has(norm))
|
|
23
|
-
if (!this.#sameOrigin(norm, baseUrl))
|
|
24
|
-
if (depth > 3)
|
|
23
|
+
if (!norm || this.#visited.has(norm)) continue;
|
|
24
|
+
if (!this.#sameOrigin(norm, baseUrl)) continue;
|
|
25
|
+
if (depth > 3) continue;
|
|
25
26
|
|
|
26
27
|
this.#visited.add(norm);
|
|
27
28
|
|
|
@@ -29,18 +30,15 @@ export class HTTPCrawler {
|
|
|
29
30
|
routes.push(route);
|
|
30
31
|
if (onRoute) onRoute(route);
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (ln && !this.#visited.has(ln) && this.#sameOrigin(ln, baseUrl)) {
|
|
37
|
-
queue.push({ url: ln, depth: depth + 1 });
|
|
38
|
-
}
|
|
33
|
+
for (const link of (route.links || [])) {
|
|
34
|
+
const ln = this.#norm(link);
|
|
35
|
+
if (ln && !this.#visited.has(ln) && this.#sameOrigin(ln, baseUrl)) {
|
|
36
|
+
queue.push({ url: ln, depth: depth + 1 });
|
|
39
37
|
}
|
|
40
38
|
}
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
//
|
|
41
|
+
// Probe common API paths
|
|
44
42
|
const apiPaths = [
|
|
45
43
|
'/api/health', '/api/status', '/api/v1/health',
|
|
46
44
|
'/api/v1/users', '/api/v1/products', '/health',
|
|
@@ -48,15 +46,17 @@ export class HTTPCrawler {
|
|
|
48
46
|
];
|
|
49
47
|
|
|
50
48
|
for (const p of apiPaths) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(p, baseUrl).toString();
|
|
51
|
+
const norm = this.#norm(url);
|
|
52
|
+
if (this.#visited.has(norm)) continue;
|
|
53
|
+
this.#visited.add(norm);
|
|
54
|
+
const route = await this.#probeURL(url, 0);
|
|
55
|
+
if (route.status > 0 && route.status < 500) {
|
|
56
|
+
routes.push(route);
|
|
57
|
+
if (onRoute) onRoute(route);
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
return routes;
|
|
@@ -75,7 +75,7 @@ export class HTTPCrawler {
|
|
|
75
75
|
});
|
|
76
76
|
clearTimeout(timer);
|
|
77
77
|
|
|
78
|
-
const ct
|
|
78
|
+
const ct = res.headers.get('content-type') || '';
|
|
79
79
|
const duration = Date.now() - t0;
|
|
80
80
|
const headers = {};
|
|
81
81
|
res.headers.forEach((v, k) => { headers[k] = v; });
|
|
@@ -151,25 +151,24 @@ export class HTTPCrawler {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
#detectType(url, ct, status) {
|
|
154
|
-
if (status >= 400)
|
|
154
|
+
if (status >= 400) return 'error-page';
|
|
155
155
|
if (ct.includes('json') || url.includes('/api/')) return 'api';
|
|
156
156
|
if (url.endsWith('.xml') || url.endsWith('.txt')) return 'resource';
|
|
157
|
-
if (/\/(login|signin|auth)/i.test(url))
|
|
158
|
-
if (/\/(admin)/i.test(url))
|
|
159
|
-
if (/\/(dashboard)/i.test(url))
|
|
157
|
+
if (/\/(login|signin|auth)/i.test(url)) return 'auth';
|
|
158
|
+
if (/\/(admin)/i.test(url)) return 'admin';
|
|
159
|
+
if (/\/(dashboard)/i.test(url)) return 'dashboard';
|
|
160
160
|
return 'page';
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// ── Browser-powered crawler (Playwright) ──────────────────────────────────
|
|
165
165
|
export class SmartCrawler {
|
|
166
|
-
#visited
|
|
166
|
+
#visited = new Set();
|
|
167
167
|
#launchOpts = null;
|
|
168
168
|
|
|
169
|
-
constructor(_playwright) {}
|
|
169
|
+
constructor(_playwright) {}
|
|
170
170
|
|
|
171
171
|
async crawl(baseUrl, { maxPages = 60, maxDepth = 4, onRoute } = {}) {
|
|
172
|
-
// Resolve launch options including auto-install
|
|
173
172
|
if (!this.#launchOpts) {
|
|
174
173
|
this.#launchOpts = await getBrowserLaunchOptions();
|
|
175
174
|
}
|
|
@@ -213,9 +212,9 @@ export class SmartCrawler {
|
|
|
213
212
|
while (queue.length > 0 && routes.length < maxPages) {
|
|
214
213
|
const { url, depth } = queue.shift();
|
|
215
214
|
const norm = this.#norm(url);
|
|
216
|
-
if (!norm || this.#visited.has(norm))
|
|
217
|
-
if (!this.#sameOrigin(norm, baseUrl))
|
|
218
|
-
if (depth > maxDepth)
|
|
215
|
+
if (!norm || this.#visited.has(norm)) continue;
|
|
216
|
+
if (!this.#sameOrigin(norm, baseUrl)) continue;
|
|
217
|
+
if (depth > maxDepth) continue;
|
|
219
218
|
this.#visited.add(norm);
|
|
220
219
|
|
|
221
220
|
const route = await this.#probePage(context, norm, depth, baseUrl);
|
|
@@ -240,9 +239,9 @@ export class SmartCrawler {
|
|
|
240
239
|
}
|
|
241
240
|
|
|
242
241
|
async #probePage(context, url, depth, baseUrl) {
|
|
243
|
-
const page
|
|
244
|
-
const networkRequests
|
|
245
|
-
const links
|
|
242
|
+
const page = await context.newPage();
|
|
243
|
+
const networkRequests = [];
|
|
244
|
+
const links = new Set();
|
|
246
245
|
|
|
247
246
|
page.on('request', req => {
|
|
248
247
|
const u = req.url();
|
|
@@ -257,27 +256,44 @@ export class SmartCrawler {
|
|
|
257
256
|
const ct = response?.headers()['content-type'] || '';
|
|
258
257
|
|
|
259
258
|
if (!ct.includes('text/html') && !ct.includes('application/xhtml')) {
|
|
260
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
id: shortId(), url,
|
|
261
|
+
type : this.#detectType(url, ct, status),
|
|
262
|
+
status, depth, links: [], forms: [],
|
|
263
|
+
};
|
|
261
264
|
}
|
|
262
265
|
|
|
263
|
-
const hrefs = await page.$$eval('a[href]', els => els.map(e => e.href).filter(Boolean))
|
|
266
|
+
const hrefs = await page.$$eval('a[href]', els => els.map(e => e.href).filter(Boolean))
|
|
267
|
+
.catch(() => []);
|
|
264
268
|
hrefs.forEach(h => links.add(h));
|
|
265
269
|
|
|
266
270
|
const forms = await page.$$eval('form', els => els.map(f => ({
|
|
267
|
-
action: f.action,
|
|
271
|
+
action: f.action,
|
|
272
|
+
method: f.method || 'GET',
|
|
268
273
|
fields: Array.from(f.elements).map(el => ({
|
|
269
|
-
name: el.name,
|
|
274
|
+
name : el.name,
|
|
275
|
+
type : el.type,
|
|
276
|
+
required: el.required,
|
|
270
277
|
})).filter(f => f.name),
|
|
271
278
|
}))).catch(() => []);
|
|
272
279
|
|
|
273
|
-
networkRequests.forEach(r => {
|
|
280
|
+
networkRequests.forEach(r => {
|
|
281
|
+
if (this.#sameOrigin(r.url, baseUrl)) links.add(r.url);
|
|
282
|
+
});
|
|
274
283
|
|
|
275
284
|
return {
|
|
276
|
-
id: shortId(), url,
|
|
277
|
-
|
|
285
|
+
id: shortId(), url,
|
|
286
|
+
type : this.#detectType(url, ct, status),
|
|
287
|
+
status, depth,
|
|
288
|
+
links : [...links],
|
|
289
|
+
forms,
|
|
290
|
+
contentType: ct,
|
|
278
291
|
};
|
|
279
292
|
} catch (err) {
|
|
280
|
-
return {
|
|
293
|
+
return {
|
|
294
|
+
id: shortId(), url, type: 'error', status: 0,
|
|
295
|
+
depth, links: [], forms: [], error: err.message,
|
|
296
|
+
};
|
|
281
297
|
} finally {
|
|
282
298
|
await page.close().catch(() => {});
|
|
283
299
|
}
|
|
@@ -294,12 +310,12 @@ export class SmartCrawler {
|
|
|
294
310
|
}
|
|
295
311
|
|
|
296
312
|
#detectType(url, ct, status) {
|
|
297
|
-
if (status >= 400)
|
|
313
|
+
if (status >= 400) return 'error-page';
|
|
298
314
|
if (ct.includes('json') || url.includes('/api/')) return 'api';
|
|
299
315
|
if (url.endsWith('.xml') || url.endsWith('.txt')) return 'resource';
|
|
300
|
-
if (/\/(login|signin|auth)/i.test(url))
|
|
301
|
-
if (/\/(admin)/i.test(url))
|
|
302
|
-
if (/\/(dashboard)/i.test(url))
|
|
316
|
+
if (/\/(login|signin|auth)/i.test(url)) return 'auth';
|
|
317
|
+
if (/\/(admin)/i.test(url)) return 'admin';
|
|
318
|
+
if (/\/(dashboard)/i.test(url)) return 'dashboard';
|
|
303
319
|
return 'page';
|
|
304
320
|
}
|
|
305
|
-
}
|
|
321
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Real browser interactions with HTTP fallback
|
|
2
|
-
import
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { shortId, sleep } from '../qa-engine.js';
|
|
3
4
|
import { getBrowserLaunchOptions } from './installer.js';
|
|
4
5
|
|
|
5
6
|
// ── HTTP-only page tester (fallback) ──────────────────────────────────────
|
|
@@ -27,30 +28,28 @@ export class HTTPPageTester {
|
|
|
27
28
|
try { text = await res.text(); } catch {}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
const pass
|
|
31
|
-
|
|
32
|
-
// Extract forms from HTML
|
|
31
|
+
const pass = status >= 200 && status < 400;
|
|
33
32
|
const forms = this.#extractForms(text);
|
|
34
33
|
|
|
35
34
|
onNetworkEvent?.({ type: 'response', url, status, headers });
|
|
36
35
|
|
|
37
36
|
return {
|
|
38
37
|
pass,
|
|
39
|
-
failReason
|
|
40
|
-
page
|
|
41
|
-
loadTime
|
|
42
|
-
consoleErrors
|
|
43
|
-
networkErrors
|
|
44
|
-
jsErrors
|
|
45
|
-
resourcesFailed
|
|
38
|
+
failReason : pass ? null : `HTTP ${status}`,
|
|
39
|
+
page : null,
|
|
40
|
+
loadTime : duration,
|
|
41
|
+
consoleErrors : [],
|
|
42
|
+
networkErrors : [],
|
|
43
|
+
jsErrors : [],
|
|
44
|
+
resourcesFailed : [],
|
|
46
45
|
interactedElements: [],
|
|
47
|
-
renderTime
|
|
48
|
-
domContentLoaded: null,
|
|
46
|
+
renderTime : null,
|
|
47
|
+
domContentLoaded : null,
|
|
49
48
|
forms,
|
|
50
49
|
status,
|
|
51
50
|
headers,
|
|
52
51
|
message: pass ? `HTTP ${status} in ${duration}ms` : `HTTP ${status}`,
|
|
53
|
-
mode
|
|
52
|
+
mode : 'http-fallback',
|
|
54
53
|
};
|
|
55
54
|
} catch (err) {
|
|
56
55
|
return {
|
|
@@ -63,13 +62,22 @@ export class HTTPPageTester {
|
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
async testForm(page, form) {
|
|
66
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
pass : true,
|
|
67
|
+
validationOk: true,
|
|
68
|
+
submissionOk: true,
|
|
69
|
+
errors : [],
|
|
70
|
+
duration : 0,
|
|
71
|
+
message : 'HTTP mode — form structure detected',
|
|
72
|
+
};
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
async testAuthFlow(page, url, opts) {
|
|
70
|
-
// HTTP-mode auth check: verify login page exists + returns 200
|
|
71
76
|
try {
|
|
72
|
-
const res = await fetch(url, {
|
|
77
|
+
const res = await fetch(url, {
|
|
78
|
+
redirect: 'follow',
|
|
79
|
+
signal : AbortSignal.timeout(8000),
|
|
80
|
+
});
|
|
73
81
|
return {
|
|
74
82
|
pass : res.status === 200,
|
|
75
83
|
details: [{ url, status: res.status, note: 'Login page reachable (HTTP mode)' }],
|
|
@@ -93,7 +101,7 @@ export class HTTPPageTester {
|
|
|
93
101
|
const method = (attrs.match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
|
|
94
102
|
const fields = [];
|
|
95
103
|
const inpRe = /<input([^>]*)>/gi;
|
|
96
|
-
let
|
|
104
|
+
let inp;
|
|
97
105
|
while ((inp = inpRe.exec(body)) !== null) {
|
|
98
106
|
const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
|
|
99
107
|
const type = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
|
|
@@ -110,9 +118,9 @@ export class HTTPPageTester {
|
|
|
110
118
|
export class BrowserInteractor {
|
|
111
119
|
#playwright;
|
|
112
120
|
#session;
|
|
113
|
-
#browser
|
|
114
|
-
#context
|
|
115
|
-
#launchOpts
|
|
121
|
+
#browser = null;
|
|
122
|
+
#context = null;
|
|
123
|
+
#launchOpts = null;
|
|
116
124
|
#httpFallback;
|
|
117
125
|
#useFallback = false;
|
|
118
126
|
|
|
@@ -133,9 +141,20 @@ export class BrowserInteractor {
|
|
|
133
141
|
return;
|
|
134
142
|
}
|
|
135
143
|
|
|
144
|
+
// If playwright module wasn't loaded, fall back
|
|
145
|
+
if (!this.#playwright) {
|
|
146
|
+
this.#useFallback = true;
|
|
147
|
+
console.log(chalk.yellow('\n ⚠ Playwright module not available — using HTTP-only mode\n'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
try {
|
|
137
152
|
const { executablePath, headless, args } = this.#launchOpts;
|
|
138
|
-
this.#browser
|
|
153
|
+
this.#browser = await this.#playwright.chromium.launch({
|
|
154
|
+
executablePath,
|
|
155
|
+
headless,
|
|
156
|
+
args,
|
|
157
|
+
});
|
|
139
158
|
this.#context = await this.#browser.newContext({
|
|
140
159
|
viewport : { width: 1280, height: 800 },
|
|
141
160
|
ignoreHTTPSErrors: true,
|
|
@@ -160,11 +179,11 @@ export class BrowserInteractor {
|
|
|
160
179
|
return this.#httpFallback.testPage(url, opts);
|
|
161
180
|
}
|
|
162
181
|
|
|
163
|
-
const page
|
|
164
|
-
const consoleErrors
|
|
165
|
-
const networkErrors
|
|
166
|
-
const jsErrors
|
|
167
|
-
const resourcesFailed
|
|
182
|
+
const page = await this.#context.newPage();
|
|
183
|
+
const consoleErrors = [];
|
|
184
|
+
const networkErrors = [];
|
|
185
|
+
const jsErrors = [];
|
|
186
|
+
const resourcesFailed = [];
|
|
168
187
|
const interactedElements = [];
|
|
169
188
|
|
|
170
189
|
page.on('console', msg => {
|
|
@@ -182,7 +201,12 @@ export class BrowserInteractor {
|
|
|
182
201
|
});
|
|
183
202
|
|
|
184
203
|
page.on('requestfailed', req => {
|
|
185
|
-
const entry = {
|
|
204
|
+
const entry = {
|
|
205
|
+
url : req.url(),
|
|
206
|
+
method : req.method(),
|
|
207
|
+
failure : req.failure()?.errorText || 'unknown',
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
};
|
|
186
210
|
networkErrors.push(entry);
|
|
187
211
|
resourcesFailed.push({ url: req.url(), type: req.resourceType(), failure: entry.failure });
|
|
188
212
|
opts.onNetworkEvent?.({ type: 'failed', ...entry });
|
|
@@ -199,27 +223,33 @@ export class BrowserInteractor {
|
|
|
199
223
|
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
|
|
200
224
|
const status = response?.status() || 0;
|
|
201
225
|
|
|
202
|
-
if
|
|
226
|
+
if (status >= 500) { pass = false; failReason = `Server error: HTTP ${status}`; }
|
|
203
227
|
else if (status >= 400) { pass = false; failReason = `Client error: HTTP ${status}`; }
|
|
204
228
|
|
|
205
229
|
const timing = await page.evaluate(() => {
|
|
206
230
|
const t = window.performance?.timing;
|
|
207
231
|
if (!t) return null;
|
|
208
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
renderTime : t.domComplete - t.navigationStart,
|
|
234
|
+
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
|
|
235
|
+
};
|
|
209
236
|
}).catch(() => null);
|
|
210
237
|
|
|
211
238
|
renderTime = timing?.renderTime;
|
|
212
239
|
domContentLoaded = timing?.domContentLoaded;
|
|
213
240
|
|
|
214
241
|
const forms = await page.$$eval('form', els => els.map(f => ({
|
|
215
|
-
action: f.action,
|
|
242
|
+
action: f.action,
|
|
243
|
+
method: f.method || 'GET',
|
|
216
244
|
fields: Array.from(f.elements).map(el => ({
|
|
217
|
-
name: el.name,
|
|
245
|
+
name : el.name,
|
|
246
|
+
type : el.type,
|
|
247
|
+
required: el.required,
|
|
218
248
|
tagName: el.tagName?.toLowerCase(),
|
|
219
249
|
})).filter(f => f.name),
|
|
220
250
|
}))).catch(() => []);
|
|
221
251
|
|
|
222
|
-
// Real interactions
|
|
252
|
+
// Real hover interactions on nav links
|
|
223
253
|
const navLinks = await page.$$('nav a, header a').catch(() => []);
|
|
224
254
|
for (const link of navLinks.slice(0, 5)) {
|
|
225
255
|
try {
|
|
@@ -243,8 +273,10 @@ export class BrowserInteractor {
|
|
|
243
273
|
|
|
244
274
|
} catch (err) {
|
|
245
275
|
return {
|
|
246
|
-
pass: false,
|
|
247
|
-
|
|
276
|
+
pass : false,
|
|
277
|
+
failReason: err.message,
|
|
278
|
+
page,
|
|
279
|
+
loadTime : Date.now() - t0,
|
|
248
280
|
consoleErrors, networkErrors, jsErrors,
|
|
249
281
|
resourcesFailed, interactedElements,
|
|
250
282
|
forms: [], message: err.message, mode: 'browser',
|
|
@@ -270,10 +302,24 @@ export class BrowserInteractor {
|
|
|
270
302
|
els => els.map(e => e.textContent?.trim()).filter(Boolean)
|
|
271
303
|
).catch(() => []);
|
|
272
304
|
|
|
273
|
-
return {
|
|
305
|
+
return {
|
|
306
|
+
pass : true,
|
|
307
|
+
validationOk: msgs.length > 0,
|
|
308
|
+
submissionOk: true,
|
|
309
|
+
errors,
|
|
310
|
+
duration : Date.now() - t0,
|
|
311
|
+
message : 'Form tested',
|
|
312
|
+
};
|
|
274
313
|
} catch (err) {
|
|
275
314
|
errors.push(err.message);
|
|
276
|
-
return {
|
|
315
|
+
return {
|
|
316
|
+
pass : false,
|
|
317
|
+
validationOk: false,
|
|
318
|
+
submissionOk: false,
|
|
319
|
+
errors,
|
|
320
|
+
duration : Date.now() - t0,
|
|
321
|
+
message : err.message,
|
|
322
|
+
};
|
|
277
323
|
}
|
|
278
324
|
}
|
|
279
325
|
|
|
@@ -286,35 +332,51 @@ export class BrowserInteractor {
|
|
|
286
332
|
for (const cred of (opts.testCredentials || [])) {
|
|
287
333
|
try {
|
|
288
334
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
335
|
+
|
|
289
336
|
const uf = await page.$('input[type="email"],input[name="email"],input[name="username"]');
|
|
290
337
|
const pf = await page.$('input[type="password"]');
|
|
291
338
|
if (!uf || !pf) { details.push({ note: 'Login fields not found' }); continue; }
|
|
339
|
+
|
|
292
340
|
await uf.fill(cred.username);
|
|
293
341
|
await pf.fill(cred.password);
|
|
342
|
+
|
|
294
343
|
const btn = await page.$('[type="submit"]');
|
|
295
344
|
if (btn) { await btn.click(); await page.waitForTimeout(2000); }
|
|
296
345
|
|
|
297
346
|
const body = await page.textContent('body').catch(() => '');
|
|
298
347
|
const curUrl = page.url();
|
|
299
|
-
const rejected = /invalid|incorrect|error|wrong|fail/i.test(body)
|
|
348
|
+
const rejected = /invalid|incorrect|error|wrong|fail/i.test(body)
|
|
349
|
+
|| curUrl.includes('login');
|
|
350
|
+
|
|
351
|
+
details.push({
|
|
352
|
+
credentials: cred.username.slice(0, 8) + '...',
|
|
353
|
+
expectFail : cred.expectFail,
|
|
354
|
+
wasRejected: rejected,
|
|
355
|
+
currentUrl : curUrl,
|
|
356
|
+
});
|
|
300
357
|
|
|
301
|
-
details.push({ credentials: cred.username.slice(0, 8) + '...', expectFail: cred.expectFail, wasRejected: rejected, currentUrl: curUrl });
|
|
302
358
|
if (cred.expectFail && !rejected) { pass = false; }
|
|
303
359
|
} catch (err) {
|
|
304
360
|
details.push({ error: err.message });
|
|
305
361
|
}
|
|
306
362
|
}
|
|
307
|
-
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
pass,
|
|
366
|
+
details,
|
|
367
|
+
duration: Date.now() - t0,
|
|
368
|
+
message : pass ? 'Auth flow validated' : 'Auth issues detected',
|
|
369
|
+
};
|
|
308
370
|
}
|
|
309
371
|
|
|
310
372
|
#testValue(field) {
|
|
311
373
|
const t = (field.type || 'text').toLowerCase();
|
|
312
374
|
const n = (field.name || '').toLowerCase();
|
|
313
|
-
if (t === 'email'
|
|
314
|
-
if (t === 'password' || n.includes('pass'))
|
|
315
|
-
if (t === 'tel'
|
|
316
|
-
if (t === 'number')
|
|
317
|
-
if (n.includes('name'))
|
|
375
|
+
if (t === 'email' || n.includes('email')) return 'test@backlist-qa.dev';
|
|
376
|
+
if (t === 'password' || n.includes('pass')) return 'TestPass123!';
|
|
377
|
+
if (t === 'tel' || n.includes('phone')) return '+1-555-000-0000';
|
|
378
|
+
if (t === 'number') return '42';
|
|
379
|
+
if (n.includes('name')) return 'QA Test User';
|
|
318
380
|
return 'backlist-qa-test';
|
|
319
381
|
}
|
|
320
|
-
}
|
|
382
|
+
}
|
package/src/qa/qa-engine.js
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import { performance } from 'node:perf_hooks';
|
|
15
15
|
import { EventEmitter } from 'node:events';
|
|
16
|
+
import readline from 'node:readline';
|
|
16
17
|
|
|
17
18
|
import { SmartCrawler } from './browser/crawler.js';
|
|
18
19
|
import { BrowserInteractor } from './browser/interactions.js';
|
|
@@ -28,43 +29,64 @@ import { JSONReporter } from './reporters/json.js';
|
|
|
28
29
|
import { AIClassifier } from './utils/ai-classifier.js';
|
|
29
30
|
|
|
30
31
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
31
|
-
export const VERSION
|
|
32
|
-
export const QA_DIR
|
|
33
|
-
export const REPORT_DIR
|
|
34
|
-
export const HISTORY_FILE
|
|
32
|
+
export const VERSION = '12.0.0';
|
|
33
|
+
export const QA_DIR = path.join(process.cwd(), '.backlist', 'qa');
|
|
34
|
+
export const REPORT_DIR = path.join(QA_DIR, 'reports');
|
|
35
|
+
export const HISTORY_FILE = path.join(QA_DIR, 'history.json');
|
|
35
36
|
export const SCREENSHOT_DIR = path.join(REPORT_DIR, 'screenshots');
|
|
36
37
|
|
|
37
38
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
38
|
-
export function timestamp()
|
|
39
|
-
export function shortId()
|
|
40
|
-
export function sleep(ms)
|
|
39
|
+
export function timestamp() { return new Date().toISOString(); }
|
|
40
|
+
export function shortId() { return Math.random().toString(36).slice(2, 9); }
|
|
41
|
+
export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
41
42
|
export function formatDuration(ms) {
|
|
42
43
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
43
44
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
44
45
|
}
|
|
45
46
|
export function formatBytes(b) {
|
|
46
|
-
if (!b || b < 0)
|
|
47
|
-
if (b < 1024)
|
|
48
|
-
if (b < 1024 * 1024)
|
|
47
|
+
if (!b || b < 0) return '0B';
|
|
48
|
+
if (b < 1024) return `${b}B`;
|
|
49
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`;
|
|
49
50
|
return `${(b / 1024 / 1024).toFixed(1)}MB`;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// ── Ask yes/no in terminal without async-inside-Promise issue ─────────────
|
|
54
|
+
function askQuestion(question) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const rl = readline.createInterface({
|
|
57
|
+
input : process.stdin,
|
|
58
|
+
output: process.stdout,
|
|
59
|
+
});
|
|
60
|
+
// Auto-resolve after 10s if no input
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve(false);
|
|
64
|
+
}, 10_000);
|
|
65
|
+
|
|
66
|
+
rl.question(question, (answer) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
rl.close();
|
|
69
|
+
resolve(answer.toLowerCase().trim() === 'y');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
// ── QA Session ────────────────────────────────────────────────────────────
|
|
53
75
|
export class QASession {
|
|
54
76
|
id;
|
|
55
77
|
startedAt;
|
|
56
|
-
urls
|
|
57
|
-
results
|
|
58
|
-
bugs
|
|
59
|
-
screenshots
|
|
78
|
+
urls = {};
|
|
79
|
+
results = [];
|
|
80
|
+
bugs = [];
|
|
81
|
+
screenshots = [];
|
|
60
82
|
consoleErrors = [];
|
|
61
|
-
networkLog
|
|
62
|
-
apiLog
|
|
63
|
-
routeMap
|
|
64
|
-
perfMetrics
|
|
65
|
-
secFindings
|
|
66
|
-
a11yResults
|
|
67
|
-
seoResults
|
|
83
|
+
networkLog = [];
|
|
84
|
+
apiLog = [];
|
|
85
|
+
routeMap = [];
|
|
86
|
+
perfMetrics = {};
|
|
87
|
+
secFindings = [];
|
|
88
|
+
a11yResults = [];
|
|
89
|
+
seoResults = [];
|
|
68
90
|
|
|
69
91
|
constructor(urls) {
|
|
70
92
|
this.id = `QA-${shortId()}`;
|
|
@@ -72,9 +94,7 @@ export class QASession {
|
|
|
72
94
|
this.urls = urls;
|
|
73
95
|
}
|
|
74
96
|
|
|
75
|
-
addResult(result) {
|
|
76
|
-
this.results.push(result);
|
|
77
|
-
}
|
|
97
|
+
addResult(result) { this.results.push(result); }
|
|
78
98
|
|
|
79
99
|
addBug(bug) {
|
|
80
100
|
this.bugs.push({ ...bug, id: `BUG-${shortId()}`, createdAt: timestamp() });
|
|
@@ -111,110 +131,91 @@ export class QAEngine extends EventEmitter {
|
|
|
111
131
|
|
|
112
132
|
constructor(session, options = {}) {
|
|
113
133
|
super();
|
|
114
|
-
this.#session
|
|
115
|
-
this.#terminal
|
|
134
|
+
this.#session = session;
|
|
135
|
+
this.#terminal = new TerminalDashboard(session);
|
|
116
136
|
this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
|
|
117
137
|
this.#aiClassifier = new AIClassifier();
|
|
118
138
|
}
|
|
119
139
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Always import installer — handles all browser detection
|
|
132
|
-
const { getBrowserLaunchOptions, ensureBrowser } = await import('./browser/installer.js');
|
|
133
|
-
|
|
134
|
-
// Check browser availability once — share result
|
|
135
|
-
const launchOpts = await getBrowserLaunchOptions();
|
|
136
|
-
|
|
137
|
-
if (!launchOpts.available) {
|
|
138
|
-
console.log(chalk.yellow('\n ⚠ Playwright browser not found.'));
|
|
139
|
-
console.log(chalk.gray(' The QA engine will run in HTTP-only mode.'));
|
|
140
|
-
console.log(chalk.gray(' Browser-based tests (JS errors, screenshots, a11y'));
|
|
141
|
-
console.log(chalk.gray(' via axe-core, real Web Vitals) will be skipped.\n'));
|
|
142
|
-
console.log(chalk.dim(' To enable full browser testing:'));
|
|
143
|
-
console.log(chalk.white(' npx playwright install chromium\n'));
|
|
144
|
-
|
|
145
|
-
// Offer to install now
|
|
146
|
-
const shouldInstall = await new Promise(resolve => {
|
|
147
|
-
const rl = (await import('node:readline')).createInterface({
|
|
148
|
-
input : process.stdin,
|
|
149
|
-
output: process.stdout,
|
|
150
|
-
});
|
|
151
|
-
rl.question(chalk.cyan(' Install Playwright browser now? (y/N): '), ans => {
|
|
152
|
-
rl.close();
|
|
153
|
-
resolve(ans.toLowerCase() === 'y');
|
|
154
|
-
});
|
|
155
|
-
// Auto-timeout after 10s
|
|
156
|
-
setTimeout(() => { rl.close(); resolve(false); }, 10_000);
|
|
157
|
-
});
|
|
140
|
+
// ── FIX: init() — no await inside non-async callbacks ─────────────────
|
|
141
|
+
async init() {
|
|
142
|
+
// Dynamic import Playwright — optional dependency
|
|
143
|
+
let playwright = null;
|
|
144
|
+
try {
|
|
145
|
+
playwright = await import('playwright');
|
|
146
|
+
} catch {
|
|
147
|
+
// Will use HTTP fallback throughout — playwright is optional
|
|
148
|
+
}
|
|
158
149
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
150
|
+
// Resolve browser launch options (handles all detection logic)
|
|
151
|
+
const { getBrowserLaunchOptions, installPlaywrightBrowsers } = await import('./browser/installer.js');
|
|
152
|
+
const launchOpts = await getBrowserLaunchOptions();
|
|
153
|
+
|
|
154
|
+
if (!launchOpts.available) {
|
|
155
|
+
console.log(chalk.yellow('\n ⚠ Playwright browser not found.'));
|
|
156
|
+
console.log(chalk.gray(' The QA engine will run in HTTP-only mode.'));
|
|
157
|
+
console.log(chalk.gray(' Browser-based tests (JS errors, screenshots, real Web Vitals)'));
|
|
158
|
+
console.log(chalk.gray(' will be skipped. All HTTP-based tests will still run.\n'));
|
|
159
|
+
console.log(chalk.dim(' To enable full browser testing:'));
|
|
160
|
+
console.log(chalk.white(' npx playwright install chromium\n'));
|
|
161
|
+
|
|
162
|
+
// ── FIX: use the extracted askQuestion() helper — no await in Promise ──
|
|
163
|
+
const shouldInstall = await askQuestion(
|
|
164
|
+
chalk.cyan(' Install Playwright browser now? (y/N): ')
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (shouldInstall) {
|
|
168
|
+
const result = await installPlaywrightBrowsers();
|
|
169
|
+
if (!result.success) {
|
|
170
|
+
console.log(chalk.yellow(' Auto-install failed. Continuing in HTTP-only mode.\n'));
|
|
171
|
+
}
|
|
164
172
|
}
|
|
173
|
+
} else {
|
|
174
|
+
const exeName = launchOpts.executablePath?.split(/[/\\]/).pop() ?? 'chromium';
|
|
175
|
+
console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${exeName})`));
|
|
165
176
|
}
|
|
166
|
-
} else {
|
|
167
|
-
console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${launchOpts.executablePath?.split(/[/\\]/).pop()})`));
|
|
168
|
-
}
|
|
169
177
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
// Initialise all subsystems
|
|
179
|
+
this.#crawler = new SmartCrawler(playwright);
|
|
180
|
+
this.#interactor = new BrowserInteractor(playwright, this.#session);
|
|
181
|
+
this.#apiValidator = new RealAPIValidator(this.#session);
|
|
182
|
+
this.#security = new SecurityScanner(this.#session);
|
|
183
|
+
this.#performance = new PerformanceProfiler(this.#session);
|
|
184
|
+
this.#a11y = new AccessibilityChecker(playwright, this.#session);
|
|
185
|
+
this.#seo = new SEOScanner(this.#session);
|
|
186
|
+
this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
|
|
187
|
+
this.#aiClassifier = new AIClassifier();
|
|
188
|
+
|
|
189
|
+
await this.#interactor.launch();
|
|
190
|
+
await this.#screenshotter.init();
|
|
191
|
+
}
|
|
183
192
|
|
|
184
193
|
async run() {
|
|
185
194
|
this.#terminal.start();
|
|
186
195
|
this.emit('session:start', this.#session);
|
|
187
196
|
|
|
188
197
|
try {
|
|
189
|
-
// Phase 1 — Discovery
|
|
190
198
|
this.#terminal.setPhase('🔍 Phase 1: Route Discovery & Crawling');
|
|
191
199
|
await this.#phaseDiscovery();
|
|
192
200
|
|
|
193
|
-
// Phase 2 — API Validation
|
|
194
201
|
this.#terminal.setPhase('📡 Phase 2: Real API Validation');
|
|
195
202
|
await this.#phaseAPIValidation();
|
|
196
203
|
|
|
197
|
-
// Phase 3 — Browser Interactions
|
|
198
204
|
this.#terminal.setPhase('🖱️ Phase 3: Browser Interaction Testing');
|
|
199
205
|
await this.#phaseBrowserInteractions();
|
|
200
206
|
|
|
201
|
-
// Phase 4 — Security Scan
|
|
202
207
|
this.#terminal.setPhase('🛡️ Phase 4: Security Deep Scan');
|
|
203
208
|
await this.#phaseSecurityScan();
|
|
204
209
|
|
|
205
|
-
// Phase 5 — Performance
|
|
206
210
|
this.#terminal.setPhase('⚡ Phase 5: Performance Profiling');
|
|
207
211
|
await this.#phasePerformance();
|
|
208
212
|
|
|
209
|
-
// Phase 6 — Accessibility
|
|
210
213
|
this.#terminal.setPhase('♿ Phase 6: Accessibility Testing');
|
|
211
214
|
await this.#phaseAccessibility();
|
|
212
215
|
|
|
213
|
-
// Phase 7 — SEO
|
|
214
216
|
this.#terminal.setPhase('🔎 Phase 7: SEO Validation');
|
|
215
217
|
await this.#phaseSEO();
|
|
216
218
|
|
|
217
|
-
// Phase 8 — AI Bug Classification
|
|
218
219
|
this.#terminal.setPhase('🤖 Phase 8: AI Bug Classification');
|
|
219
220
|
await this.#phaseAIClassification();
|
|
220
221
|
|
|
@@ -229,6 +230,36 @@ async init() {
|
|
|
229
230
|
return this.#session;
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
// Run a single named phase (used by manual QA)
|
|
234
|
+
async runPhase(name) {
|
|
235
|
+
this.#terminal.start();
|
|
236
|
+
try {
|
|
237
|
+
switch (name) {
|
|
238
|
+
case 'full-url':
|
|
239
|
+
await this.#phaseDiscovery();
|
|
240
|
+
await this.#phaseAPIValidation();
|
|
241
|
+
await this.#phaseBrowserInteractions();
|
|
242
|
+
await this.#phaseSecurityScan();
|
|
243
|
+
await this.#phasePerformance();
|
|
244
|
+
await this.#phaseAccessibility();
|
|
245
|
+
await this.#phaseSEO();
|
|
246
|
+
await this.#phaseAIClassification();
|
|
247
|
+
break;
|
|
248
|
+
case 'security': await this.#phaseSecurityScan(); break;
|
|
249
|
+
case 'perf': await this.#phasePerformance(); break;
|
|
250
|
+
case 'a11y': await this.#phaseAccessibility(); break;
|
|
251
|
+
case 'seo': await this.#phaseSEO(); break;
|
|
252
|
+
case 'api': await this.#phaseAPIValidation(); break;
|
|
253
|
+
default:
|
|
254
|
+
await this.run();
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
this.#terminal.stop();
|
|
258
|
+
await this.#interactor.close().catch(() => {});
|
|
259
|
+
}
|
|
260
|
+
return this.#session;
|
|
261
|
+
}
|
|
262
|
+
|
|
232
263
|
abort() {
|
|
233
264
|
this.#aborted = true;
|
|
234
265
|
this.#terminal.stop();
|
|
@@ -242,9 +273,9 @@ async init() {
|
|
|
242
273
|
this.#terminal.log(`Crawling ${label}: ${url}`);
|
|
243
274
|
|
|
244
275
|
const routes = await this.#crawler.crawl(url, {
|
|
245
|
-
maxPages
|
|
246
|
-
maxDepth
|
|
247
|
-
onRoute
|
|
276
|
+
maxPages: 60,
|
|
277
|
+
maxDepth: 4,
|
|
278
|
+
onRoute : (route) => {
|
|
248
279
|
this.#session.routeMap.push(route);
|
|
249
280
|
this.#terminal.log(` Found: ${route.url} (${route.type})`);
|
|
250
281
|
},
|
|
@@ -258,17 +289,16 @@ async init() {
|
|
|
258
289
|
message : routes.length > 0
|
|
259
290
|
? `Discovered ${routes.length} routes`
|
|
260
291
|
: 'No routes discovered — site may be unreachable',
|
|
261
|
-
data : { routeCount: routes.length
|
|
262
|
-
url,
|
|
263
|
-
label,
|
|
292
|
+
data : { routeCount: routes.length },
|
|
293
|
+
url, label,
|
|
264
294
|
});
|
|
265
295
|
}
|
|
266
296
|
}
|
|
267
297
|
|
|
268
|
-
// ── Phase 2:
|
|
298
|
+
// ── Phase 2: API Validation ────────────────────────────────────────────
|
|
269
299
|
async #phaseAPIValidation() {
|
|
270
300
|
const apiRoutes = this.#session.routeMap.filter(r =>
|
|
271
|
-
r.type === 'api' || r.url
|
|
301
|
+
r.type === 'api' || r.url?.includes('/api/')
|
|
272
302
|
);
|
|
273
303
|
|
|
274
304
|
this.#terminal.log(`Validating ${apiRoutes.length} API endpoints...`);
|
|
@@ -287,11 +317,11 @@ async init() {
|
|
|
287
317
|
status : result.pass ? 'PASS' : 'FAIL',
|
|
288
318
|
message : result.message,
|
|
289
319
|
data : {
|
|
290
|
-
statusCode
|
|
320
|
+
statusCode : result.statusCode,
|
|
291
321
|
responseTime: result.responseTime,
|
|
292
|
-
contentType: result.contentType,
|
|
293
|
-
body
|
|
294
|
-
headers
|
|
322
|
+
contentType : result.contentType,
|
|
323
|
+
body : result.body?.slice(0, 500),
|
|
324
|
+
headers : result.headers,
|
|
295
325
|
},
|
|
296
326
|
url : route.url,
|
|
297
327
|
duration: result.responseTime,
|
|
@@ -308,7 +338,6 @@ async init() {
|
|
|
308
338
|
}
|
|
309
339
|
}
|
|
310
340
|
|
|
311
|
-
// Detect APIs from network traffic
|
|
312
341
|
const discoveredAPIs = await this.#apiValidator.discoverFromNetworkLog(
|
|
313
342
|
this.#session.networkLog
|
|
314
343
|
);
|
|
@@ -338,7 +367,6 @@ async init() {
|
|
|
338
367
|
},
|
|
339
368
|
});
|
|
340
369
|
|
|
341
|
-
// Real screenshot on failure
|
|
342
370
|
if (!result.pass || result.consoleErrors.length > 0) {
|
|
343
371
|
const screenshot = await this.#screenshotter.capture(
|
|
344
372
|
result.page,
|
|
@@ -346,7 +374,11 @@ async init() {
|
|
|
346
374
|
);
|
|
347
375
|
if (screenshot) {
|
|
348
376
|
result.screenshotPath = screenshot;
|
|
349
|
-
this.#session.screenshots.push({
|
|
377
|
+
this.#session.screenshots.push({
|
|
378
|
+
url : route.url,
|
|
379
|
+
path : screenshot,
|
|
380
|
+
reason: result.failReason,
|
|
381
|
+
});
|
|
350
382
|
}
|
|
351
383
|
}
|
|
352
384
|
|
|
@@ -354,26 +386,27 @@ async init() {
|
|
|
354
386
|
name : `Page: ${route.url}`,
|
|
355
387
|
type : 'browser',
|
|
356
388
|
category: 'interaction',
|
|
357
|
-
status : result.pass
|
|
389
|
+
status : result.pass
|
|
390
|
+
? (result.consoleErrors.length > 0 ? 'FLAKY' : 'PASS')
|
|
391
|
+
: 'FAIL',
|
|
358
392
|
message : result.message,
|
|
359
393
|
data : {
|
|
360
|
-
loadTime
|
|
361
|
-
consoleErrors
|
|
362
|
-
networkErrors
|
|
394
|
+
loadTime : result.loadTime,
|
|
395
|
+
consoleErrors : result.consoleErrors,
|
|
396
|
+
networkErrors : result.networkErrors,
|
|
363
397
|
interactedElements: result.interactedElements,
|
|
364
|
-
screenshotPath: result.screenshotPath,
|
|
365
|
-
jsErrors
|
|
366
|
-
resourcesFailed: result.resourcesFailed,
|
|
367
|
-
renderTime
|
|
368
|
-
domContentLoaded: result.domContentLoaded,
|
|
398
|
+
screenshotPath : result.screenshotPath,
|
|
399
|
+
jsErrors : result.jsErrors,
|
|
400
|
+
resourcesFailed : result.resourcesFailed,
|
|
401
|
+
renderTime : result.renderTime,
|
|
402
|
+
domContentLoaded : result.domContentLoaded,
|
|
369
403
|
},
|
|
370
|
-
url
|
|
371
|
-
duration: result.loadTime,
|
|
404
|
+
url : route.url,
|
|
405
|
+
duration : result.loadTime,
|
|
372
406
|
screenshotPath: result.screenshotPath,
|
|
373
407
|
});
|
|
374
408
|
|
|
375
|
-
|
|
376
|
-
for (const err of result.consoleErrors) {
|
|
409
|
+
for (const err of (result.consoleErrors || [])) {
|
|
377
410
|
this.#session.addBug({
|
|
378
411
|
title : `JS Error: ${err.text?.slice(0, 80)}`,
|
|
379
412
|
severity : err.type === 'error' ? 'P1' : 'P2',
|
|
@@ -384,8 +417,7 @@ async init() {
|
|
|
384
417
|
});
|
|
385
418
|
}
|
|
386
419
|
|
|
387
|
-
|
|
388
|
-
for (const nErr of result.networkErrors) {
|
|
420
|
+
for (const nErr of (result.networkErrors || [])) {
|
|
389
421
|
this.#session.addBug({
|
|
390
422
|
title : `Network Failure: ${nErr.url}`,
|
|
391
423
|
severity : 'P2',
|
|
@@ -396,12 +428,10 @@ async init() {
|
|
|
396
428
|
});
|
|
397
429
|
}
|
|
398
430
|
|
|
399
|
-
|
|
400
|
-
if (result.forms && result.forms.length > 0) {
|
|
431
|
+
if (result.forms?.length > 0) {
|
|
401
432
|
await this.#testForms(route.url, result.forms, result.page);
|
|
402
433
|
}
|
|
403
434
|
|
|
404
|
-
// Test auth flows
|
|
405
435
|
if (this.#isAuthPage(route.url)) {
|
|
406
436
|
await this.#testAuthFlow(route.url, result.page);
|
|
407
437
|
}
|
|
@@ -424,19 +454,18 @@ async init() {
|
|
|
424
454
|
status : finding.pass ? 'PASS' : 'FAIL',
|
|
425
455
|
message : finding.detail,
|
|
426
456
|
data : finding.evidence,
|
|
427
|
-
url,
|
|
428
|
-
label,
|
|
457
|
+
url, label,
|
|
429
458
|
severity: finding.severity,
|
|
430
459
|
});
|
|
431
460
|
|
|
432
461
|
if (!finding.pass && (finding.severity === 'P0' || finding.severity === 'P1')) {
|
|
433
462
|
this.#session.addBug({
|
|
434
|
-
title
|
|
435
|
-
severity
|
|
436
|
-
type
|
|
437
|
-
description: finding.detail,
|
|
463
|
+
title : `Security: ${finding.check}`,
|
|
464
|
+
severity : finding.severity,
|
|
465
|
+
type : 'security',
|
|
466
|
+
description : finding.detail,
|
|
438
467
|
url,
|
|
439
|
-
evidence
|
|
468
|
+
evidence : finding.evidence,
|
|
440
469
|
recommendation: finding.recommendation,
|
|
441
470
|
});
|
|
442
471
|
}
|
|
@@ -452,20 +481,18 @@ async init() {
|
|
|
452
481
|
const metrics = await this.#performance.profile(url);
|
|
453
482
|
this.#session.perfMetrics[label] = metrics;
|
|
454
483
|
|
|
455
|
-
// Core Web Vitals as real test results
|
|
456
484
|
const vitals = [
|
|
457
|
-
{ name: 'LCP', value: metrics.lcp, threshold: 2500,
|
|
458
|
-
{ name: 'FID', value: metrics.fid, threshold: 100,
|
|
459
|
-
{ name: 'CLS', value: metrics.cls, threshold: 0.1,
|
|
460
|
-
{ name: 'FCP', value: metrics.fcp, threshold: 1800,
|
|
461
|
-
{ name: 'TTFB', value: metrics.ttfb, threshold: 800,
|
|
462
|
-
{ name: '
|
|
463
|
-
{ name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
|
|
485
|
+
{ name: 'LCP', value: metrics.lcp, threshold: 2500, unit: 'ms' },
|
|
486
|
+
{ name: 'FID', value: metrics.fid, threshold: 100, unit: 'ms' },
|
|
487
|
+
{ name: 'CLS', value: metrics.cls, threshold: 0.1, unit: '' },
|
|
488
|
+
{ name: 'FCP', value: metrics.fcp, threshold: 1800, unit: 'ms' },
|
|
489
|
+
{ name: 'TTFB', value: metrics.ttfb, threshold: 800, unit: 'ms' },
|
|
490
|
+
{ name: 'TBT', value: metrics.tbt, threshold: 200, unit: 'ms' },
|
|
464
491
|
];
|
|
465
492
|
|
|
466
493
|
for (const vital of vitals) {
|
|
467
|
-
const
|
|
468
|
-
const
|
|
494
|
+
const na = vital.value === null || vital.value === undefined;
|
|
495
|
+
const pass = !na && vital.value <= vital.threshold;
|
|
469
496
|
|
|
470
497
|
this.#addResult({
|
|
471
498
|
name : `[${label}] ${vital.name} — Core Web Vital`,
|
|
@@ -473,38 +500,35 @@ async init() {
|
|
|
473
500
|
category: 'web-vitals',
|
|
474
501
|
status : na ? 'SKIP' : (pass ? 'PASS' : 'FAIL'),
|
|
475
502
|
message : na
|
|
476
|
-
? `${vital.name} not measurable`
|
|
477
|
-
: `${vital.name}: ${vital.value}${vital.unit} (threshold:
|
|
478
|
-
data : { value: vital.value, threshold: vital.threshold
|
|
479
|
-
url,
|
|
480
|
-
label,
|
|
503
|
+
? `${vital.name} not measurable (HTTP-only mode)`
|
|
504
|
+
: `${vital.name}: ${vital.value}${vital.unit} (threshold: ≤${vital.threshold}${vital.unit})`,
|
|
505
|
+
data : { value: vital.value, threshold: vital.threshold },
|
|
506
|
+
url, label,
|
|
481
507
|
duration: vital.value,
|
|
482
508
|
});
|
|
483
509
|
|
|
484
510
|
if (!na && !pass) {
|
|
485
511
|
this.#session.addBug({
|
|
486
|
-
title
|
|
487
|
-
severity
|
|
488
|
-
type
|
|
489
|
-
description: `${vital.name} exceeds threshold on ${label}`,
|
|
512
|
+
title : `Poor ${vital.name}: ${vital.value}${vital.unit} (>${vital.threshold}${vital.unit})`,
|
|
513
|
+
severity : (vital.name === 'LCP' || vital.name === 'CLS') ? 'P1' : 'P2',
|
|
514
|
+
type : 'performance',
|
|
515
|
+
description : `${vital.name} exceeds threshold on ${label}`,
|
|
490
516
|
url,
|
|
491
|
-
evidence
|
|
517
|
+
evidence : { value: vital.value, threshold: vital.threshold },
|
|
492
518
|
recommendation: `Optimize ${vital.name} — see https://web.dev/vitals`,
|
|
493
519
|
});
|
|
494
520
|
}
|
|
495
521
|
}
|
|
496
522
|
|
|
497
|
-
// Real resource analysis
|
|
498
523
|
for (const resource of (metrics.slowResources || [])) {
|
|
499
524
|
this.#addResult({
|
|
500
|
-
name : `[${label}] Slow resource: ${resource.url
|
|
525
|
+
name : `[${label}] Slow resource: ${resource.url?.split('/').pop()}`,
|
|
501
526
|
type : 'performance',
|
|
502
527
|
category: 'resource',
|
|
503
528
|
status : 'FAIL',
|
|
504
529
|
message : `${resource.url} took ${resource.duration}ms (${formatBytes(resource.size)})`,
|
|
505
530
|
data : resource,
|
|
506
|
-
url,
|
|
507
|
-
label,
|
|
531
|
+
url, label,
|
|
508
532
|
duration: resource.duration,
|
|
509
533
|
});
|
|
510
534
|
}
|
|
@@ -524,7 +548,7 @@ async init() {
|
|
|
524
548
|
const result = await this.#a11y.check(route.url);
|
|
525
549
|
this.#session.a11yResults.push({ url: route.url, ...result });
|
|
526
550
|
|
|
527
|
-
for (const violation of result.violations) {
|
|
551
|
+
for (const violation of (result.violations || [])) {
|
|
528
552
|
this.#addResult({
|
|
529
553
|
name : `A11y [${violation.impact}]: ${violation.description}`,
|
|
530
554
|
type : 'accessibility',
|
|
@@ -545,18 +569,17 @@ async init() {
|
|
|
545
569
|
|
|
546
570
|
if (violation.impact === 'critical' || violation.impact === 'serious') {
|
|
547
571
|
this.#session.addBug({
|
|
548
|
-
title
|
|
549
|
-
severity
|
|
550
|
-
type
|
|
551
|
-
description: `${violation.nodes} element(s): ${violation.help}`,
|
|
552
|
-
url
|
|
553
|
-
evidence
|
|
572
|
+
title : `A11y: ${violation.description}`,
|
|
573
|
+
severity : violation.impact === 'critical' ? 'P0' : 'P1',
|
|
574
|
+
type : 'accessibility',
|
|
575
|
+
description : `${violation.nodes} element(s): ${violation.help}`,
|
|
576
|
+
url : route.url,
|
|
577
|
+
evidence : violation.affectedNodes,
|
|
554
578
|
recommendation: violation.helpUrl,
|
|
555
579
|
});
|
|
556
580
|
}
|
|
557
581
|
}
|
|
558
582
|
|
|
559
|
-
// Passes also recorded as real results
|
|
560
583
|
for (const pass of (result.passes || []).slice(0, 5)) {
|
|
561
584
|
this.#addResult({
|
|
562
585
|
name : `A11y Pass: ${pass.description}`,
|
|
@@ -584,7 +607,7 @@ async init() {
|
|
|
584
607
|
const result = await this.#seo.scan(route.url);
|
|
585
608
|
this.#session.seoResults.push({ url: route.url, ...result });
|
|
586
609
|
|
|
587
|
-
for (const check of result.checks) {
|
|
610
|
+
for (const check of (result.checks || [])) {
|
|
588
611
|
this.#addResult({
|
|
589
612
|
name : `SEO: ${check.name} — ${new URL(route.url).pathname}`,
|
|
590
613
|
type : 'seo',
|
|
@@ -598,11 +621,11 @@ async init() {
|
|
|
598
621
|
|
|
599
622
|
if (!check.pass && (check.severity === 'P0' || check.severity === 'P1')) {
|
|
600
623
|
this.#session.addBug({
|
|
601
|
-
title
|
|
602
|
-
severity
|
|
603
|
-
type
|
|
604
|
-
description: check.detail,
|
|
605
|
-
url
|
|
624
|
+
title : `SEO: ${check.name}`,
|
|
625
|
+
severity : check.severity,
|
|
626
|
+
type : 'seo',
|
|
627
|
+
description : check.detail,
|
|
628
|
+
url : route.url,
|
|
606
629
|
recommendation: check.recommendation,
|
|
607
630
|
});
|
|
608
631
|
}
|
|
@@ -616,16 +639,16 @@ async init() {
|
|
|
616
639
|
|
|
617
640
|
for (const bug of this.#session.bugs) {
|
|
618
641
|
const classification = await this.#aiClassifier.classify(bug, this.#session);
|
|
619
|
-
bug.aiSeverity
|
|
620
|
-
bug.aiCategory
|
|
642
|
+
bug.aiSeverity = classification.severity;
|
|
643
|
+
bug.aiCategory = classification.category;
|
|
621
644
|
bug.aiRecommendation = classification.recommendation;
|
|
622
|
-
bug.aiConfidence
|
|
645
|
+
bug.aiConfidence = classification.confidence;
|
|
623
646
|
}
|
|
624
647
|
|
|
625
|
-
// Sort bugs by AI-determined severity
|
|
626
648
|
this.#session.bugs.sort((a, b) => {
|
|
627
649
|
const order = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
628
|
-
return (order[a.aiSeverity || a.severity] || 3)
|
|
650
|
+
return (order[a.aiSeverity || a.severity] || 3)
|
|
651
|
+
- (order[b.aiSeverity || b.severity] || 3);
|
|
629
652
|
});
|
|
630
653
|
}
|
|
631
654
|
|
|
@@ -673,9 +696,9 @@ async init() {
|
|
|
673
696
|
|
|
674
697
|
const result = await this.#interactor.testAuthFlow(page, url, {
|
|
675
698
|
testCredentials: [
|
|
676
|
-
{ username: 'test@example.com',
|
|
677
|
-
{ username: 'invalid@test.com',
|
|
678
|
-
{ username: '',
|
|
699
|
+
{ username: 'test@example.com', password: 'wrong-password-test', expectFail: true },
|
|
700
|
+
{ username: 'invalid@test.com', password: 'wrong123', expectFail: true },
|
|
701
|
+
{ username: '', password: '', expectFail: true },
|
|
679
702
|
],
|
|
680
703
|
});
|
|
681
704
|
|
|
@@ -706,7 +729,6 @@ async init() {
|
|
|
706
729
|
return /\/(login|signin|auth|register|signup)/i.test(url);
|
|
707
730
|
}
|
|
708
731
|
|
|
709
|
-
// ── Add real result ────────────────────────────────────────────────────
|
|
710
732
|
#addResult(result) {
|
|
711
733
|
const r = {
|
|
712
734
|
id : shortId(),
|
|
@@ -721,9 +743,9 @@ async init() {
|
|
|
721
743
|
}
|
|
722
744
|
}
|
|
723
745
|
|
|
724
|
-
//
|
|
746
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
725
747
|
// Public API — exported functions
|
|
726
|
-
//
|
|
748
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
727
749
|
|
|
728
750
|
export async function initQASystem() {
|
|
729
751
|
await fs.ensureDir(QA_DIR);
|
|
@@ -738,12 +760,12 @@ export async function saveSession(session) {
|
|
|
738
760
|
const history = await loadHistory();
|
|
739
761
|
const summary = session.getSummary();
|
|
740
762
|
history.runs.unshift({
|
|
741
|
-
id
|
|
742
|
-
startedAt: session.startedAt,
|
|
743
|
-
urls
|
|
763
|
+
id : session.id,
|
|
764
|
+
startedAt : session.startedAt,
|
|
765
|
+
urls : session.urls,
|
|
744
766
|
summary,
|
|
745
|
-
version
|
|
746
|
-
bugCount
|
|
767
|
+
version : VERSION,
|
|
768
|
+
bugCount : session.bugs.length,
|
|
747
769
|
screenshotCount: session.screenshots.length,
|
|
748
770
|
});
|
|
749
771
|
if (history.runs.length > 100) history.runs = history.runs.slice(0, 100);
|
|
@@ -774,11 +796,8 @@ export async function runUrlQA({ localUrl, stagingUrl, prodUrl, options = {} } =
|
|
|
774
796
|
await engine.run();
|
|
775
797
|
await saveSession(session);
|
|
776
798
|
|
|
777
|
-
const
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
const htmlPath = await htmlReporter.generate(REPORT_DIR);
|
|
781
|
-
const jsonPath = await jsonReporter.generate(REPORT_DIR);
|
|
799
|
+
const htmlPath = await new HTMLReporter(session).generate(REPORT_DIR);
|
|
800
|
+
const jsonPath = await new JSONReporter(session).generate(REPORT_DIR);
|
|
782
801
|
|
|
783
802
|
return { session, htmlPath, jsonPath };
|
|
784
803
|
}
|
|
@@ -832,12 +851,12 @@ export async function runManualQA() {
|
|
|
832
851
|
const action = await p.select({
|
|
833
852
|
message: 'Manual QA — what to run?',
|
|
834
853
|
options: [
|
|
835
|
-
{ value: 'full-url',
|
|
836
|
-
{ value: 'security',
|
|
837
|
-
{ value: 'perf',
|
|
838
|
-
{ value: 'a11y',
|
|
839
|
-
{ value: 'seo',
|
|
840
|
-
{ value: 'api',
|
|
854
|
+
{ value: 'full-url', label: '🌐 Full URL-Based Real Scan', hint: 'Browser + API + Security + Perf + SEO + A11y' },
|
|
855
|
+
{ value: 'security', label: '🛡️ Security Only', hint: 'Real HTTP security header + vuln scan' },
|
|
856
|
+
{ value: 'perf', label: '⚡ Performance Only', hint: 'Real Core Web Vitals measurement' },
|
|
857
|
+
{ value: 'a11y', label: '♿ Accessibility Only', hint: 'Real axe-core WCAG scan' },
|
|
858
|
+
{ value: 'seo', label: '🔎 SEO Only', hint: 'Real meta, og, robots, sitemap scan' },
|
|
859
|
+
{ value: 'api', label: '📡 API Only', hint: 'Real endpoint probe + contract validation' },
|
|
841
860
|
],
|
|
842
861
|
});
|
|
843
862
|
if (p.isCancel(action)) { p.cancel('Cancelled.'); return; }
|
|
@@ -861,8 +880,6 @@ export async function runManualQA() {
|
|
|
861
880
|
const session = new QASession(urls);
|
|
862
881
|
const engine = new QAEngine(session);
|
|
863
882
|
await engine.init();
|
|
864
|
-
|
|
865
|
-
// Only run selected phases
|
|
866
883
|
await engine.runPhase(action);
|
|
867
884
|
|
|
868
885
|
await saveSession(session);
|
|
@@ -881,8 +898,8 @@ export async function autoRunPostGeneration(options = {}) {
|
|
|
881
898
|
console.log('');
|
|
882
899
|
|
|
883
900
|
const url = await p.text({
|
|
884
|
-
message
|
|
885
|
-
placeholder: 'http://localhost:3000',
|
|
901
|
+
message : 'Server URL to validate:',
|
|
902
|
+
placeholder : 'http://localhost:3000',
|
|
886
903
|
defaultValue: 'http://localhost:3000',
|
|
887
904
|
});
|
|
888
905
|
if (p.isCancel(url)) { p.cancel('Cancelled.'); return; }
|
|
@@ -907,7 +924,8 @@ export async function viewQAHistory() {
|
|
|
907
924
|
|
|
908
925
|
for (const run of history.runs.slice(0, 15)) {
|
|
909
926
|
const rate = run.summary?.passRate ?? '–';
|
|
910
|
-
const color = Number(rate) >= 90 ? chalk.green
|
|
927
|
+
const color = Number(rate) >= 90 ? chalk.green
|
|
928
|
+
: Number(rate) >= 70 ? chalk.yellow : chalk.red;
|
|
911
929
|
const bugs = run.bugCount ?? 0;
|
|
912
930
|
const shots = run.screenshotCount ?? 0;
|
|
913
931
|
const urlStr = Object.values(run.urls || {}).filter(Boolean).join(', ');
|
|
@@ -939,7 +957,6 @@ export async function viewQAHistory() {
|
|
|
939
957
|
const reportPath = path.join(REPORT_DIR, `${chosen.toLowerCase()}.html`);
|
|
940
958
|
if (await fs.pathExists(reportPath)) {
|
|
941
959
|
console.log(chalk.green(` 📄 Report: ${reportPath}`));
|
|
942
|
-
// Open in browser if possible
|
|
943
960
|
try {
|
|
944
961
|
const { exec } = await import('node:child_process');
|
|
945
962
|
const cmd = process.platform === 'darwin' ? 'open'
|