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.
@@ -1,27 +1,151 @@
1
- // Real browser interactions click, type, submit, verify
2
- import { shortId, sleep } from '../qa-engine.js';
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 = null;
8
- #context = null;
113
+ #browser = null;
114
+ #context = null;
115
+ #launchOpts = null;
116
+ #httpFallback;
117
+ #useFallback = false;
9
118
 
10
119
  constructor(playwright, session) {
11
- this.#playwright = playwright;
12
- this.#session = session;
120
+ this.#playwright = playwright;
121
+ this.#session = session;
122
+ this.#httpFallback = new HTTPPageTester();
13
123
  }
14
124
 
15
125
  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
- });
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
- async testPage(url, { onConsoleError, onNetworkEvent } = {}) {
33
- const page = await this.#context.newPage();
34
- const consoleErrors = [];
35
- const networkErrors = [];
36
- const jsErrors = [];
37
- const resourcesFailed = [];
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
- onNetworkEvent?.({ type: 'failed', ...entry });
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
- 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
- });
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
- waitUntil: 'networkidle',
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
- 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
- }
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 : el.name,
129
- type : el.type,
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
- // Interact: try clicking navigation links
137
- const navLinks = await page.$$('nav a, header a, [role="navigation"] a').catch(() => []);
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
- networkErrors,
168
- jsErrors,
169
- resourcesFailed,
170
- interactedElements,
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
- failReason,
184
- page,
185
- loadTime : Date.now() - t0,
186
- consoleErrors,
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
- const t0 = Date.now();
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
- 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 });
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
- // 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
- };
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, { testCredentials = [] } = {}) {
248
- const t0 = Date.now();
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
- try {
253
- for (const cred of testCredentials) {
254
- await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
283
+ const t0 = Date.now(), details = [];
284
+ let pass = true;
255
285
 
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
- }
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
- #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';
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
  }
@@ -117,29 +117,70 @@ export class QAEngine extends EventEmitter {
117
117
  this.#aiClassifier = new AIClassifier();
118
118
  }
119
119
 
120
- async init() {
121
- // Dynamically import Playwright — optional peer dependency
122
- let playwright;
123
- try {
124
- playwright = await import('playwright');
125
- } catch {
126
- throw new Error(
127
- 'Playwright not installed. Run: npm install playwright && npx playwright install chromium'
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
- this.#crawler = new SmartCrawler(playwright);
132
- this.#interactor = new BrowserInteractor(playwright, this.#session);
133
- this.#apiValidator = new RealAPIValidator(this.#session);
134
- this.#security = new SecurityScanner(this.#session);
135
- this.#performance = new PerformanceProfiler(this.#session);
136
- this.#a11y = new AccessibilityChecker(playwright, this.#session);
137
- this.#seo = new SEOScanner(this.#session);
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
- await this.#interactor.launch();
140
- await this.#screenshotter.init();
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);