qa360 2.3.2 → 2.3.3

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.
@@ -159,6 +159,12 @@ export declare class PlaywrightUiAdapter {
159
159
  * Test page with actions (for user journeys)
160
160
  */
161
161
  private testPageWithActions;
162
+ /**
163
+ * Smart click with automatic hover/scroll fallback for invisible elements
164
+ * Handles dropdown menus that require hover to reveal clickable items
165
+ */
166
+ private smartClick;
167
+ private sleep;
162
168
  /**
163
169
  * Execute a single page action
164
170
  */
@@ -1474,6 +1474,70 @@ export class PlaywrightUiAdapter {
1474
1474
  };
1475
1475
  }
1476
1476
  }
1477
+ /**
1478
+ * Smart click with automatic hover/scroll fallback for invisible elements
1479
+ * Handles dropdown menus that require hover to reveal clickable items
1480
+ */
1481
+ async smartClick(selector, timeout) {
1482
+ const page = this.page;
1483
+ const maxRetries = 3;
1484
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1485
+ try {
1486
+ // First, try a direct click
1487
+ await page.click(selector, { timeout: timeout / maxRetries, force: false });
1488
+ return;
1489
+ }
1490
+ catch (error) {
1491
+ // If direct click failed, try fallback strategies
1492
+ const isVisible = await page.isVisible(selector).catch(() => false);
1493
+ const isAttached = await page.locator(selector).count().then(c => c > 0).catch(() => false);
1494
+ if (!isAttached) {
1495
+ throw error; // Element doesn't exist, no point retrying
1496
+ }
1497
+ if (!isVisible) {
1498
+ // Strategy 1: Scroll into view
1499
+ try {
1500
+ await page.locator(selector).first().scrollIntoViewIfNeeded({ timeout: 1000 });
1501
+ await this.sleep(200); // Wait for scroll to complete
1502
+ }
1503
+ catch { /* Ignore scroll errors */ }
1504
+ // Strategy 2: Try to hover on parent to reveal dropdown
1505
+ try {
1506
+ // Extract parent selector (remove last pseudo-class or attribute)
1507
+ const parentSelectors = [
1508
+ selector.replace(/::.+$/, ''), // Remove pseudo-class
1509
+ selector.replace(/\[\w+=.+\]$/, ''), // Remove attribute
1510
+ ];
1511
+ for (const parent of parentSelectors) {
1512
+ if (parent !== selector) {
1513
+ const isParentVisible = await page.isVisible(parent).catch(() => false);
1514
+ if (isParentVisible) {
1515
+ await page.locator(parent).first().hover({ timeout: 1000 });
1516
+ await this.sleep(300); // Wait for dropdown animation
1517
+ break;
1518
+ }
1519
+ }
1520
+ }
1521
+ }
1522
+ catch { /* Ignore hover errors */ }
1523
+ // Strategy 3: Force click after revealing
1524
+ try {
1525
+ await page.click(selector, { timeout: 2000, force: true });
1526
+ return;
1527
+ }
1528
+ catch { /* Continue to next attempt */ }
1529
+ }
1530
+ // Last attempt: force click
1531
+ if (attempt === maxRetries - 1) {
1532
+ await page.click(selector, { timeout, force: true });
1533
+ return;
1534
+ }
1535
+ }
1536
+ }
1537
+ }
1538
+ sleep(ms) {
1539
+ return new Promise(resolve => setTimeout(resolve, ms));
1540
+ }
1477
1541
  /**
1478
1542
  * Execute a single page action
1479
1543
  */
@@ -1488,7 +1552,7 @@ export class PlaywrightUiAdapter {
1488
1552
  if (!action.selector) {
1489
1553
  throw new Error('Click action requires a selector');
1490
1554
  }
1491
- await this.page.click(action.selector, { timeout });
1555
+ await this.smartClick(action.selector, timeout);
1492
1556
  break;
1493
1557
  case 'fill':
1494
1558
  if (!action.selector) {
@@ -1536,6 +1600,26 @@ export class PlaywrightUiAdapter {
1536
1600
  }
1537
1601
  }
1538
1602
  break;
1603
+ case 'press':
1604
+ if (action.value) {
1605
+ if (action.selector) {
1606
+ await this.page.locator(action.selector).press(action.value, { timeout });
1607
+ }
1608
+ else {
1609
+ await this.page.keyboard.press(action.value);
1610
+ }
1611
+ }
1612
+ break;
1613
+ case 'waitForSelector':
1614
+ if (action.selector) {
1615
+ await this.page.waitForSelector(action.selector, { timeout });
1616
+ }
1617
+ break;
1618
+ case 'hover':
1619
+ if (action.selector) {
1620
+ await this.page.hover(action.selector, { timeout });
1621
+ }
1622
+ break;
1539
1623
  default:
1540
1624
  console.warn(`Unknown action type: ${action.type}`);
1541
1625
  }
