qa360 2.0.11 → 2.0.13

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.
Files changed (42) hide show
  1. package/dist/commands/ai.js +26 -14
  2. package/dist/commands/ask.d.ts +75 -23
  3. package/dist/commands/ask.js +413 -265
  4. package/dist/commands/crawl.d.ts +24 -0
  5. package/dist/commands/crawl.js +121 -0
  6. package/dist/commands/history.js +38 -3
  7. package/dist/commands/init.d.ts +89 -95
  8. package/dist/commands/init.js +282 -200
  9. package/dist/commands/run.d.ts +1 -0
  10. package/dist/core/adapters/playwright-ui.d.ts +45 -7
  11. package/dist/core/adapters/playwright-ui.js +365 -59
  12. package/dist/core/assertions/engine.d.ts +51 -0
  13. package/dist/core/assertions/engine.js +530 -0
  14. package/dist/core/assertions/index.d.ts +11 -0
  15. package/dist/core/assertions/index.js +11 -0
  16. package/dist/core/assertions/types.d.ts +121 -0
  17. package/dist/core/assertions/types.js +37 -0
  18. package/dist/core/crawler/index.d.ts +57 -0
  19. package/dist/core/crawler/index.js +281 -0
  20. package/dist/core/crawler/journey-generator.d.ts +49 -0
  21. package/dist/core/crawler/journey-generator.js +412 -0
  22. package/dist/core/crawler/page-analyzer.d.ts +88 -0
  23. package/dist/core/crawler/page-analyzer.js +709 -0
  24. package/dist/core/crawler/selector-generator.d.ts +34 -0
  25. package/dist/core/crawler/selector-generator.js +240 -0
  26. package/dist/core/crawler/types.d.ts +353 -0
  27. package/dist/core/crawler/types.js +6 -0
  28. package/dist/core/generation/crawler-pack-generator.d.ts +44 -0
  29. package/dist/core/generation/crawler-pack-generator.js +231 -0
  30. package/dist/core/generation/index.d.ts +2 -0
  31. package/dist/core/generation/index.js +2 -0
  32. package/dist/core/index.d.ts +3 -0
  33. package/dist/core/index.js +4 -0
  34. package/dist/core/types/pack-v1.d.ts +90 -0
  35. package/dist/index.js +6 -2
  36. package/examples/accessibility.yml +39 -16
  37. package/examples/api-basic.yml +19 -14
  38. package/examples/complete.yml +134 -42
  39. package/examples/fullstack.yml +66 -31
  40. package/examples/security.yml +47 -15
  41. package/examples/ui-basic.yml +16 -12
  42. package/package.json +3 -2
