create-backlist 10.0.2 → 10.0.4
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/qa.js +138 -183
- package/package.json +6 -1
- package/src/qa/analyzers/accessibility.js +81 -0
- package/src/qa/analyzers/api.js +125 -0
- package/src/qa/analyzers/performance.js +137 -0
- package/src/qa/analyzers/security.js +207 -0
- package/src/qa/analyzers/seo.js +248 -0
- package/src/qa/browser/crawler.js +223 -0
- package/src/qa/browser/interactions.js +317 -0
- package/src/qa/browser/screenshot.js +34 -0
- package/src/qa/qa-engine.js +748 -1286
- package/src/qa/reporters/html.js +623 -0
- package/src/qa/reporters/json.js +49 -0
- package/src/qa/reporters/terminal.js +184 -0
- package/src/qa/utils/ai-classifier.js +98 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Real smart crawler — discovers all routes via Playwright
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { shortId } from '../qa-engine.js';
|
|
4
|
+
|
|
5
|
+
export class SmartCrawler {
|
|
6
|
+
#playwright;
|
|
7
|
+
#visited = new Set();
|
|
8
|
+
#queue = [];
|
|
9
|
+
#routes = [];
|
|
10
|
+
|
|
11
|
+
constructor(playwright) {
|
|
12
|
+
this.#playwright = playwright;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async crawl(baseUrl, { maxPages = 60, maxDepth = 4, onRoute } = {}) {
|
|
16
|
+
this.#visited.clear();
|
|
17
|
+
this.#queue = [{ url: baseUrl, depth: 0 }];
|
|
18
|
+
this.#routes = [];
|
|
19
|
+
|
|
20
|
+
const browser = await this.#playwright.chromium.launch({ headless: true });
|
|
21
|
+
const context = await browser.newContext({
|
|
22
|
+
userAgent: 'Backlist-QA-Crawler/12.0',
|
|
23
|
+
ignoreHTTPSErrors: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
while (this.#queue.length > 0 && this.#routes.length < maxPages) {
|
|
28
|
+
const { url, depth } = this.#queue.shift();
|
|
29
|
+
const normalized = this.#normalizeUrl(url);
|
|
30
|
+
|
|
31
|
+
if (!normalized || this.#visited.has(normalized)) continue;
|
|
32
|
+
if (!this.#isSameOrigin(normalized, baseUrl)) continue;
|
|
33
|
+
if (depth > maxDepth) continue;
|
|
34
|
+
|
|
35
|
+
this.#visited.add(normalized);
|
|
36
|
+
|
|
37
|
+
const route = await this.#probePage(context, normalized, depth, baseUrl);
|
|
38
|
+
if (!route) continue;
|
|
39
|
+
|
|
40
|
+
this.#routes.push(route);
|
|
41
|
+
if (onRoute) onRoute(route);
|
|
42
|
+
|
|
43
|
+
// Enqueue discovered links
|
|
44
|
+
for (const link of route.links || []) {
|
|
45
|
+
const linkUrl = this.#normalizeUrl(link);
|
|
46
|
+
if (linkUrl && !this.#visited.has(linkUrl)) {
|
|
47
|
+
this.#queue.push({ url: linkUrl, depth: depth + 1 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
await context.close();
|
|
53
|
+
await browser.close();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Also discover APIs from network intercepts
|
|
57
|
+
const apiRoutes = await this.#discoverAPIs(baseUrl);
|
|
58
|
+
for (const api of apiRoutes) {
|
|
59
|
+
if (!this.#routes.find(r => r.url === api.url)) {
|
|
60
|
+
this.#routes.push(api);
|
|
61
|
+
if (onRoute) onRoute(api);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return this.#routes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async #probePage(context, url, depth, baseUrl) {
|
|
69
|
+
const page = await context.newPage();
|
|
70
|
+
const networkRequests = [];
|
|
71
|
+
const links = new Set();
|
|
72
|
+
|
|
73
|
+
page.on('request', req => {
|
|
74
|
+
if (req.url().includes('/api/') || req.resourceType() === 'fetch') {
|
|
75
|
+
networkRequests.push({
|
|
76
|
+
url : req.url(),
|
|
77
|
+
method: req.method(),
|
|
78
|
+
type : 'api',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const response = await page.goto(url, {
|
|
85
|
+
waitUntil: 'networkidle',
|
|
86
|
+
timeout : 15_000,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const status = response?.status() || 0;
|
|
90
|
+
const contentType = response?.headers()['content-type'] || '';
|
|
91
|
+
|
|
92
|
+
// Skip non-HTML
|
|
93
|
+
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
|
94
|
+
const type = this.#detectResourceType(url, contentType);
|
|
95
|
+
return { id: shortId(), url, type, status, depth, links: [], forms: [], apis: [] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Extract all links
|
|
99
|
+
const hrefs = await page.$$eval('a[href]', els =>
|
|
100
|
+
els.map(el => el.href).filter(Boolean)
|
|
101
|
+
).catch(() => []);
|
|
102
|
+
hrefs.forEach(h => links.add(h));
|
|
103
|
+
|
|
104
|
+
// Extract forms
|
|
105
|
+
const forms = await page.$$eval('form', els => els.map(f => ({
|
|
106
|
+
action: f.action,
|
|
107
|
+
method: f.method || 'GET',
|
|
108
|
+
fields : Array.from(f.elements).map(el => ({
|
|
109
|
+
name : el.name,
|
|
110
|
+
type : el.type,
|
|
111
|
+
required: el.required,
|
|
112
|
+
})).filter(f => f.name),
|
|
113
|
+
}))).catch(() => []);
|
|
114
|
+
|
|
115
|
+
// Detect page type
|
|
116
|
+
const type = this.#detectPageType(url, status);
|
|
117
|
+
|
|
118
|
+
// Add API routes discovered via network
|
|
119
|
+
for (const req of networkRequests) {
|
|
120
|
+
if (this.#isSameOrigin(req.url, baseUrl)) {
|
|
121
|
+
links.add(req.url);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id : shortId(),
|
|
127
|
+
url,
|
|
128
|
+
type,
|
|
129
|
+
status,
|
|
130
|
+
depth,
|
|
131
|
+
links: [...links],
|
|
132
|
+
forms,
|
|
133
|
+
apis : networkRequests,
|
|
134
|
+
contentType,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return {
|
|
139
|
+
id : shortId(),
|
|
140
|
+
url,
|
|
141
|
+
type : 'error',
|
|
142
|
+
status: 0,
|
|
143
|
+
depth,
|
|
144
|
+
links: [],
|
|
145
|
+
forms: [],
|
|
146
|
+
error: err.message,
|
|
147
|
+
};
|
|
148
|
+
} finally {
|
|
149
|
+
await page.close().catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #discoverAPIs(baseUrl) {
|
|
154
|
+
const browser = await this.#playwright.chromium.launch({ headless: true });
|
|
155
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
156
|
+
const page = await context.newPage();
|
|
157
|
+
const apis = new Set();
|
|
158
|
+
|
|
159
|
+
page.on('request', req => {
|
|
160
|
+
const url = req.url();
|
|
161
|
+
if (
|
|
162
|
+
(url.includes('/api/') || url.includes('/graphql') || url.includes('/rest/')) &&
|
|
163
|
+
this.#isSameOrigin(url, baseUrl)
|
|
164
|
+
) {
|
|
165
|
+
apis.add(JSON.stringify({ url, method: req.method() }));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 20_000 });
|
|
171
|
+
// Interact briefly to trigger more API calls
|
|
172
|
+
await page.waitForTimeout(2000);
|
|
173
|
+
} catch {}
|
|
174
|
+
|
|
175
|
+
await page.close();
|
|
176
|
+
await context.close();
|
|
177
|
+
await browser.close();
|
|
178
|
+
|
|
179
|
+
return [...apis].map(s => {
|
|
180
|
+
const parsed = JSON.parse(s);
|
|
181
|
+
return { id: shortId(), url: parsed.url, method: parsed.method, type: 'api' };
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#normalizeUrl(url) {
|
|
186
|
+
try {
|
|
187
|
+
const u = new URL(url);
|
|
188
|
+
u.hash = '';
|
|
189
|
+
return u.toString().replace(/\/$/, '') || '/';
|
|
190
|
+
} catch { return null; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#isSameOrigin(url, base) {
|
|
194
|
+
try {
|
|
195
|
+
return new URL(url).origin === new URL(base).origin;
|
|
196
|
+
} catch { return false; }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#detectPageType(url, status) {
|
|
200
|
+
if (status === 0) return 'error';
|
|
201
|
+
if (status >= 400) return 'error-page';
|
|
202
|
+
const u = url.toLowerCase();
|
|
203
|
+
if (u.includes('/api/')) return 'api';
|
|
204
|
+
if (u.includes('/graphql')) return 'api';
|
|
205
|
+
if (u.endsWith('.json')) return 'api';
|
|
206
|
+
if (u.endsWith('.xml')) return 'resource';
|
|
207
|
+
if (u.endsWith('.txt')) return 'resource';
|
|
208
|
+
if (/\/(login|signin|auth)/i.test(u)) return 'auth';
|
|
209
|
+
if (/\/(register|signup)/i.test(u)) return 'auth';
|
|
210
|
+
if (/\/(admin)/i.test(u)) return 'admin';
|
|
211
|
+
if (/\/(dashboard)/i.test(u)) return 'dashboard';
|
|
212
|
+
return 'page';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#detectResourceType(url, ct) {
|
|
216
|
+
if (ct.includes('json')) return 'api';
|
|
217
|
+
if (ct.includes('javascript')) return 'script';
|
|
218
|
+
if (ct.includes('css')) return 'style';
|
|
219
|
+
if (ct.includes('image')) return 'image';
|
|
220
|
+
if (url.endsWith('.xml')) return 'resource';
|
|
221
|
+
return 'unknown';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Real browser interactions — click, type, submit, verify
|
|
2
|
+
import { shortId, sleep } from '../qa-engine.js';
|
|
3
|
+
|
|
4
|
+
export class BrowserInteractor {
|
|
5
|
+
#playwright;
|
|
6
|
+
#session;
|
|
7
|
+
#browser = null;
|
|
8
|
+
#context = null;
|
|
9
|
+
|
|
10
|
+
constructor(playwright, session) {
|
|
11
|
+
this.#playwright = playwright;
|
|
12
|
+
this.#session = session;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async launch() {
|
|
16
|
+
this.#browser = await this.#playwright.chromium.launch({
|
|
17
|
+
headless: true,
|
|
18
|
+
args : ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
19
|
+
});
|
|
20
|
+
this.#context = await this.#browser.newContext({
|
|
21
|
+
viewport : { width: 1280, height: 800 },
|
|
22
|
+
ignoreHTTPSErrors: true,
|
|
23
|
+
recordVideo : undefined,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async close() {
|
|
28
|
+
await this.#context?.close().catch(() => {});
|
|
29
|
+
await this.#browser?.close().catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async testPage(url, { onConsoleError, onNetworkEvent } = {}) {
|
|
33
|
+
const page = await this.#context.newPage();
|
|
34
|
+
const consoleErrors = [];
|
|
35
|
+
const networkErrors = [];
|
|
36
|
+
const jsErrors = [];
|
|
37
|
+
const resourcesFailed = [];
|
|
38
|
+
const interactedElements = [];
|
|
39
|
+
|
|
40
|
+
// Real console capture
|
|
41
|
+
page.on('console', msg => {
|
|
42
|
+
if (msg.type() === 'error' || msg.type() === 'warning') {
|
|
43
|
+
const entry = { type: msg.type(), text: msg.text(), timestamp: Date.now() };
|
|
44
|
+
consoleErrors.push(entry);
|
|
45
|
+
onConsoleError?.(entry);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Real JS error capture
|
|
50
|
+
page.on('pageerror', err => {
|
|
51
|
+
const entry = { message: err.message, stack: err.stack, timestamp: Date.now() };
|
|
52
|
+
jsErrors.push(entry);
|
|
53
|
+
onConsoleError?.({ type: 'pageerror', text: err.message, stack: err.stack });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Real network failure capture
|
|
57
|
+
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
|
+
};
|
|
64
|
+
networkErrors.push(entry);
|
|
65
|
+
onNetworkEvent?.({ type: 'failed', ...entry });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// All network requests
|
|
69
|
+
page.on('response', async res => {
|
|
70
|
+
const status = res.status();
|
|
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
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const t0 = Date.now();
|
|
89
|
+
let pass = true;
|
|
90
|
+
let failReason = null;
|
|
91
|
+
let renderTime = null;
|
|
92
|
+
let domContentLoaded = null;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await page.goto(url, {
|
|
96
|
+
waitUntil: 'networkidle',
|
|
97
|
+
timeout : 20_000,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const status = response?.status() || 0;
|
|
101
|
+
if (status >= 500) {
|
|
102
|
+
pass = false;
|
|
103
|
+
failReason = `Server error: HTTP ${status}`;
|
|
104
|
+
} else if (status >= 400) {
|
|
105
|
+
pass = false;
|
|
106
|
+
failReason = `Client error: HTTP ${status}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Real timing metrics
|
|
110
|
+
const timing = await page.evaluate(() => {
|
|
111
|
+
const t = window.performance?.timing;
|
|
112
|
+
if (!t) return null;
|
|
113
|
+
return {
|
|
114
|
+
renderTime : t.domComplete - t.navigationStart,
|
|
115
|
+
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
|
|
116
|
+
};
|
|
117
|
+
}).catch(() => null);
|
|
118
|
+
|
|
119
|
+
renderTime = timing?.renderTime;
|
|
120
|
+
domContentLoaded = timing?.domContentLoaded;
|
|
121
|
+
|
|
122
|
+
// Discover forms
|
|
123
|
+
const forms = await page.$$eval('form', els => els.map(f => ({
|
|
124
|
+
action: f.action,
|
|
125
|
+
method: f.method || 'GET',
|
|
126
|
+
id : f.id,
|
|
127
|
+
fields: Array.from(f.elements).map(el => ({
|
|
128
|
+
name : el.name,
|
|
129
|
+
type : el.type,
|
|
130
|
+
tagName : el.tagName.toLowerCase(),
|
|
131
|
+
required: el.required,
|
|
132
|
+
value : el.type === 'password' ? '[REDACTED]' : (el.value || ''),
|
|
133
|
+
})).filter(f => f.name),
|
|
134
|
+
}))).catch(() => []);
|
|
135
|
+
|
|
136
|
+
// Interact: try clicking navigation links
|
|
137
|
+
const navLinks = await page.$$('nav a, header a, [role="navigation"] a').catch(() => []);
|
|
138
|
+
for (const link of navLinks.slice(0, 5)) {
|
|
139
|
+
try {
|
|
140
|
+
const href = await link.getAttribute('href');
|
|
141
|
+
if (href && !href.startsWith('mailto:') && !href.startsWith('tel:')) {
|
|
142
|
+
await link.hover();
|
|
143
|
+
interactedElements.push({ type: 'hover', tag: 'a', href });
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
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
|
+
return {
|
|
162
|
+
pass,
|
|
163
|
+
failReason,
|
|
164
|
+
page,
|
|
165
|
+
loadTime : Date.now() - t0,
|
|
166
|
+
consoleErrors,
|
|
167
|
+
networkErrors,
|
|
168
|
+
jsErrors,
|
|
169
|
+
resourcesFailed,
|
|
170
|
+
interactedElements,
|
|
171
|
+
renderTime,
|
|
172
|
+
domContentLoaded,
|
|
173
|
+
forms,
|
|
174
|
+
message: pass ? `Page loaded in ${Date.now() - t0}ms` : failReason,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
} catch (err) {
|
|
178
|
+
pass = false;
|
|
179
|
+
failReason = err.message;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
pass: false,
|
|
183
|
+
failReason,
|
|
184
|
+
page,
|
|
185
|
+
loadTime : Date.now() - t0,
|
|
186
|
+
consoleErrors,
|
|
187
|
+
networkErrors,
|
|
188
|
+
jsErrors,
|
|
189
|
+
resourcesFailed,
|
|
190
|
+
interactedElements,
|
|
191
|
+
forms : [],
|
|
192
|
+
message : err.message,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async testForm(page, form) {
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
const errors = [];
|
|
200
|
+
let validationOk = false;
|
|
201
|
+
let submissionOk = false;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
// Fill form fields with test data
|
|
205
|
+
for (const field of form.fields) {
|
|
206
|
+
try {
|
|
207
|
+
const selector = field.name
|
|
208
|
+
? `[name="${field.name}"]`
|
|
209
|
+
: `#${field.id}`;
|
|
210
|
+
|
|
211
|
+
const testValue = this.#generateTestValue(field);
|
|
212
|
+
await page.fill(selector, testValue, { timeout: 3000 });
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Test empty submission (validation check)
|
|
217
|
+
const submitBtn = await page.$('[type="submit"], button[type="submit"]');
|
|
218
|
+
if (submitBtn) {
|
|
219
|
+
await submitBtn.click();
|
|
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
|
+
};
|
|
240
|
+
|
|
241
|
+
} catch (err) {
|
|
242
|
+
errors.push(err.message);
|
|
243
|
+
return { pass: false, validationOk, submissionOk, errors, duration: Date.now() - t0, message: err.message };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async testAuthFlow(page, url, { testCredentials = [] } = {}) {
|
|
248
|
+
const t0 = Date.now();
|
|
249
|
+
const details = [];
|
|
250
|
+
let pass = true;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
for (const cred of testCredentials) {
|
|
254
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
255
|
+
|
|
256
|
+
// Find email/username field
|
|
257
|
+
const usernameField = await page.$(
|
|
258
|
+
'input[type="email"], input[name="email"], input[name="username"], input[type="text"]'
|
|
259
|
+
);
|
|
260
|
+
const passwordField = await page.$('input[type="password"]');
|
|
261
|
+
|
|
262
|
+
if (!usernameField || !passwordField) {
|
|
263
|
+
details.push({ note: 'Could not find login fields' });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await usernameField.fill(cred.username);
|
|
268
|
+
await passwordField.fill(cred.password);
|
|
269
|
+
|
|
270
|
+
const submitBtn = await page.$('[type="submit"], button[type="submit"]');
|
|
271
|
+
if (submitBtn) {
|
|
272
|
+
await submitBtn.click();
|
|
273
|
+
await page.waitForTimeout(2000);
|
|
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
|
+
}
|
|
294
|
+
}
|
|
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
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#generateTestValue(field) {
|
|
304
|
+
const type = (field.type || 'text').toLowerCase();
|
|
305
|
+
const name = (field.name || '').toLowerCase();
|
|
306
|
+
if (type === 'email' || name.includes('email')) return 'test@backlist-qa.dev';
|
|
307
|
+
if (type === 'password' || name.includes('pass')) return 'TestPass123!';
|
|
308
|
+
if (type === 'tel' || name.includes('phone')) return '+1-555-000-0000';
|
|
309
|
+
if (type === 'url' || name.includes('url')) return 'https://example.com';
|
|
310
|
+
if (type === 'number') return '42';
|
|
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';
|
|
315
|
+
return 'backlist-qa-test';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { timestamp } from '../qa-engine.js';
|
|
4
|
+
|
|
5
|
+
export class ScreenshotCapture {
|
|
6
|
+
#dir;
|
|
7
|
+
|
|
8
|
+
constructor(dir) { this.#dir = dir; }
|
|
9
|
+
|
|
10
|
+
async init() {
|
|
11
|
+
await fs.ensureDir(this.#dir);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async capture(page, label = 'shot') {
|
|
15
|
+
if (!page) return null;
|
|
16
|
+
try {
|
|
17
|
+
const filename = `${label}-${Date.now()}.png`;
|
|
18
|
+
const filepath = path.join(this.#dir, filename);
|
|
19
|
+
await page.screenshot({ path: filepath, fullPage: true, timeout: 8000 });
|
|
20
|
+
return filepath;
|
|
21
|
+
} catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async captureElement(page, selector, label) {
|
|
25
|
+
try {
|
|
26
|
+
const el = await page.$(selector);
|
|
27
|
+
if (!el) return null;
|
|
28
|
+
const filename = `${label}-element-${Date.now()}.png`;
|
|
29
|
+
const filepath = path.join(this.#dir, filename);
|
|
30
|
+
await el.screenshot({ path: filepath });
|
|
31
|
+
return filepath;
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
}
|