create-backlist 10.0.4 → 10.0.5
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 +989 -713
- package/package.json +1 -1
- package/src/qa/analyzers/accessibility.js +157 -39
- package/src/qa/analyzers/performance.js +89 -102
- package/src/qa/browser/crawler.js +248 -166
- package/src/qa/browser/installer.js +209 -0
- package/src/qa/browser/interactions.js +222 -219
- package/src/qa/qa-engine.js +60 -19
|
@@ -1,27 +1,151 @@
|
|
|
1
|
-
// Real browser interactions
|
|
2
|
-
import { shortId, sleep }
|
|
1
|
+
// Real browser interactions with HTTP fallback
|
|
2
|
+
import { shortId, sleep } from '../qa-engine.js';
|
|
3
|
+
import { getBrowserLaunchOptions } from './installer.js';
|
|
3
4
|
|
|
5
|
+
// ── HTTP-only page tester (fallback) ──────────────────────────────────────
|
|
6
|
+
export class HTTPPageTester {
|
|
7
|
+
async testPage(url, { onConsoleError, onNetworkEvent } = {}) {
|
|
8
|
+
const t0 = Date.now();
|
|
9
|
+
try {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
signal : controller.signal,
|
|
14
|
+
headers : { 'User-Agent': 'Backlist-QA/12.0' },
|
|
15
|
+
redirect: 'follow',
|
|
16
|
+
});
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
|
|
19
|
+
const duration = Date.now() - t0;
|
|
20
|
+
const status = res.status;
|
|
21
|
+
const contentType = res.headers.get('content-type') || '';
|
|
22
|
+
const headers = {};
|
|
23
|
+
res.headers.forEach((v, k) => { headers[k] = v; });
|
|
24
|
+
|
|
25
|
+
let text = '';
|
|
26
|
+
if (contentType.includes('text/html')) {
|
|
27
|
+
try { text = await res.text(); } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pass = status >= 200 && status < 400;
|
|
31
|
+
|
|
32
|
+
// Extract forms from HTML
|
|
33
|
+
const forms = this.#extractForms(text);
|
|
34
|
+
|
|
35
|
+
onNetworkEvent?.({ type: 'response', url, status, headers });
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
pass,
|
|
39
|
+
failReason : pass ? null : `HTTP ${status}`,
|
|
40
|
+
page : null, // no real browser page
|
|
41
|
+
loadTime : duration,
|
|
42
|
+
consoleErrors : [], // HTTP mode — no JS execution
|
|
43
|
+
networkErrors : [],
|
|
44
|
+
jsErrors : [],
|
|
45
|
+
resourcesFailed : [],
|
|
46
|
+
interactedElements: [],
|
|
47
|
+
renderTime : null,
|
|
48
|
+
domContentLoaded: null,
|
|
49
|
+
forms,
|
|
50
|
+
status,
|
|
51
|
+
headers,
|
|
52
|
+
message: pass ? `HTTP ${status} in ${duration}ms` : `HTTP ${status}`,
|
|
53
|
+
mode : 'http-fallback',
|
|
54
|
+
};
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
pass: false, failReason: err.message, page: null,
|
|
58
|
+
loadTime: Date.now() - t0, consoleErrors: [], networkErrors: [],
|
|
59
|
+
jsErrors: [], resourcesFailed: [], interactedElements: [],
|
|
60
|
+
forms: [], message: err.message, mode: 'http-fallback',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async testForm(page, form) {
|
|
66
|
+
return { pass: true, validationOk: true, submissionOk: true, errors: [], duration: 0, message: 'HTTP mode — form structure detected' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async testAuthFlow(page, url, opts) {
|
|
70
|
+
// HTTP-mode auth check: verify login page exists + returns 200
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
|
|
73
|
+
return {
|
|
74
|
+
pass : res.status === 200,
|
|
75
|
+
details: [{ url, status: res.status, note: 'Login page reachable (HTTP mode)' }],
|
|
76
|
+
duration: 0,
|
|
77
|
+
message: res.status === 200 ? 'Login page accessible' : `Login page returned ${res.status}`,
|
|
78
|
+
};
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { pass: false, details: [], duration: 0, message: err.message };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#extractForms(html) {
|
|
85
|
+
if (!html) return [];
|
|
86
|
+
const forms = [];
|
|
87
|
+
const re = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = re.exec(html)) !== null) {
|
|
90
|
+
const attrs = m[1];
|
|
91
|
+
const body = m[2];
|
|
92
|
+
const action = (attrs.match(/action=["']([^"']+)["']/) || [])[1] || '';
|
|
93
|
+
const method = (attrs.match(/method=["']([^"']+)["']/) || [])[1] || 'GET';
|
|
94
|
+
const fields = [];
|
|
95
|
+
const inpRe = /<input([^>]*)>/gi;
|
|
96
|
+
let inp;
|
|
97
|
+
while ((inp = inpRe.exec(body)) !== null) {
|
|
98
|
+
const name = (inp[1].match(/name=["']([^"']+)["']/) || [])[1];
|
|
99
|
+
const type = (inp[1].match(/type=["']([^"']+)["']/) || [])[1] || 'text';
|
|
100
|
+
const required = /required/i.test(inp[1]);
|
|
101
|
+
if (name) fields.push({ name, type, required });
|
|
102
|
+
}
|
|
103
|
+
if (fields.length > 0) forms.push({ action, method, fields });
|
|
104
|
+
}
|
|
105
|
+
return forms;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Browser-powered interactor (Playwright) ───────────────────────────────
|
|
4
110
|
export class BrowserInteractor {
|
|
5
111
|
#playwright;
|
|
6
112
|
#session;
|
|
7
|
-
#browser
|
|
8
|
-
#context
|
|
113
|
+
#browser = null;
|
|
114
|
+
#context = null;
|
|
115
|
+
#launchOpts = null;
|
|
116
|
+
#httpFallback;
|
|
117
|
+
#useFallback = false;
|
|
9
118
|
|
|
10
119
|
constructor(playwright, session) {
|
|
11
|
-
this.#playwright
|
|
12
|
-
this.#session
|
|
120
|
+
this.#playwright = playwright;
|
|
121
|
+
this.#session = session;
|
|
122
|
+
this.#httpFallback = new HTTPPageTester();
|
|
13
123
|
}
|
|
14
124
|
|
|
15
125
|
async launch() {
|
|
16
|
-
this.#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
126
|
+
this.#launchOpts = await getBrowserLaunchOptions();
|
|
127
|
+
|
|
128
|
+
if (!this.#launchOpts.available) {
|
|
129
|
+
this.#useFallback = true;
|
|
130
|
+
console.log(chalk.yellow('\n ⚠ Browser unavailable — using HTTP-only mode'));
|
|
131
|
+
console.log(chalk.gray(' Browser-dependent tests (JS errors, screenshots, interactions)'));
|
|
132
|
+
console.log(chalk.gray(' will be skipped. All HTTP-based tests will still run.\n'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const { executablePath, headless, args } = this.#launchOpts;
|
|
138
|
+
this.#browser = await this.#playwright.chromium.launch({ executablePath, headless, args });
|
|
139
|
+
this.#context = await this.#browser.newContext({
|
|
140
|
+
viewport : { width: 1280, height: 800 },
|
|
141
|
+
ignoreHTTPSErrors: true,
|
|
142
|
+
});
|
|
143
|
+
console.log(chalk.gray(` ✓ Browser ready (${this.#launchOpts.source})`));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
this.#useFallback = true;
|
|
146
|
+
console.log(chalk.yellow(` ⚠ Browser launch failed: ${err.message}`));
|
|
147
|
+
console.log(chalk.gray(' Switching to HTTP-only mode...\n'));
|
|
148
|
+
}
|
|
25
149
|
}
|
|
26
150
|
|
|
27
151
|
async close() {
|
|
@@ -29,112 +153,74 @@ export class BrowserInteractor {
|
|
|
29
153
|
await this.#browser?.close().catch(() => {});
|
|
30
154
|
}
|
|
31
155
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
156
|
+
get isUsingBrowser() { return !this.#useFallback && !!this.#browser; }
|
|
157
|
+
|
|
158
|
+
async testPage(url, opts = {}) {
|
|
159
|
+
if (this.#useFallback || !this.#browser) {
|
|
160
|
+
return this.#httpFallback.testPage(url, opts);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const page = await this.#context.newPage();
|
|
164
|
+
const consoleErrors = [];
|
|
165
|
+
const networkErrors = [];
|
|
166
|
+
const jsErrors = [];
|
|
167
|
+
const resourcesFailed = [];
|
|
38
168
|
const interactedElements = [];
|
|
39
169
|
|
|
40
|
-
// Real console capture
|
|
41
170
|
page.on('console', msg => {
|
|
42
171
|
if (msg.type() === 'error' || msg.type() === 'warning') {
|
|
43
172
|
const entry = { type: msg.type(), text: msg.text(), timestamp: Date.now() };
|
|
44
173
|
consoleErrors.push(entry);
|
|
45
|
-
onConsoleError?.(entry);
|
|
174
|
+
opts.onConsoleError?.(entry);
|
|
46
175
|
}
|
|
47
176
|
});
|
|
48
177
|
|
|
49
|
-
// Real JS error capture
|
|
50
178
|
page.on('pageerror', err => {
|
|
51
179
|
const entry = { message: err.message, stack: err.stack, timestamp: Date.now() };
|
|
52
180
|
jsErrors.push(entry);
|
|
53
|
-
onConsoleError?.({ type: 'pageerror', text: err.message, stack: err.stack });
|
|
181
|
+
opts.onConsoleError?.({ type: 'pageerror', text: err.message, stack: err.stack });
|
|
54
182
|
});
|
|
55
183
|
|
|
56
|
-
// Real network failure capture
|
|
57
184
|
page.on('requestfailed', req => {
|
|
58
|
-
const entry = {
|
|
59
|
-
url : req.url(),
|
|
60
|
-
method : req.method(),
|
|
61
|
-
failure: req.failure()?.errorText || 'unknown',
|
|
62
|
-
timestamp: Date.now(),
|
|
63
|
-
};
|
|
185
|
+
const entry = { url: req.url(), method: req.method(), failure: req.failure()?.errorText || 'unknown', timestamp: Date.now() };
|
|
64
186
|
networkErrors.push(entry);
|
|
65
|
-
|
|
187
|
+
resourcesFailed.push({ url: req.url(), type: req.resourceType(), failure: entry.failure });
|
|
188
|
+
opts.onNetworkEvent?.({ type: 'failed', ...entry });
|
|
66
189
|
});
|
|
67
190
|
|
|
68
|
-
// All network requests
|
|
69
191
|
page.on('response', async res => {
|
|
70
|
-
|
|
71
|
-
onNetworkEvent?.({
|
|
72
|
-
type : 'response',
|
|
73
|
-
url : res.url(),
|
|
74
|
-
status,
|
|
75
|
-
headers: await res.allHeaders().catch(() => {}),
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// Resources that fail to load
|
|
80
|
-
page.on('requestfailed', req => {
|
|
81
|
-
resourcesFailed.push({
|
|
82
|
-
url : req.url(),
|
|
83
|
-
type : req.resourceType(),
|
|
84
|
-
failure: req.failure()?.errorText,
|
|
85
|
-
});
|
|
192
|
+
opts.onNetworkEvent?.({ type: 'response', url: res.url(), status: res.status() });
|
|
86
193
|
});
|
|
87
194
|
|
|
88
195
|
const t0 = Date.now();
|
|
89
|
-
let pass = true;
|
|
90
|
-
let failReason = null;
|
|
91
|
-
let renderTime = null;
|
|
92
|
-
let domContentLoaded = null;
|
|
196
|
+
let pass = true, failReason = null, renderTime = null, domContentLoaded = null;
|
|
93
197
|
|
|
94
198
|
try {
|
|
95
|
-
const response = await page.goto(url, {
|
|
96
|
-
|
|
97
|
-
timeout : 20_000,
|
|
98
|
-
});
|
|
199
|
+
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
|
|
200
|
+
const status = response?.status() || 0;
|
|
99
201
|
|
|
100
|
-
|
|
101
|
-
if (status >=
|
|
102
|
-
pass = false;
|
|
103
|
-
failReason = `Server error: HTTP ${status}`;
|
|
104
|
-
} else if (status >= 400) {
|
|
105
|
-
pass = false;
|
|
106
|
-
failReason = `Client error: HTTP ${status}`;
|
|
107
|
-
}
|
|
202
|
+
if (status >= 500) { pass = false; failReason = `Server error: HTTP ${status}`; }
|
|
203
|
+
else if (status >= 400) { pass = false; failReason = `Client error: HTTP ${status}`; }
|
|
108
204
|
|
|
109
|
-
// Real timing metrics
|
|
110
205
|
const timing = await page.evaluate(() => {
|
|
111
206
|
const t = window.performance?.timing;
|
|
112
207
|
if (!t) return null;
|
|
113
|
-
return {
|
|
114
|
-
renderTime : t.domComplete - t.navigationStart,
|
|
115
|
-
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
|
|
116
|
-
};
|
|
208
|
+
return { renderTime: t.domComplete - t.navigationStart, domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart };
|
|
117
209
|
}).catch(() => null);
|
|
118
210
|
|
|
119
211
|
renderTime = timing?.renderTime;
|
|
120
212
|
domContentLoaded = timing?.domContentLoaded;
|
|
121
213
|
|
|
122
|
-
// Discover forms
|
|
123
214
|
const forms = await page.$$eval('form', els => els.map(f => ({
|
|
124
|
-
action: f.action,
|
|
125
|
-
method: f.method || 'GET',
|
|
126
|
-
id : f.id,
|
|
215
|
+
action: f.action, method: f.method || 'GET',
|
|
127
216
|
fields: Array.from(f.elements).map(el => ({
|
|
128
|
-
name
|
|
129
|
-
|
|
130
|
-
tagName : el.tagName.toLowerCase(),
|
|
131
|
-
required: el.required,
|
|
132
|
-
value : el.type === 'password' ? '[REDACTED]' : (el.value || ''),
|
|
217
|
+
name: el.name, type: el.type, required: el.required,
|
|
218
|
+
tagName: el.tagName?.toLowerCase(),
|
|
133
219
|
})).filter(f => f.name),
|
|
134
220
|
}))).catch(() => []);
|
|
135
221
|
|
|
136
|
-
//
|
|
137
|
-
const navLinks = await page.$$('nav a, header a
|
|
222
|
+
// Real interactions
|
|
223
|
+
const navLinks = await page.$$('nav a, header a').catch(() => []);
|
|
138
224
|
for (const link of navLinks.slice(0, 5)) {
|
|
139
225
|
try {
|
|
140
226
|
const href = await link.getAttribute('href');
|
|
@@ -145,173 +231,90 @@ export class BrowserInteractor {
|
|
|
145
231
|
} catch {}
|
|
146
232
|
}
|
|
147
233
|
|
|
148
|
-
// Try clicking buttons that don't submit forms
|
|
149
|
-
const buttons = await page.$$('button:not([type="submit"])').catch(() => []);
|
|
150
|
-
for (const btn of buttons.slice(0, 3)) {
|
|
151
|
-
try {
|
|
152
|
-
const text = await btn.textContent();
|
|
153
|
-
if (text?.trim()) {
|
|
154
|
-
await btn.click({ timeout: 2000 });
|
|
155
|
-
interactedElements.push({ type: 'click', tag: 'button', text: text.trim() });
|
|
156
|
-
await sleep(300);
|
|
157
|
-
}
|
|
158
|
-
} catch {}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
234
|
return {
|
|
162
|
-
pass,
|
|
163
|
-
failReason,
|
|
164
|
-
page,
|
|
235
|
+
pass, failReason, page,
|
|
165
236
|
loadTime : Date.now() - t0,
|
|
166
|
-
consoleErrors,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
renderTime,
|
|
172
|
-
domContentLoaded,
|
|
173
|
-
forms,
|
|
174
|
-
message: pass ? `Page loaded in ${Date.now() - t0}ms` : failReason,
|
|
237
|
+
consoleErrors, networkErrors, jsErrors,
|
|
238
|
+
resourcesFailed, interactedElements,
|
|
239
|
+
renderTime, domContentLoaded, forms,
|
|
240
|
+
message: pass ? `Loaded in ${Date.now() - t0}ms` : failReason,
|
|
241
|
+
mode : 'browser',
|
|
175
242
|
};
|
|
176
243
|
|
|
177
244
|
} catch (err) {
|
|
178
|
-
pass = false;
|
|
179
|
-
failReason = err.message;
|
|
180
|
-
|
|
181
245
|
return {
|
|
182
|
-
pass: false,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
networkErrors,
|
|
188
|
-
jsErrors,
|
|
189
|
-
resourcesFailed,
|
|
190
|
-
interactedElements,
|
|
191
|
-
forms : [],
|
|
192
|
-
message : err.message,
|
|
246
|
+
pass: false, failReason: err.message, page,
|
|
247
|
+
loadTime: Date.now() - t0,
|
|
248
|
+
consoleErrors, networkErrors, jsErrors,
|
|
249
|
+
resourcesFailed, interactedElements,
|
|
250
|
+
forms: [], message: err.message, mode: 'browser',
|
|
193
251
|
};
|
|
194
252
|
}
|
|
195
253
|
}
|
|
196
254
|
|
|
197
255
|
async testForm(page, form) {
|
|
198
|
-
|
|
199
|
-
const errors = [];
|
|
200
|
-
let validationOk = false;
|
|
201
|
-
let submissionOk = false;
|
|
256
|
+
if (this.#useFallback || !page) return this.#httpFallback.testForm(null, form);
|
|
202
257
|
|
|
258
|
+
const t0 = Date.now(), errors = [];
|
|
203
259
|
try {
|
|
204
|
-
// Fill form fields with test data
|
|
205
260
|
for (const field of form.fields) {
|
|
206
261
|
try {
|
|
207
|
-
|
|
208
|
-
? `[name="${field.name}"]`
|
|
209
|
-
: `#${field.id}`;
|
|
210
|
-
|
|
211
|
-
const testValue = this.#generateTestValue(field);
|
|
212
|
-
await page.fill(selector, testValue, { timeout: 3000 });
|
|
262
|
+
await page.fill(`[name="${field.name}"]`, this.#testValue(field), { timeout: 3000 });
|
|
213
263
|
} catch {}
|
|
214
264
|
}
|
|
265
|
+
const btn = await page.$('[type="submit"]');
|
|
266
|
+
if (btn) { await btn.click(); await sleep(1000); }
|
|
215
267
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await sleep(1000);
|
|
221
|
-
|
|
222
|
-
// Check for validation messages
|
|
223
|
-
const validationMsgs = await page.$$eval(
|
|
224
|
-
'[class*="error"], [class*="invalid"], [aria-invalid="true"], .help-block',
|
|
225
|
-
els => els.map(e => e.textContent?.trim()).filter(Boolean)
|
|
226
|
-
).catch(() => []);
|
|
227
|
-
|
|
228
|
-
validationOk = validationMsgs.length > 0 || (form.fields.some(f => f.required));
|
|
229
|
-
submissionOk = true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
pass : validationOk,
|
|
234
|
-
validationOk,
|
|
235
|
-
submissionOk,
|
|
236
|
-
errors,
|
|
237
|
-
duration : Date.now() - t0,
|
|
238
|
-
message : validationOk ? 'Form validation works' : 'Form may lack validation',
|
|
239
|
-
};
|
|
268
|
+
const msgs = await page.$$eval(
|
|
269
|
+
'[class*="error"],[class*="invalid"],[aria-invalid="true"]',
|
|
270
|
+
els => els.map(e => e.textContent?.trim()).filter(Boolean)
|
|
271
|
+
).catch(() => []);
|
|
240
272
|
|
|
273
|
+
return { pass: true, validationOk: msgs.length > 0, submissionOk: true, errors, duration: Date.now() - t0, message: 'Form tested' };
|
|
241
274
|
} catch (err) {
|
|
242
275
|
errors.push(err.message);
|
|
243
|
-
return { pass: false, validationOk, submissionOk, errors, duration: Date.now() - t0, message: err.message };
|
|
276
|
+
return { pass: false, validationOk: false, submissionOk: false, errors, duration: Date.now() - t0, message: err.message };
|
|
244
277
|
}
|
|
245
278
|
}
|
|
246
279
|
|
|
247
|
-
async testAuthFlow(page, url,
|
|
248
|
-
|
|
249
|
-
const details = [];
|
|
250
|
-
let pass = true;
|
|
280
|
+
async testAuthFlow(page, url, opts) {
|
|
281
|
+
if (this.#useFallback || !page) return this.#httpFallback.testAuthFlow(null, url, opts);
|
|
251
282
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
283
|
+
const t0 = Date.now(), details = [];
|
|
284
|
+
let pass = true;
|
|
255
285
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
await
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const currentUrl = page.url();
|
|
277
|
-
const bodyText = await page.textContent('body').catch(() => '');
|
|
278
|
-
|
|
279
|
-
// Check if invalid credentials were rejected
|
|
280
|
-
if (cred.expectFail) {
|
|
281
|
-
const wasRejected = /invalid|incorrect|error|wrong|fail/i.test(bodyText) ||
|
|
282
|
-
currentUrl === url || currentUrl.includes('login') || currentUrl.includes('error');
|
|
283
|
-
details.push({
|
|
284
|
-
credentials: { username: cred.username.slice(0, 8) + '...' },
|
|
285
|
-
expectedFail: true,
|
|
286
|
-
wasRejected,
|
|
287
|
-
currentUrl,
|
|
288
|
-
});
|
|
289
|
-
if (!wasRejected) {
|
|
290
|
-
pass = false;
|
|
291
|
-
details.push({ warning: 'Weak credentials were accepted — auth may be broken' });
|
|
292
|
-
}
|
|
293
|
-
}
|
|
286
|
+
for (const cred of (opts.testCredentials || [])) {
|
|
287
|
+
try {
|
|
288
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
289
|
+
const uf = await page.$('input[type="email"],input[name="email"],input[name="username"]');
|
|
290
|
+
const pf = await page.$('input[type="password"]');
|
|
291
|
+
if (!uf || !pf) { details.push({ note: 'Login fields not found' }); continue; }
|
|
292
|
+
await uf.fill(cred.username);
|
|
293
|
+
await pf.fill(cred.password);
|
|
294
|
+
const btn = await page.$('[type="submit"]');
|
|
295
|
+
if (btn) { await btn.click(); await page.waitForTimeout(2000); }
|
|
296
|
+
|
|
297
|
+
const body = await page.textContent('body').catch(() => '');
|
|
298
|
+
const curUrl = page.url();
|
|
299
|
+
const rejected = /invalid|incorrect|error|wrong|fail/i.test(body) || curUrl.includes('login');
|
|
300
|
+
|
|
301
|
+
details.push({ credentials: cred.username.slice(0, 8) + '...', expectFail: cred.expectFail, wasRejected: rejected, currentUrl: curUrl });
|
|
302
|
+
if (cred.expectFail && !rejected) { pass = false; }
|
|
303
|
+
} catch (err) {
|
|
304
|
+
details.push({ error: err.message });
|
|
294
305
|
}
|
|
295
|
-
|
|
296
|
-
return { pass, details, duration: Date.now() - t0, message: pass ? 'Auth flow validated' : 'Auth issues detected' };
|
|
297
|
-
|
|
298
|
-
} catch (err) {
|
|
299
|
-
return { pass: false, details, duration: Date.now() - t0, message: err.message };
|
|
300
306
|
}
|
|
307
|
+
return { pass, details, duration: Date.now() - t0, message: pass ? 'Auth flow validated' : 'Auth issues detected' };
|
|
301
308
|
}
|
|
302
309
|
|
|
303
|
-
#
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
if (
|
|
307
|
-
if (
|
|
308
|
-
if (
|
|
309
|
-
if (
|
|
310
|
-
if (
|
|
311
|
-
if (name.includes('name')) return 'QA Test User';
|
|
312
|
-
if (name.includes('address')) return '123 QA Street';
|
|
313
|
-
if (name.includes('city')) return 'Test City';
|
|
314
|
-
if (name.includes('zip') || name.includes('post')) return '10001';
|
|
310
|
+
#testValue(field) {
|
|
311
|
+
const t = (field.type || 'text').toLowerCase();
|
|
312
|
+
const n = (field.name || '').toLowerCase();
|
|
313
|
+
if (t === 'email' || n.includes('email')) return 'test@backlist-qa.dev';
|
|
314
|
+
if (t === 'password' || n.includes('pass')) return 'TestPass123!';
|
|
315
|
+
if (t === 'tel' || n.includes('phone')) return '+1-555-000-0000';
|
|
316
|
+
if (t === 'number') return '42';
|
|
317
|
+
if (n.includes('name')) return 'QA Test User';
|
|
315
318
|
return 'backlist-qa-test';
|
|
316
319
|
}
|
|
317
320
|
}
|
package/src/qa/qa-engine.js
CHANGED
|
@@ -117,29 +117,70 @@ export class QAEngine extends EventEmitter {
|
|
|
117
117
|
this.#aiClassifier = new AIClassifier();
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
120
|
+
// Replace the init() method in QAEngine class
|
|
121
|
+
|
|
122
|
+
async init() {
|
|
123
|
+
// Dynamic import Playwright — optional
|
|
124
|
+
let playwright = null;
|
|
125
|
+
try {
|
|
126
|
+
playwright = await import('playwright');
|
|
127
|
+
} catch {
|
|
128
|
+
// Will use HTTP fallback throughout
|
|
129
|
+
}
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
});
|
|
138
158
|
|
|
139
|
-
|
|
140
|
-
|
|
159
|
+
if (shouldInstall) {
|
|
160
|
+
const { installPlaywrightBrowsers } = await import('./browser/installer.js');
|
|
161
|
+
const result = await installPlaywrightBrowsers();
|
|
162
|
+
if (!result.success) {
|
|
163
|
+
console.log(chalk.yellow(' Auto-install failed. Continuing in HTTP-only mode.\n'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
console.log(chalk.gray(` ✓ Browser ready (${launchOpts.source}: ${launchOpts.executablePath?.split(/[/\\]/).pop()})`));
|
|
141
168
|
}
|
|
142
169
|
|
|
170
|
+
this.#crawler = new SmartCrawler(playwright);
|
|
171
|
+
this.#interactor = new BrowserInteractor(playwright, this.#session);
|
|
172
|
+
this.#apiValidator = new RealAPIValidator(this.#session);
|
|
173
|
+
this.#security = new SecurityScanner(this.#session);
|
|
174
|
+
this.#performance = new PerformanceProfiler(this.#session);
|
|
175
|
+
this.#a11y = new AccessibilityChecker(playwright, this.#session);
|
|
176
|
+
this.#seo = new SEOScanner(this.#session);
|
|
177
|
+
this.#screenshotter = new ScreenshotCapture(SCREENSHOT_DIR);
|
|
178
|
+
this.#aiClassifier = new AIClassifier();
|
|
179
|
+
|
|
180
|
+
await this.#interactor.launch();
|
|
181
|
+
await this.#screenshotter.init();
|
|
182
|
+
}
|
|
183
|
+
|
|
143
184
|
async run() {
|
|
144
185
|
this.#terminal.start();
|
|
145
186
|
this.emit('session:start', this.#session);
|