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.
@@ -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
+ }