create-backlist 10.0.3 → 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.
@@ -0,0 +1,320 @@
1
+ // Real browser interactions with HTTP fallback
2
+ import { shortId, sleep } from '../qa-engine.js';
3
+ import { getBrowserLaunchOptions } from './installer.js';
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) ───────────────────────────────
110
+ export class BrowserInteractor {
111
+ #playwright;
112
+ #session;
113
+ #browser = null;
114
+ #context = null;
115
+ #launchOpts = null;
116
+ #httpFallback;
117
+ #useFallback = false;
118
+
119
+ constructor(playwright, session) {
120
+ this.#playwright = playwright;
121
+ this.#session = session;
122
+ this.#httpFallback = new HTTPPageTester();
123
+ }
124
+
125
+ async launch() {
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
+ }
149
+ }
150
+
151
+ async close() {
152
+ await this.#context?.close().catch(() => {});
153
+ await this.#browser?.close().catch(() => {});
154
+ }
155
+
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 = [];
168
+ const interactedElements = [];
169
+
170
+ page.on('console', msg => {
171
+ if (msg.type() === 'error' || msg.type() === 'warning') {
172
+ const entry = { type: msg.type(), text: msg.text(), timestamp: Date.now() };
173
+ consoleErrors.push(entry);
174
+ opts.onConsoleError?.(entry);
175
+ }
176
+ });
177
+
178
+ page.on('pageerror', err => {
179
+ const entry = { message: err.message, stack: err.stack, timestamp: Date.now() };
180
+ jsErrors.push(entry);
181
+ opts.onConsoleError?.({ type: 'pageerror', text: err.message, stack: err.stack });
182
+ });
183
+
184
+ page.on('requestfailed', req => {
185
+ const entry = { url: req.url(), method: req.method(), failure: req.failure()?.errorText || 'unknown', timestamp: Date.now() };
186
+ networkErrors.push(entry);
187
+ resourcesFailed.push({ url: req.url(), type: req.resourceType(), failure: entry.failure });
188
+ opts.onNetworkEvent?.({ type: 'failed', ...entry });
189
+ });
190
+
191
+ page.on('response', async res => {
192
+ opts.onNetworkEvent?.({ type: 'response', url: res.url(), status: res.status() });
193
+ });
194
+
195
+ const t0 = Date.now();
196
+ let pass = true, failReason = null, renderTime = null, domContentLoaded = null;
197
+
198
+ try {
199
+ const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
200
+ const status = response?.status() || 0;
201
+
202
+ if (status >= 500) { pass = false; failReason = `Server error: HTTP ${status}`; }
203
+ else if (status >= 400) { pass = false; failReason = `Client error: HTTP ${status}`; }
204
+
205
+ const timing = await page.evaluate(() => {
206
+ const t = window.performance?.timing;
207
+ if (!t) return null;
208
+ return { renderTime: t.domComplete - t.navigationStart, domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart };
209
+ }).catch(() => null);
210
+
211
+ renderTime = timing?.renderTime;
212
+ domContentLoaded = timing?.domContentLoaded;
213
+
214
+ const forms = await page.$$eval('form', els => els.map(f => ({
215
+ action: f.action, method: f.method || 'GET',
216
+ fields: Array.from(f.elements).map(el => ({
217
+ name: el.name, type: el.type, required: el.required,
218
+ tagName: el.tagName?.toLowerCase(),
219
+ })).filter(f => f.name),
220
+ }))).catch(() => []);
221
+
222
+ // Real interactions
223
+ const navLinks = await page.$$('nav a, header a').catch(() => []);
224
+ for (const link of navLinks.slice(0, 5)) {
225
+ try {
226
+ const href = await link.getAttribute('href');
227
+ if (href && !href.startsWith('mailto:') && !href.startsWith('tel:')) {
228
+ await link.hover();
229
+ interactedElements.push({ type: 'hover', tag: 'a', href });
230
+ }
231
+ } catch {}
232
+ }
233
+
234
+ return {
235
+ pass, failReason, page,
236
+ loadTime : Date.now() - t0,
237
+ consoleErrors, networkErrors, jsErrors,
238
+ resourcesFailed, interactedElements,
239
+ renderTime, domContentLoaded, forms,
240
+ message: pass ? `Loaded in ${Date.now() - t0}ms` : failReason,
241
+ mode : 'browser',
242
+ };
243
+
244
+ } catch (err) {
245
+ return {
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',
251
+ };
252
+ }
253
+ }
254
+
255
+ async testForm(page, form) {
256
+ if (this.#useFallback || !page) return this.#httpFallback.testForm(null, form);
257
+
258
+ const t0 = Date.now(), errors = [];
259
+ try {
260
+ for (const field of form.fields) {
261
+ try {
262
+ await page.fill(`[name="${field.name}"]`, this.#testValue(field), { timeout: 3000 });
263
+ } catch {}
264
+ }
265
+ const btn = await page.$('[type="submit"]');
266
+ if (btn) { await btn.click(); await sleep(1000); }
267
+
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(() => []);
272
+
273
+ return { pass: true, validationOk: msgs.length > 0, submissionOk: true, errors, duration: Date.now() - t0, message: 'Form tested' };
274
+ } catch (err) {
275
+ errors.push(err.message);
276
+ return { pass: false, validationOk: false, submissionOk: false, errors, duration: Date.now() - t0, message: err.message };
277
+ }
278
+ }
279
+
280
+ async testAuthFlow(page, url, opts) {
281
+ if (this.#useFallback || !page) return this.#httpFallback.testAuthFlow(null, url, opts);
282
+
283
+ const t0 = Date.now(), details = [];
284
+ let pass = true;
285
+
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 });
305
+ }
306
+ }
307
+ return { pass, details, duration: Date.now() - t0, message: pass ? 'Auth flow validated' : 'Auth issues detected' };
308
+ }
309
+
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';
318
+ return 'backlist-qa-test';
319
+ }
320
+ }
@@ -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
+ }