@@ -0,0 +1,709 @@
1
+ /**
2
+ * QA360 Page Analyzer
3
+ *
4
+ * Analyzes web pages to discover elements, forms, and patterns
5
+ */
6
+ import { chromium } from '@playwright/test';
7
+ import { generateSelectorFromElement } from './selector-generator.js';
8
+ /**
9
+ * Page Analyzer class
10
+ */
11
+ export class PageAnalyzer {
12
+ browser;
13
+ context;
14
+ page;
15
+ options;
16
+ constructor(options) {
17
+ this.options = {
18
+ timeout: 30000,
19
+ headless: true,
20
+ waitForNetworkIdle: true,
21
+ ...options,
22
+ };
23
+ }
24
+ /**
25
+ * Initialize browser
26
+ */
27
+ async initBrowser() {
28
+ this.browser = await chromium.launch({
29
+ headless: this.options.headless ?? true,
30
+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
31
+ });
32
+ this.context = await this.browser.newContext({
33
+ viewport: { width: 1920, height: 1080 },
34
+ userAgent: 'QA360-Crawler/1.0',
35
+ });
36
+ this.page = await this.context.newPage();
37
+ // Set default timeout
38
+ this.page.setDefaultTimeout(this.options.timeout);
39
+ }
40
+ /**
41
+ * Perform authentication if configured
42
+ */
43
+ async performAuth() {
44
+ if (!this.options.auth)
45
+ return;
46
+ const auth = this.options.auth;
47
+ if (auth.type === 'basic') {
48
+ // Set basic auth headers
49
+ const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
50
+ await this.page.setExtraHTTPHeaders({
51
+ Authorization: `Basic ${credentials}`,
52
+ });
53
+ }
54
+ else if (auth.type === 'bearer' && auth.token) {
55
+ await this.page.setExtraHTTPHeaders({
56
+ Authorization: `Bearer ${auth.token}`,
57
+ });
58
+ }
59
+ else if (auth.type === 'cookie' && auth.cookies) {
60
+ await this.context.addCookies(auth.cookies.map(c => ({
61
+ name: c.name,
62
+ value: c.value,
63
+ domain: c.domain || new URL(this.options.baseUrl).hostname,
64
+ path: '/',
65
+ })));
66
+ }
67
+ else if (auth.type === 'form' && auth.loginUrl) {
68
+ // Perform form login
69
+ await this.page.goto(auth.loginUrl);
70
+ const usernameSelector = auth.usernameSelector || 'input[name="username"], input[name="email"], input[type="email"]';
71
+ const passwordSelector = auth.passwordSelector || 'input[name="password"], input[type="password"]';
72
+ const submitSelector = auth.submitSelector || 'button[type="submit"]';
73
+ await this.page.fill(usernameSelector, auth.username || '');
74
+ await this.page.fill(passwordSelector, auth.password || '');
75
+ await this.page.click(submitSelector);
76
+ // Wait for navigation
77
+ if (this.options.waitForNetworkIdle) {
78
+ await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { });
79
+ }
80
+ }
81
+ }
82
+ /**
83
+ * Analyze a single page
84
+ */
85
+ async analyze(url, depth) {
86
+ if (!this.browser) {
87
+ await this.initBrowser();
88
+ await this.performAuth();
89
+ }
90
+ const startTime = Date.now();
91
+ try {
92
+ // Navigate to page
93
+ const response = await this.page.goto(url, {
94
+ waitUntil: this.options.waitForNetworkIdle ? 'networkidle' : 'domcontentloaded',
95
+ timeout: this.options.timeout,
96
+ });
97
+ const loadTime = Date.now() - startTime;
98
+ const status = response?.status() || 0;
99
+ // Get page info
100
+ const title = await this.page.title();
101
+ const path = url.replace(new URL(this.options.baseUrl).origin, '') || '/';
102
+ // Get meta description
103
+ const description = (await this.page
104
+ .locator('meta[name="description"]')
105
+ .getAttribute('content')
106
+ .catch(() => undefined)) || undefined;
107
+ // Discover all elements
108
+ const elements = await this.discoverElements();
109
+ // Analyze navigation
110
+ const navigation = await this.analyzeNavigation();
111
+ // Accessibility scan
112
+ const accessibility = await this.runAccessibilityScan();
113
+ // Screenshot if requested
114
+ let screenshot;
115
+ if (this.options.screenshots) {
116
+ const buffer = await this.page.screenshot({ fullPage: false });
117
+ screenshot = `data:image/png;base64,${buffer.toString('base64')}`;
118
+ }
119
+ // Detect page type
120
+ const { pageType, pageTypeConfidence } = this.detectPageType(elements, title, path);
121
+ return {
122
+ url,
123
+ path,
124
+ title,
125
+ depth,
126
+ status,
127
+ loadTime,
128
+ description,
129
+ elements,
130
+ navigation,
131
+ screenshot,
132
+ accessibility,
133
+ pageType,
134
+ pageTypeConfidence,
135
+ };
136
+ }
137
+ catch (error) {
138
+ // Return minimal info on error
139
+ return {
140
+ url,
141
+ path: url.replace(new URL(this.options.baseUrl).origin, ''),
142
+ title: 'Error',
143
+ depth,
144
+ status: 0,
145
+ loadTime: Date.now() - startTime,
146
+ elements: {
147
+ buttons: [],
148
+ links: [],
149
+ forms: [],
150
+ inputs: [],
151
+ selects: [],
152
+ checkboxes: [],
153
+ radios: [],
154
+ },
155
+ navigation: {
156
+ main: undefined,
157
+ footer: undefined,
158
+ breadcrumb: undefined,
159
+ },
160
+ pageType: 'other',
161
+ pageTypeConfidence: 0,
162
+ };
163
+ }
164
+ }
165
+ /**
166
+ * Discover all interactive elements on the page
167
+ */
168
+ async discoverElements() {
169
+ const buttons = await this.discoverButtons();
170
+ const links = await this.discoverLinks();
171
+ const forms = await this.discoverForms();
172
+ const inputs = await this.discoverInputs();
173
+ const selects = await this.discoverSelects();
174
+ const checkboxes = await this.discoverCheckboxes();
175
+ const radios = await this.discoverRadios();
176
+ return {
177
+ buttons,
178
+ links,
179
+ forms,
180
+ inputs,
181
+ selects,
182
+ checkboxes,
183
+ radios,
184
+ };
185
+ }
186
+ /**
187
+ * Discover buttons
188
+ */
189
+ async discoverButtons() {
190
+ const elements = await this.page.$$('button, input[type="button"], input[type="submit"], [role="button"], a[role="button"]');
191
+ const buttons = [];
192
+ for (const el of elements) {
193
+ try {
194
+ const info = await generateSelectorFromElement(el, this.page);
195
+ if (info.selector !== 'unknown') {
196
+ buttons.push(info);
197
+ }
198
+ }
199
+ catch {
200
+ // Skip failed elements
201
+ }
202
+ }
203
+ return buttons;
204
+ }
205
+ /**
206
+ * Discover links
207
+ */
208
+ async discoverLinks() {
209
+ const elements = await this.page.$$('[href]');
210
+ const links = [];
211
+ const baseUrl = new URL(this.options.baseUrl);
212
+ for (const el of elements) {
213
+ try {
214
+ const info = await generateSelectorFromElement(el, this.page);
215
+ const href = await el.getAttribute('href');
216
+ if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
217
+ continue;
218
+ }
219
+ // Resolve relative URLs
220
+ let url;
221
+ try {
222
+ url = new URL(href, baseUrl.origin).href;
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ const internal = url.startsWith(baseUrl.origin);
228
+ links.push({
229
+ ...info,
230
+ url,
231
+ internal,
232
+ visited: false,
233
+ });
234
+ }
235
+ catch {
236
+ // Skip failed elements
237
+ }
238
+ }
239
+ return links;
240
+ }
241
+ /**
242
+ * Discover forms
243
+ */
244
+ async discoverForms() {
245
+ const forms = await this.page.$$('[role="form"], form, .form, [data-form]');
246
+ const discovered = [];
247
+ for (const formEl of forms) {
248
+ try {
249
+ const selector = await generateSelectorFromElement(formEl, this.page).then(i => i.selector);
250
+ // Get form fields
251
+ const fields = await this.discoverFormFields(formEl);
252
+ if (fields.length === 0)
253
+ continue;
254
+ // Get submit button
255
+ let submitButton;
256
+ const submitBtn = await formEl.$('button[type="submit"], input[type="submit"], [type="submit"]');
257
+ if (submitBtn) {
258
+ submitButton = await generateSelectorFromElement(submitBtn, this.page);
259
+ }
260
+ // Detect form purpose
261
+ const purpose = this.detectFormPurpose(fields, selector);
262
+ const confidence = this.calculateFormPurposeConfidence(fields, purpose);
263
+ discovered.push({
264
+ selector,
265
+ purpose,
266
+ fields,
267
+ submitButton,
268
+ confidence,
269
+ });
270
+ }
271
+ catch {
272
+ // Skip failed forms
273
+ }
274
+ }
275
+ return discovered;
276
+ }
277
+ /**
278
+ * Discover fields within a form
279
+ */
280
+ async discoverFormFields(formEl) {
281
+ const fields = [];
282
+ // Get all input, select, textarea elements
283
+ const inputs = await formEl.$$('input:not([type="hidden"]):not([type="submit"]):not([type="button"]), select, textarea');
284
+ for (const input of inputs) {
285
+ try {
286
+ const info = await generateSelectorFromElement(input, this.page);
287
+ // Get field-specific attributes
288
+ const inputType = await input.getAttribute('type') || 'text';
289
+ const name = await input.getAttribute('name');
290
+ const required = (await input.getAttribute('required')) !== null;
291
+ const placeholder = await input.getAttribute('placeholder');
292
+ // Get validation attributes
293
+ const validation = {};
294
+ const min = await input.getAttribute('min');
295
+ const max = await input.getAttribute('max');
296
+ const pattern = await input.getAttribute('pattern');
297
+ const minLength = await input.getAttribute('minlength');
298
+ const maxLength = await input.getAttribute('maxlength');
299
+ if (min)
300
+ validation.min = min;
301
+ if (max)
302
+ validation.max = max;
303
+ if (pattern)
304
+ validation.pattern = pattern;
305
+ if (minLength)
306
+ validation.minLength = parseInt(minLength, 10);
307
+ if (maxLength)
308
+ validation.maxLength = parseInt(maxLength, 10);
309
+ // Get options for selects
310
+ let options;
311
+ if (inputType === 'select-one' || inputType === 'select-multiple') {
312
+ options = await input.$$eval('option', (opts) => opts
313
+ .map(o => o.value || o.text)
314
+ .filter(Boolean));
315
+ }
316
+ fields.push({
317
+ ...info,
318
+ inputType,
319
+ name: name || undefined,
320
+ required,
321
+ placeholder: placeholder || undefined,
322
+ options,
323
+ validation: Object.keys(validation).length > 0 ? validation : undefined,
324
+ });
325
+ }
326
+ catch {
327
+ // Skip failed fields
328
+ }
329
+ }
330
+ return fields;
331
+ }
332
+ /**
333
+ * Detect form purpose based on fields
334
+ */
335
+ detectFormPurpose(fields, selector) {
336
+ const fieldNames = fields.map(f => f.name?.toLowerCase() || '');
337
+ const selectorLower = selector.toLowerCase();
338
+ // Check for login form
339
+ if ((fieldNames.includes('password') || fieldNames.includes('pass')) &&
340
+ (fieldNames.includes('email') ||
341
+ fieldNames.includes('username') ||
342
+ fieldNames.includes('user'))) {
343
+ return 'login';
344
+ }
345
+ // Check for signup form
346
+ if ((fieldNames.includes('password') || fieldNames.includes('confirm')) &&
347
+ (fieldNames.includes('email') || fieldNames.includes('username'))) {
348
+ return 'signup';
349
+ }
350
+ // Check for search form
351
+ if (selectorLower.includes('search') || fieldNames.includes('q') || fieldNames.includes('search')) {
352
+ return 'search';
353
+ }
354
+ // Check for checkout form
355
+ if (fieldNames.includes('card') ||
356
+ fieldNames.includes('cvv') ||
357
+ fieldNames.includes('expiry')) {
358
+ return 'checkout';
359
+ }
360
+ // Check for contact form
361
+ if (fieldNames.includes('message') || fieldNames.includes('subject')) {
362
+ return 'contact';
363
+ }
364
+ return 'other';
365
+ }
366
+ /**
367
+ * Calculate confidence for form purpose detection
368
+ */
369
+ calculateFormPurposeConfidence(fields, purpose) {
370
+ // High confidence for login/signup with password field
371
+ if (purpose === 'login' || purpose === 'signup') {
372
+ if (fields.some(f => f.inputType === 'password')) {
373
+ return 0.9;
374
+ }
375
+ }
376
+ // Medium confidence for forms with clear naming
377
+ if (purpose !== 'other') {
378
+ return 0.7;
379
+ }
380
+ return 0.5;
381
+ }
382
+ /**
383
+ * Discover standalone input fields (not in forms)
384
+ */
385
+ async discoverInputs() {
386
+ const inputs = await this.page.$$('input:not([type="hidden"]):not([type="submit"]):not(form input), textarea:not(form textarea)');
387
+ const fields = [];
388
+ for (const input of inputs) {
389
+ try {
390
+ const info = await generateSelectorFromElement(input, this.page);
391
+ const inputType = await input.getAttribute('type') || 'text';
392
+ const name = await input.getAttribute('name');
393
+ const required = (await input.getAttribute('required')) !== null;
394
+ const placeholder = await input.getAttribute('placeholder');
395
+ fields.push({
396
+ ...info,
397
+ inputType,
398
+ name: name || undefined,
399
+ required,
400
+ placeholder: placeholder || undefined,
401
+ });
402
+ }
403
+ catch {
404
+ // Skip failed
405
+ }
406
+ }
407
+ return fields;
408
+ }
409
+ /**
410
+ * Discover select elements
411
+ */
412
+ async discoverSelects() {
413
+ const selects = await this.page.$$('select:not(form select)');
414
+ const fields = [];
415
+ for (const select of selects) {
416
+ try {
417
+ const info = await generateSelectorFromElement(select, this.page);
418
+ const name = await select.getAttribute('name');
419
+ const required = (await select.getAttribute('required')) !== null;
420
+ // Get options
421
+ const options = await select.$$eval('option', opts => opts.map(o => o.value).filter(Boolean));
422
+ fields.push({
423
+ ...info,
424
+ inputType: 'select-one',
425
+ name: name || undefined,
426
+ required,
427
+ options: options.length > 0 ? options : undefined,
428
+ });
429
+ }
430
+ catch {
431
+ // Skip failed
432
+ }
433
+ }
434
+ return fields;
435
+ }
436
+ /**
437
+ * Discover checkboxes
438
+ */
439
+ async discoverCheckboxes() {
440
+ const checkboxSelector = "input[type=\"checkbox\"]";
441
+ const checkboxes = await this.page.$$(checkboxSelector);
442
+ const elements = [];
443
+ for (const el of checkboxes) {
444
+ try {
445
+ const info = await generateSelectorFromElement(el, this.page);
446
+ if (info.selector !== 'unknown') {
447
+ elements.push(info);
448
+ }
449
+ }
450
+ catch {
451
+ // Skip failed
452
+ }
453
+ }
454
+ return elements;
455
+ }
456
+ /**
457
+ * Discover radio buttons
458
+ */
459
+ async discoverRadios() {
460
+ const radioSelector = "input[type=\"radio\"]";
461
+ const radios = await this.page.$$(radioSelector);
462
+ const elements = [];
463
+ for (const el of radios) {
464
+ try {
465
+ const info = await generateSelectorFromElement(el, this.page);
466
+ if (info.selector !== 'unknown') {
467
+ elements.push(info);
468
+ }
469
+ }
470
+ catch {
471
+ // Skip failed
472
+ }
473
+ }
474
+ return elements;
475
+ }
476
+ /**
477
+ * Analyze page navigation structure
478
+ */
479
+ async analyzeNavigation() {
480
+ const navigation = {};
481
+ // Main navigation (nav, menu, navbar)
482
+ const mainNav = await this.page.$('nav, [role="navigation"], .nav, .navigation, .navbar, .menu');
483
+ if (mainNav) {
484
+ const navSelector = await generateSelectorFromElement(mainNav, this.page).then(i => i.selector);
485
+ const links = await mainNav.$$('[href]');
486
+ const items = [];
487
+ for (const link of links) {
488
+ try {
489
+ const info = await generateSelectorFromElement(link, this.page);
490
+ const href = await link.getAttribute('href');
491
+ if (href) {
492
+ const url = new URL(href, new URL(this.options.baseUrl).origin).href;
493
+ items.push({
494
+ ...info,
495
+ url,
496
+ internal: url.startsWith(new URL(this.options.baseUrl).origin),
497
+ visited: false,
498
+ });
499
+ }
500
+ }
501
+ catch {
502
+ // Skip
503
+ }
504
+ }
505
+ navigation.main = { selector: navSelector, items };
506
+ }
507
+ // Breadcrumb
508
+ const breadcrumb = await this.page.$('[aria-label="breadcrumb"], .breadcrumb, .breadcrumbs, ol.breadcrumb');
509
+ if (breadcrumb) {
510
+ const crumbSelector = await generateSelectorFromElement(breadcrumb, this.page).then(i => i.selector);
511
+ const items = await breadcrumb.$$('[href], span, li');
512
+ const breadcrumbs = [];
513
+ for (const item of items) {
514
+ try {
515
+ const text = await item.textContent();
516
+ const href = await item.getAttribute('href');
517
+ if (text && text.trim()) {
518
+ breadcrumbs.push({
519
+ text: text.trim(),
520
+ url: href || '',
521
+ });
522
+ }
523
+ }
524
+ catch {
525
+ // Skip
526
+ }
527
+ }
528
+ if (breadcrumbs.length > 0) {
529
+ navigation.breadcrumb = { selector: crumbSelector, items: breadcrumbs };
530
+ }
531
+ }
532
+ // Pagination
533
+ const pagination = await this.page.$('[aria-label="pagination"], .pagination, [role="navigation"] nav');
534
+ if (pagination) {
535
+ const pageSelector = await generateSelectorFromElement(pagination, this.page).then(i => i.selector);
536
+ const nextLink = await pagination.$('a[rel="next"], .next, [aria-label="next"]');
537
+ const prevLink = await pagination.$('a[rel="prev"], .prev, [aria-label="previous"]');
538
+ const paginationInfo = {
539
+ selector: pageSelector,
540
+ };
541
+ if (nextLink) {
542
+ const info = await generateSelectorFromElement(nextLink, this.page);
543
+ const href = await nextLink.getAttribute('href');
544
+ if (href) {
545
+ paginationInfo.nextPage = {
546
+ ...info,
547
+ url: new URL(href, new URL(this.options.baseUrl).origin).href,
548
+ internal: true,
549
+ visited: false,
550
+ };
551
+ }
552
+ }
553
+ if (prevLink) {
554
+ const info = await generateSelectorFromElement(prevLink, this.page);
555
+ const href = await prevLink.getAttribute('href');
556
+ if (href) {
557
+ paginationInfo.prevPage = {
558
+ ...info,
559
+ url: new URL(href, new URL(this.options.baseUrl).origin).href,
560
+ internal: true,
561
+ visited: false,
562
+ };
563
+ }
564
+ }
565
+ navigation.pagination = paginationInfo;
566
+ }
567
+ // Footer
568
+ const footer = await this.page.$('footer, [role="contentinfo"], .footer');
569
+ if (footer) {
570
+ const footerSelector = await generateSelectorFromElement(footer, this.page).then(i => i.selector);
571
+ const links = await footer.$$('[href]');
572
+ const items = [];
573
+ for (const link of links) {
574
+ try {
575
+ const info = await generateSelectorFromElement(link, this.page);
576
+ const href = await link.getAttribute('href');
577
+ if (href) {
578
+ const url = new URL(href, new URL(this.options.baseUrl).origin).href;
579
+ items.push({
580
+ ...info,
581
+ url,
582
+ internal: url.startsWith(new URL(this.options.baseUrl).origin),
583
+ visited: false,
584
+ });
585
+ }
586
+ }
587
+ catch {
588
+ // Skip
589
+ }
590
+ }
591
+ navigation.footer = { selector: footerSelector, items };
592
+ }
593
+ return navigation;
594
+ }
595
+ /**
596
+ * Run accessibility scan using axe-core
597
+ */
598
+ async runAccessibilityScan() {
599
+ try {
600
+ // Inject axe-core
601
+ await this.page.addScriptTag({
602
+ url: 'https://unpkg.com/axe-core@4.8.2/axe.min.js',
603
+ });
604
+ // Run axe
605
+ const results = await this.page.evaluate(() => {
606
+ return new Promise((resolve) => {
607
+ // @ts-ignore
608
+ if (typeof axe !== 'undefined') {
609
+ // @ts-ignore
610
+ axe.run((err, results) => {
611
+ if (err) {
612
+ resolve({ violations: [], passes: [], incomplete: [] });
613
+ }
614
+ else {
615
+ resolve(results);
616
+ }
617
+ });
618
+ }
619
+ else {
620
+ resolve({ violations: [], passes: [], incomplete: [] });
621
+ }
622
+ });
623
+ });
624
+ const axeResults = results;
625
+ // Process violations
626
+ const violations = (axeResults.violations || []).map((v) => ({
627
+ id: v.id,
628
+ impact: v.impact || 'moderate',
629
+ description: v.description || v.help || 'Accessibility issue',
630
+ nodes: v.nodes?.length || 0,
631
+ selectors: (v.nodes || []).slice(0, 5).map((n) => n.target?.[0] || '').filter(Boolean),
632
+ }));
633
+ // Calculate score
634
+ const criticalCount = violations.filter((v) => v.impact === 'critical').length;
635
+ const seriousCount = violations.filter((v) => v.impact === 'serious').length;
636
+ const moderateCount = violations.filter((v) => v.impact === 'moderate').length;
637
+ const minorCount = violations.filter((v) => v.impact === 'minor').length;
638
+ const score = Math.max(0, 100 - (criticalCount * 25 + seriousCount * 10 + moderateCount * 5 + minorCount * 1));
639
+ return {
640
+ score: Math.round(score),
641
+ violations,
642
+ passes: axeResults.passes?.length || 0,
643
+ incomplete: axeResults.incomplete?.length || 0,
644
+ };
645
+ }
646
+ catch {
647
+ return undefined;
648
+ }
649
+ }
650
+ /**
651
+ * Detect page type
652
+ */
653
+ detectPageType(elements, title, path) {
654
+ const pathLower = path.toLowerCase();
655
+ const titleLower = title.toLowerCase();
656
+ // Homepage
657
+ if (path === '/' || path === '' || pathLower === '/home' || pathLower === '/index') {
658
+ return { pageType: 'homepage', pageTypeConfidence: 0.95 };
659
+ }
660
+ // Login page
661
+ if (pathLower.includes('/login') ||
662
+ pathLower.includes('/signin') ||
663
+ pathLower.includes('/auth') ||
664
+ titleLower.includes('login') ||
665
+ titleLower.includes('sign in')) {
666
+ return { pageType: 'login', pageTypeConfidence: 0.9 };
667
+ }
668
+ // Signup page
669
+ if (pathLower.includes('/signup') ||
670
+ pathLower.includes('/register') ||
671
+ pathLower.includes('/join') ||
672
+ titleLower.includes('sign up') ||
673
+ titleLower.includes('register')) {
674
+ return { pageType: 'signup', pageTypeConfidence: 0.9 };
675
+ }
676
+ // Dashboard
677
+ if (pathLower.includes('/dashboard') ||
678
+ pathLower.includes('/my-account') ||
679
+ pathLower.includes('/profile') ||
680
+ titleLower.includes('dashboard')) {
681
+ return { pageType: 'dashboard', pageTypeConfidence: 0.85 };
682
+ }
683
+ // Listing page (multiple items, pagination)
684
+ if (elements.links.length > 20 || elements.forms.some(f => f.purpose === 'filter')) {
685
+ return { pageType: 'listing', pageTypeConfidence: 0.7 };
686
+ }
687
+ // Form page
688
+ if (elements.forms.length > 0) {
689
+ return { pageType: 'form', pageTypeConfidence: 0.75 };
690
+ }
691
+ return { pageType: 'other', pageTypeConfidence: 0.5 };
692
+ }
693
+ /**
694
+ * Clean up resources
695
+ */
696
+ async cleanup() {
697
+ try {
698
+ if (this.page)
699
+ await this.page.close();
700
+ if (this.context)
701
+ await this.context.close();
702
+ if (this.browser)
703
+ await this.browser.close();
704
+ }
705
+ catch {
706
+ // Ignore cleanup errors
707
+ }
708
+ }
709
+ }