@@ -5,6 +5,8 @@
5
5
  * Supports all major Consent Management Platforms (CMPs).
6
6
  *
7
7
  * Philosophy: "80% of EU sites block interaction with consent banners"
8
+ *
9
+ * NEW: Continuous monitoring mode for async-loaded banners
8
10
  */
9
11
  import type { Page } from '@playwright/test';
10
12
  /**
@@ -17,6 +19,8 @@ export interface ConsentOptions {
17
19
  timeout?: number;
18
20
  /** Custom selector override */
19
21
  customSelector?: string;
22
+ /** Enable continuous monitoring (watches for banners appearing during session) */
23
+ continuous?: boolean;
20
24
  }
21
25
  /**
22
26
  * Consent Handler - Auto-detects and handles GDPR/CCPA banners
@@ -30,6 +34,19 @@ export declare class ConsentHandler {
30
34
  * @returns true if a banner was found and handled, false otherwise
31
35
  */
32
36
  static handleConsent(page: Page, options?: ConsentOptions): Promise<boolean>;
37
+ /**
38
+ * Start continuous monitoring for consent banners
39
+ * Uses MutationObserver to detect banners that appear after page load
40
+ */
41
+ private static startContinuousMonitoring;
42
+ /**
43
+ * Stop continuous monitoring for a page
44
+ */
45
+ static stopContinuousMonitoring(page: Page): void;
46
+ /**
47
+ * Stop all continuous monitoring
48
+ */
49
+ static stopAllMonitoring(): void;
33
50
  /**
34
51
  * Try a specific CMP
35
52
  */
@@ -5,7 +5,13 @@
5
5
  * Supports all major Consent Management Platforms (CMPs).
6
6
  *
7
7
  * Philosophy: "80% of EU sites block interaction with consent banners"
8
+ *
9
+ * NEW: Continuous monitoring mode for async-loaded banners
10
+ */
11
+ /**
12
+ * Active monitoring sessions (used to stop continuous monitoring)
8
13
  */
14
+ const activeMonitors = new Map();
9
15
  /**
10
16
  * CMP (Consent Management Platform) Selectors
11
17
  */
