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.
- package/dist/core/adapters/playwright-ui.d.ts +6 -0
- package/dist/core/adapters/playwright-ui.js +85 -1
- package/dist/core/crawler/consent-handler.d.ts +17 -0
- package/dist/core/crawler/consent-handler.js +176 -10
- package/dist/core/crawler/intelligent-selector-generator.js +3 -1
- package/dist/core/crawler/journey-generator.js +56 -4
- package/dist/core/crawler/page-analyzer.d.ts +7 -1
- package/dist/core/crawler/page-analyzer.js +127 -4
- package/dist/core/generation/crawler-pack-generator.js +35 -17
- package/package.json +1 -1
|
@@ -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.
|
|
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("
|
|
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 =
|
|
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
|
|
158
|
-
if (
|
|
159
|
-
|
|
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
|
-
|
|
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 ||
|
|
495
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
163
|
-
if (actionType !== 'navigate' || stepTyped.selector
|
|
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)) {
|