@@ -82,51 +88,121 @@ const CONSENT_SELECTORS = {
82
88
  // === GENERIC PATTERNS (fallback for unknown CMPs) ===
83
89
  generic: {
84
90
  accept: [
91
+ // Attribute-based patterns
85
92
  '[id*="cookie"][id*="accept"]',
86
93
  '[id*="cookie"][id*="agree"]',
94
+ '[id*="cookie"][id*="allow"]',
87
95
  '[class*="cookie"][class*="accept"]',
88
96
  '[class*="cookie"][class*="agree"]',
97
+ '[class*="cookie"][class*="allow"]',
98
+ '[data-cookie*="accept"]',
99
+ '[data-cookie*="agree"]',
89
100
  '[id*="consent"][id*="accept"]',
90
101
  '[class*="consent"][class*="accept"]',
102
+ // Text-based patterns (English)
91
103
  'button:has-text("Accept")',
92
104
  'button:has-text("Accept All")',
93
- 'button:has-text("Accepter")',
94
- 'button:has-text("Tout accepter")',
95
- "button:has-text(\"J'accepte\")",
105
+ 'button:has-text("Accept Cookies")',
96
106
  'button:has-text("I agree")',
107
+ 'button:has-text("I accept")',
108
+ 'button:has-text("Agree")',
97
109
  'button:has-text("OK")',
98
110
  'button:has-text("Got it")',
111
+ // Text-based patterns (French)
112
+ 'button:has-text("Accepter")',
113
+ 'button:has-text("Tout accepter")',
114
+ "button:has-text(\"J'accepte\")",
99
115
  'button:has-text("Accepter les cookies")',
116
+ 'button:has-text("Tout accepter")',
117
+ // Text-based patterns (German)
118
+ 'button:has-text("Akzeptieren")',
119
+ 'button:has-text("Alle akzeptieren")',
120
+ 'button:has-text("Zustimmen")',
121
+ 'button:has-text("OK")',
122
+ // Text-based patterns (Spanish)
123
+ 'button:has-text("Aceptar")',
124
+ 'button:has-text("Aceptar todo")',
125
+ 'button:has-text("Aceptar cookies")',
126
+ // Common button classes
127
+ '.btn-accept',
128
+ '.btn-agree',
129
+ '.btn-allow',
130
+ '.accept-btn',
131
+ '.agree-btn',
132
+ '#btn-accept',
133
+ '#btn-agree',
134
+ // Data attributes
135
+ '[data-action="accept"]',
136
+ '[data-action="agree"]',
137
+ '[data-consent="accept"]',
138
+ '[data-cy="cookie-accept"]',
100
139
  ],
101
140
  reject: [
102
141
  '[id*="cookie"][id*="reject"]',
103
142
  '[id*="cookie"][id*="decline"]',
104
143
  '[id*="cookie"][id*="refuse"]',
144
+ '[id*="cookie"][id*="deny"]',
105
145
  '[class*="cookie"][class*="reject"]',
146
+ '[class*="cookie"][class*="decline"]',
147
+ '[class*="cookie"][class*="refuse"]',
106
148
  'button:has-text("Reject")',
149
+ 'button:has-text("Reject All")',
107
150
  'button:has-text("Refuser")',
108
- 'button:has-text("Decline")',
109
151
  'button:has-text("Tout refuser")',
152
+ 'button:has-text("Decline")',
153
+ 'button:has-text("Decline All")',
154
+ 'button:has-text("Refuser tout")',
155
+ '.btn-reject',
156
+ '.btn-decline',
157
+ '#btn-reject',
110
158
  ],
111
159
  banner: [
160
+ // ID-based patterns
112
161
  '[id*="cookie-banner"]',
113
162
  '[id*="cookie-consent"]',
114
163
  '[id*="cookie-notice"]',
115
- '[id*="gdpr"]',
164
+ '[id*="gdpr-banner"]',
165
+ '[id*="gdpr-consent"]',
116
166
  '[id*="consent-banner"]',
117
167
  '[id*="consent-notice"]',
168
+ '[id*="privacy-banner"]',
169
+ '[id*="privacy-notice"]',
170
+ // Class-based patterns
118
171
  '[class*="cookie-banner"]',
119
172
  '[class*="cookie-consent"]',
173
+ '[class*="cookie-notice"]',
174
+ '[class*="cookie-popup"]',
120
175
  '[class*="gdpr-banner"]',
176
+ '[class*="gdpr-consent"]',
121
177
  '[class*="consent-banner"]',
178
+ '[class*="consent-popup"]',
179
+ '[class*="privacy-banner"]',
180
+ // ARIA-based patterns
122
181
  '[aria-label*="cookie"]',
123
182
  '[aria-label*="consent"]',
183
+ '[aria-label*="gdpr"]',
184
+ '[aria-describedby*="cookie"]',
124
185
  '[role="dialog"][id*="cookie"]',
186
+ '[role="dialog"][id*="consent"]',
187
+ '[role="alertdialog"][id*="cookie"]',
188
+ // Common banner IDs/classes
125
189
  '.cookie-banner',
126
190
  '.cookie-consent',
191
+ '.cookie-notice',
127
192
  '.gdpr-banner',
193
+ '.consent-banner',
194
+ '.consent-popup',
128
195
  '#cookie-banner',
129
196
  '#cookie-consent',
197
+ '#gdpr-banner',
198
+ '#consent-banner',
199
+ // Data attributes
200
+ '[data-cookie-banner]',
201
+ '[data-consent-banner]',
202
+ '[data-gdpr-banner]',
203
+ // Fixed position elements (common for banners)
204
+ '[style*="position: fixed"][class*="cookie"]',
205
+ '[style*="position:fixed"][class*="cookie"]',
130
206
  ],
131
207
  name: 'Generic',
132
208
  },
@@ -143,7 +219,7 @@ export class ConsentHandler {
143
219
  * @returns true if a banner was found and handled, false otherwise
144
220
  */
145
221
  static async handleConsent(page, options = {}) {
146
- const { action = 'accept', timeout = 3000, customSelector } = options;
222
+ const { action = 'accept', timeout = 15000, customSelector, continuous = false } = options;
147
223
  if (action === 'ignore') {
148
224
  return false;
149
225
  }
@@ -152,11 +228,13 @@ export class ConsentHandler {
152
228
  return await this.handleCustom(page, customSelector, timeout);
153
229
  }
154
230
  // Try each known CMP
231
+ let handled = false;
155
232
  for (const [cmpName, selectors] of Object.entries(CONSENT_SELECTORS)) {
156
233
  try {
157
- const handled = await this.tryCMP(page, cmpName, selectors, action, timeout);
158
- if (handled) {
159
- return true;
234
+ const result = await this.tryCMP(page, cmpName, selectors, action, timeout);
235
+ if (result) {
236
+ handled = true;
237
+ break; // Found and handled, no need to try other CMPs
160
238
  }
161
239
  }
162
240
  catch {
@@ -164,7 +242,95 @@ export class ConsentHandler {
164
242
  continue;
165
243
  }
166
244
  }
167
- return false; // No consent banner found
245
+ // Start continuous monitoring if requested
246
+ if (continuous) {
247
+ this.startContinuousMonitoring(page, action);
248
+ }
249
+ return handled;
250
+ }
251
+ /**
252
+ * Start continuous monitoring for consent banners
253
+ * Uses MutationObserver to detect banners that appear after page load
254
+ */
255
+ static startContinuousMonitoring(page, action) {
256
+ // Stop existing monitor for this page if any
257
+ if (activeMonitors.has(page)) {
258
+ return; // Already monitoring
259
+ }
260
+ let attempts = 0;
261
+ const maxAttempts = 30; // Check for up to 30 seconds (30 * 1000ms)
262
+ const checkInterval = setInterval(async () => {
263
+ attempts++;
264
+ try {
265
+ // Quick check if any consent banner is present
266
+ const hasBanner = await page.evaluate(() => {
267
+ const selectors = [
268
+ // OneTrust
269
+ '#onetrust-banner-sdk, #onetrust-consent-sdk',
270
+ // Cookiebot
271
+ '#CybotCookiebotDialog, .CookieDialog',
272
+ // Generic patterns
273
+ '[id*="cookie-banner"], [id*="cookie-consent"], [id*="gdpr"]',
274
+ '[class*="cookie-banner"], [class*="cookie-consent"], [class*="gdpr-banner"]',
275
+ '[aria-label*="cookie"], [aria-label*="consent"]',
276
+ '#cookie-banner, #cookie-consent, .cookie-banner, .cookie-consent',
277
+ ];
278
+ for (const sel of selectors) {
279
+ const el = document.querySelector(sel);
280
+ if (el) {
281
+ const rect = el.getBoundingClientRect();
282
+ // Check if visible (has dimensions and not hidden)
283
+ if (rect.width > 0 && rect.height > 0) {
284
+ const style = window.getComputedStyle(el);
285
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
286
+ return true;
287
+ }
288
+ }
289
+ }
290
+ }
291
+ return false;
292
+ });
293
+ if (hasBanner) {
294
+ console.log(' 🍪 Consent banner detected (continuous monitoring)');
295
+ // Try to handle it
296
+ const handled = await this.handleConsent(page, { action, timeout: 5000 });
297
+ if (handled) {
298
+ console.log(' ✅ Consent banner handled (continuous monitoring)');
299
+ }
300
+ }
301
+ }
302
+ catch {
303
+ // Page might be closed, ignore
304
+ }
305
+ // Stop after max attempts
306
+ if (attempts >= maxAttempts) {
307
+ clearInterval(checkInterval);
308
+ activeMonitors.delete(page);
309
+ }
310
+ }, 1000); // Check every second
311
+ // Store cleanup function
312
+ activeMonitors.set(page, () => {
313
+ clearInterval(checkInterval);
314
+ });
315
+ }
316
+ /**
317
+ * Stop continuous monitoring for a page
318
+ */
319
+ static stopContinuousMonitoring(page) {
320
+ const cleanup = activeMonitors.get(page);
321
+ if (cleanup) {
322
+ cleanup();
323
+ activeMonitors.delete(page);
324
+ }
325
+ }
326
+ /**
327
+ * Stop all continuous monitoring
328
+ */
329
+ static stopAllMonitoring() {
330
+ for (const cleanup of activeMonitors.values()) {
331
+ cleanup();
332
+ }
333
+ activeMonitors.clear();
168
334
  }
169
335
  /**
170
336
  * Try a specific CMP
@@ -119,7 +119,9 @@ const GENERATED_ID_PATTERNS = [
119
119
  // === NEW - Generic ===
120
120
  /^[a-f0-9]{8}-[a-f0-9]{4}-/, // UUID prefix
121
121
  /^\w+-[a-f0-9]{6,}$/, // name-hash
122
- /^_?[a-zA-Z]+_[a-z0-9]{5,}$/, // CSS Modules
122
+ /^_?[a-zA-Z]+_[a-z0-9]{5,}$/, // CSS Modules (basic)
123
+ /^[\w-]+-module__[\w]+--[\w-]+$/, // CSS Modules (GitHub-style: Component-module__element--hash)
124
+ /^[\w]+__[\w]+--[\w-]+$/, // CSS Modules BEM with hash: Block__Element--modifier-hash
123
125
  /^[a-f0-9]{32}$/, // MD5 hash
124
126
  /^[a-f0-9]{40}$/, // SHA1 hash
125
127
  ];
@@ -248,6 +248,9 @@ export class JourneyGenerator {
248
248
  });
249
249
  // Fill form fields
250
250
  for (const field of form.fields) {
251
+ // Skip radio/checkbox here - they're handled separately below
252
+ if (field.inputType === 'radio' || field.inputType === 'checkbox')
253
+ continue;
251
254
  if (!field.required && Math.random() > 0.5)
252
255
  continue;
253
256
  const step = {
@@ -256,7 +259,7 @@ export class JourneyGenerator {
256
259
  action: field.inputType === 'select-one' ? 'select' : 'fill',
257
260
  selector: field.selector,
258
261
  };
259
- // Set appropriate test value
262
+ // Set appropriate test value based on input type
260
263
  if (field.inputType === 'email') {
261
264
  step.value = 'test@example.com';
262
265
  }
@@ -269,17 +272,66 @@ export class JourneyGenerator {
269
272
  const num = typeof min === 'number' ? min + 1 : 1;
270
273
  step.value = String(typeof max === 'number' ? Math.min(num, max - 1) : num);
271
274
  }
275
+ else if (field.inputType === 'time') {
276
+ step.value = '13:30'; // Valid time format
277
+ }
278
+ else if (field.inputType === 'date') {
279
+ step.value = '2024-01-15'; // Valid date format
280
+ }
281
+ else if (field.inputType === 'datetime-local') {
282
+ step.value = '2024-01-15T13:30'; // Valid datetime format
283
+ }
284
+ else if (field.inputType === 'url') {
285
+ step.value = 'https://example.com';
286
+ }
287
+ else if (field.inputType === 'color') {
288
+ step.value = '#ff0000';
289
+ }
290
+ else if (field.inputType === 'range') {
291
+ const min = field.validation?.min || 0;
292
+ const max = field.validation?.max || 100;
293
+ step.value = String((Number(min) + Number(max)) / 2);
294
+ }
272
295
  else if (field.inputType === 'select-one' && field.options && field.options.length > 0) {
273
296
  step.value = field.options[0];
274
297
  }
275
- else if (field.inputType === 'checkbox') {
276
- step.action = 'check';
277
- }
278
298
  else {
279
299
  step.value = 'Test value';
280
300
  }
281
301
  steps.push(step);
282
302
  }
303
+ // Handle radio buttons (select one option per radio group)
304
+ const radioGroups = new Map();
305
+ for (const field of form.fields) {
306
+ if (field.inputType === 'radio' && field.name) {
307
+ if (!radioGroups.has(field.name)) {
308
+ radioGroups.set(field.name, []);
309
+ }
310
+ radioGroups.get(field.name).push(field);
311
+ }
312
+ }
313
+ for (const [groupName, radios] of radioGroups) {
314
+ // Select the first radio option
315
+ if (radios.length > 0) {
316
+ steps.push({
317
+ order: steps.length + 1,
318
+ description: `Select ${groupName} option`,
319
+ action: 'check',
320
+ selector: radios[0].selector,
321
+ });
322
+ }
323
+ }
324
+ // Handle checkboxes (check all required ones)
325
+ for (const field of form.fields) {
326
+ if (field.inputType === 'checkbox' && field.required) {
327
+ steps.push({
328
+ order: steps.length + 1,
329
+ description: `Check ${field.name || 'checkbox'}`,
330
+ action: 'check',
331
+ selector: field.selector,
332
+ });
333
+ }
334
+ }
283
335
  // Submit form
284
336
  if (form.submitButton) {
285
337
  // Wait for submit button
@@ -89,9 +89,15 @@ export declare class PageAnalyzer {
89
89
  */
90
90
  private analyzeNavigation;
91
91
  /**
92
- * Run accessibility scan using axe-core
92
+ * Run accessibility scan using axe-core with native ARIA fallback
93
+ * When CSP blocks external scripts, falls back to native ARIA inspection
93
94
  */
94
95
  private runAccessibilityScan;
96
+ /**
97
+ * Native ARIA accessibility scan (fallback when axe-core is blocked)
98
+ * Uses browser's built-in ARIA inspection capabilities
99
+ */
100
+ private runNativeAriaScan;
95
101
  /**
96
102
  * Detect page type
97
103
  */
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { chromium } from '@playwright/test';
12
12
  import { generateSelectorFromElement, SiteProfiler, initializeSelectorGenerator, } from './selector-generator.js';
13
- import { handleConsent } from './consent-handler.js';
13
+ import { handleConsent, ConsentHandler } from './consent-handler.js';
14
14
  /**
15
15
  * Page Analyzer class
16
16
  */
@@ -491,8 +491,13 @@ export class PageAnalyzer {
491
491
  // PART 4: HANDLE GDPR/COOKIE CONSENT BANNERS
492
492
  // ═══════════════════════════════════════════════════════════════════════════════
493
493
  const consentAction = this.options.consentAction || 'accept';
494
- const consentTimeout = this.options.consentTimeout || 3000;
495
- await handleConsent(this.page, { action: consentAction, timeout: consentTimeout });
494
+ const consentTimeout = this.options.consentTimeout || 15000;
495
+ const continuousConsent = this.options.continuousConsent !== false; // true by default
496
+ await handleConsent(this.page, {
497
+ action: consentAction,
498
+ timeout: consentTimeout,
499
+ continuous: continuousConsent,
500
+ });
496
501
  // ═══════════════════════════════════════════════════════════════════════════════
497
502
  // INTELLIGENT SITE PROFILING (First page only)
498
503
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1045,7 +1050,8 @@ export class PageAnalyzer {
1045
1050
  return navigation;
1046
1051
  }
1047
1052
  /**
1048
- * Run accessibility scan using axe-core
1053
+ * Run accessibility scan using axe-core with native ARIA fallback
1054
+ * When CSP blocks external scripts, falls back to native ARIA inspection
1049
1055
  */
1050
1056
  async runAccessibilityScan() {
1051
1057
  try {
@@ -1095,6 +1101,119 @@ export class PageAnalyzer {
1095
1101
  incomplete: axeResults.incomplete?.length || 0,
1096
1102
  };
1097
1103
  }
1104
+ catch (error) {
1105
+ // CSP fallback: Native ARIA inspection when axe-core is blocked
1106
+ console.log(' ⚠️ axe-core blocked by CSP, using native ARIA inspection');
1107
+ return await this.runNativeAriaScan();
1108
+ }
1109
+ }
1110
+ /**
1111
+ * Native ARIA accessibility scan (fallback when axe-core is blocked)
1112
+ * Uses browser's built-in ARIA inspection capabilities
1113
+ */
1114
+ async runNativeAriaScan() {
1115
+ try {
1116
+ const results = await this.page.evaluate(() => {
1117
+ const violations = [];
1118
+ // Check 1: Images without alt text
1119
+ const imagesWithoutAlt = document.querySelectorAll('img:not([alt])');
1120
+ if (imagesWithoutAlt.length > 0) {
1121
+ violations.push({
1122
+ id: 'image-alt',
1123
+ impact: 'serious',
1124
+ description: 'Images must have alternate text',
1125
+ nodes: Array.from(imagesWithoutAlt).slice(0, 10).map(img => ({
1126
+ target: [img.tagName + (img.id ? `#${img.id}` : '')],
1127
+ })),
1128
+ });
1129
+ }
1130
+ // Check 2: Links without accessible name
1131
+ const linksWithoutText = Array.from(document.querySelectorAll('a')).filter(a => !a.textContent?.trim() && !a.getAttribute('aria-label') && !a.getAttribute('aria-labelledby'));
1132
+ if (linksWithoutText.length > 0) {
1133
+ violations.push({
1134
+ id: 'link-name',
1135
+ impact: 'serious',
1136
+ description: 'Links must have discernible text',
1137
+ nodes: linksWithoutText.slice(0, 10).map(a => ({
1138
+ target: [`a${a.id ? `#${a.id}` : ''}`],
1139
+ })),
1140
+ });
1141
+ }
1142
+ // Check 3: Form fields without labels
1143
+ const inputsWithoutLabels = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => {
1144
+ if (el.type === 'hidden')
1145
+ return false;
1146
+ const id = el.id;
1147
+ const hasLabel = document.querySelector(`label[for="${id}"]`);
1148
+ const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
1149
+ const hasParentLabel = el.closest('label');
1150
+ return !hasLabel && !hasAriaLabel && !hasParentLabel;
1151
+ });
1152
+ if (inputsWithoutLabels.length > 0) {
1153
+ violations.push({
1154
+ id: 'label',
1155
+ impact: 'serious',
1156
+ description: 'Form fields must have labels',
1157
+ nodes: inputsWithoutLabels.slice(0, 10).map(el => ({
1158
+ target: [`${el.tagName}${el.id ? `#${el.id}` : ''}`],
1159
+ })),
1160
+ });
1161
+ }
1162
+ // Check 4: Buttons without accessible name
1163
+ const buttonsWithoutText = Array.from(document.querySelectorAll('button')).filter(btn => !btn.textContent?.trim() && !btn.getAttribute('aria-label') && !btn.getAttribute('aria-labelledby'));
1164
+ if (buttonsWithoutText.length > 0) {
1165
+ violations.push({
1166
+ id: 'button-name',
1167
+ impact: 'serious',
1168
+ description: 'Buttons must have discernible text',
1169
+ nodes: buttonsWithoutText.slice(0, 10).map(btn => ({
1170
+ target: [`button${btn.id ? `#${btn.id}` : ''}`],
1171
+ })),
1172
+ });
1173
+ }
1174
+ // Check 5: Empty headings
1175
+ const emptyHeadings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).filter(h => !h.textContent?.trim());
1176
+ if (emptyHeadings.length > 0) {
1177
+ violations.push({
1178
+ id: 'empty-heading',
1179
+ impact: 'moderate',
1180
+ description: 'Headings must not be empty',
1181
+ nodes: emptyHeadings.slice(0, 10).map(h => ({
1182
+ target: [`${h.tagName}${h.id ? `#${h.id}` : ''}`],
1183
+ })),
1184
+ });
1185
+ }
1186
+ // Count positive checks (passes)
1187
+ const allImages = document.querySelectorAll('img').length;
1188
+ const allLinks = document.querySelectorAll('a').length;
1189
+ const allButtons = document.querySelectorAll('button').length;
1190
+ const allInputs = document.querySelectorAll('input:not([type="hidden"]), select, textarea').length;
1191
+ const allHeadings = document.querySelectorAll('h1, h2, h3, h4, h5, h6').length;
1192
+ const passes = (allImages - imagesWithoutAlt.length) +
1193
+ (allLinks - linksWithoutText.length) +
1194
+ (allButtons - buttonsWithoutText.length) +
1195
+ (allInputs - inputsWithoutLabels.length) +
1196
+ (allHeadings - emptyHeadings.length);
1197
+ return { violations, passes };
1198
+ });
1199
+ // Calculate score from native scan results
1200
+ const criticalCount = results.violations.filter((v) => v.impact === 'critical').length;
1201
+ const seriousCount = results.violations.filter((v) => v.impact === 'serious').length;
1202
+ const moderateCount = results.violations.filter((v) => v.impact === 'moderate').length;
1203
+ const score = Math.max(0, 100 - (criticalCount * 25 + seriousCount * 10 + moderateCount * 5));
1204
+ return {
1205
+ score: Math.round(score),
1206
+ violations: results.violations.map((v) => ({
1207
+ id: v.id,
1208
+ impact: v.impact || 'moderate',
1209
+ description: v.description,
1210
+ nodes: v.nodes?.length || 0,
1211
+ selectors: (v.nodes || []).slice(0, 5).map((n) => n.target?.[0] || '').filter(Boolean),
1212
+ })),
1213
+ passes: results.passes || 0,
1214
+ incomplete: 0,
1215
+ };
1216
+ }
1098
1217
  catch {
1099
1218
  return undefined;
1100
1219
  }
@@ -1147,6 +1266,10 @@ export class PageAnalyzer {
1147
1266
  */
1148
1267
  async cleanup() {
1149
1268
  try {
1269
+ // Stop continuous consent monitoring
1270
+ if (this.page) {
1271
+ ConsentHandler.stopContinuousMonitoring(this.page);
1272
+ }
1150
1273
  if (this.page)
1151
1274
  await this.page.close();
1152
1275
  if (this.context)
@@ -88,7 +88,21 @@ function generatePackFromCrawlResult(crawlResult, options) {
88
88
  // Only include HTML pages (filter out CSS, JS, images, etc.)
89
89
  return !/\.(css|js|json|xml|pdf|zip|jpg|jpeg|png|gif|svg|ico|woff|woff2|ttf|eot)$/i.test(url);
90
90
  });
91
- const pages = htmlPages.map(p => `${baseUrl}${p.path.startsWith('/') ? '' : '/'}${p.path}`);
91
+ // Build pages array - use full URL if available, otherwise construct from baseUrl + path
92
+ const pages = htmlPages.map(p => {
93
+ // If page has a full URL, use it directly
94
+ if (p.url && p.url.startsWith('http')) {
95
+ return p.url;
96
+ }
97
+ // Otherwise, construct from baseUrl (being careful not to duplicate paths)
98
+ const pagePath = p.path;
99
+ // If baseUrl already ends with the page path, just use baseUrl
100
+ if (baseUrl.endsWith(pagePath) || pagePath === '/' || pagePath === '') {
101
+ return baseUrl;
102
+ }
103
+ // Otherwise append the path
104
+ return `${baseUrl}${pagePath.startsWith('/') ? '' : '/'}${pagePath}`;
105
+ });
92
106
  // Generate YAML (v2 format)
93
107
  let yaml = `# QA360 Pack v2 - Generated by crawler
94
108
  # Source: ${options.baseUrl}
@@ -136,39 +150,43 @@ ${pages.map(p => ` - "${p}"`).join('\n')}
136
150
  const gateName = journey.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
137
151
  // Build page objects with actions from journey steps
138
152
  const pageMap = new Map();
153
+ let currentUrl = journey.entryPoint;
154
+ // Initialize with entry point
155
+ const absoluteEntryPoint = currentUrl.startsWith('http') ? currentUrl : `${baseUrl}${currentUrl.startsWith('/') ? '' : '/'}${currentUrl}`;
156
+ pageMap.set(absoluteEntryPoint, { url: absoluteEntryPoint, actions: [] });
139
157
  for (const step of journey.steps) {
140
158
  const stepTyped = step;
141
- const url = stepTyped.expected?.url || stepTyped.url;
142
- if (url && url !== 'undefined') {
143
- // Convert relative URLs to absolute
144
- const absoluteUrl = url.startsWith('http') ? url : `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
145
- if (!pageMap.has(absoluteUrl)) {
146
- pageMap.set(absoluteUrl, { url: absoluteUrl, actions: [] });
159
+ const actionType = stepTyped.action || 'navigate';
160
+ // For navigate actions, update current URL from step.value
161
+ if (actionType === 'navigate') {
162
+ const navigateUrl = stepTyped.value || stepTyped.expected?.url || stepTyped.url;
163
+ if (navigateUrl && navigateUrl !== 'undefined') {
164
+ currentUrl = navigateUrl.startsWith('http') ? navigateUrl : `${baseUrl}${navigateUrl.startsWith('/') ? '' : '/'}${navigateUrl}`;
165
+ if (!pageMap.has(currentUrl)) {
166
+ pageMap.set(currentUrl, { url: currentUrl, actions: [] });
167
+ }
147
168
  }
148
- // Add action to the page
149
- const page = pageMap.get(absoluteUrl);
150
- const actionType = stepTyped.action || 'navigate';
169
+ }
170
+ // Add action to the current page
171
+ if (pageMap.has(currentUrl)) {
172
+ const page = pageMap.get(currentUrl);
151
173
  // Map journey actions to page actions
152
174
  const pageAction = { type: actionType };
153
175
  if (stepTyped.selector) {
154
176
  pageAction.selector = stepTyped.selector;
155
177
  }
156
- if (stepTyped.value) {
178
+ if (stepTyped.value && actionType !== 'navigate') {
157
179
  pageAction.value = stepTyped.value;
158
180
  }
159
181
  if (stepTyped.wait) {
160
182
  pageAction.options = { timeout: stepTyped.wait };
161
183
  }
162
- // Add action if it's meaningful (skip simple navigation)
163
- if (actionType !== 'navigate' || stepTyped.selector || stepTyped.value) {
184
+ // Add action if it's meaningful (skip empty navigate)
185
+ if (actionType !== 'navigate' || stepTyped.selector) {
164
186
  page.actions.push(pageAction);
165
187
  }
166
188
  }
167
189
  }
168
- // If no pages found, use entry point
169
- if (pageMap.size === 0) {
170
- pageMap.set(journey.entryPoint, { url: journey.entryPoint, actions: [] });
171
- }
172
190
  const pages = Array.from(pageMap.values());
173
191
  // Generate YAML for pages with actions
174
192
  if (pages.every((p) => p.actions.length === 0)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qa360",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "QA360 Proof CLI - Quality as Cryptographic Proof",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",