qa360 2.3.1 → 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/commands/ask.js +49 -22
- package/dist/commands/coverage.js +16 -3
- package/dist/commands/generate.js +11 -4
- package/dist/commands/ollama.js +13 -5
- package/dist/commands/run.d.ts +4 -0
- package/dist/commands/run.js +86 -0
- package/dist/core/adapters/playwright-ui.d.ts +6 -0
- package/dist/core/adapters/playwright-ui.js +85 -1
- package/dist/core/ai/ollama-provider.d.ts +4 -0
- package/dist/core/ai/ollama-provider.js +27 -6
- 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/dist/index.js +2 -3
- package/dist/utils/config.js +36 -3
- package/package.json +1 -1
package/dist/commands/ask.js
CHANGED
|
@@ -184,33 +184,42 @@ export class QA360Ask {
|
|
|
184
184
|
console.log(chalk.gray(`Analyse: "${query}"`));
|
|
185
185
|
// Detection de patterns simples (Phase 1 - deterministe)
|
|
186
186
|
const patterns = {
|
|
187
|
-
|
|
188
|
-
web: /\b(web|ui|interface|page|site|browser)\b/i,
|
|
189
|
-
performance: /\b(performance|perf|load|stress|speed|latency)\b/i,
|
|
187
|
+
complete: /\b(complete|full|comprehensive|end-to-end|e2e|suite|tout)\b/i,
|
|
190
188
|
security: /\b(security|sécurité|secu|dast|vuln|scan|pentest|zap)\b/i,
|
|
189
|
+
performance: /\b(performance|perf|load|stress|speed|latency|charge|charge)\b/i,
|
|
191
190
|
accessibility: /\b(accessibility|a11y|accessible|wcag)\b/i,
|
|
192
|
-
|
|
191
|
+
web: /\b(web|ui|interface|page|site|browser|login|auth|signin|signup|registration|connexion|inscription)\b/i,
|
|
192
|
+
api: /\b(api|rest|graphql|endpoint|swagger|openapi)\b/i
|
|
193
193
|
};
|
|
194
194
|
// URL extraction
|
|
195
195
|
const urlMatch = query.match(/https?:\/\/[^\s]+/);
|
|
196
196
|
const target = urlMatch ? urlMatch[0] : 'https://httpbin.org';
|
|
197
|
-
// Type detection with priority
|
|
198
|
-
let type = '
|
|
199
|
-
// Check
|
|
197
|
+
// Type detection with priority order
|
|
198
|
+
let type = 'web'; // default to web (UI) for generic queries like "login tests"
|
|
199
|
+
// Check patterns in priority order
|
|
200
200
|
if (patterns.complete.test(query)) {
|
|
201
201
|
type = 'complete';
|
|
202
202
|
}
|
|
203
203
|
else if (patterns.security.test(query)) {
|
|
204
204
|
type = 'security';
|
|
205
205
|
}
|
|
206
|
-
else {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
206
|
+
else if (patterns.performance.test(query)) {
|
|
207
|
+
type = 'performance';
|
|
208
|
+
}
|
|
209
|
+
else if (patterns.accessibility.test(query)) {
|
|
210
|
+
type = 'accessibility';
|
|
211
|
+
}
|
|
212
|
+
else if (patterns.web.test(query)) {
|
|
213
|
+
type = 'web';
|
|
214
|
+
}
|
|
215
|
+
else if (patterns.api.test(query)) {
|
|
216
|
+
type = 'api';
|
|
217
|
+
}
|
|
218
|
+
// If no pattern matched, default to 'web' for login-like scenarios, otherwise 'api'
|
|
219
|
+
// Special handling for login/auth scenarios - default to web (UI) tests
|
|
220
|
+
const loginKeywords = /\b(login|auth|authentication|signin|signup|register|connexion|inscription|form)\b/i;
|
|
221
|
+
if (loginKeywords.test(query) && type === 'api') {
|
|
222
|
+
type = 'web';
|
|
214
223
|
}
|
|
215
224
|
// Pack name from query
|
|
216
225
|
const name = query.toLowerCase()
|
|
@@ -316,6 +325,29 @@ export class QA360Ask {
|
|
|
316
325
|
}
|
|
317
326
|
generateWebPack(name, description, baseUrl, authConfig) {
|
|
318
327
|
const auth = buildAuthConfig(authConfig);
|
|
328
|
+
// Check if this is a login/auth scenario based on name or description
|
|
329
|
+
const isLoginScenario = /\b(login|auth|signin|signup|connexion|inscription)\b/i.test(name + ' ' + description);
|
|
330
|
+
// Build pages based on scenario type
|
|
331
|
+
const pages = isLoginScenario ? [
|
|
332
|
+
{
|
|
333
|
+
url: '/login',
|
|
334
|
+
expectedElements: ['input[type="email"]', 'input[type="password"]', 'button[type="submit"]'],
|
|
335
|
+
actions: [
|
|
336
|
+
{ type: 'fill', selector: 'input[type="email"]', value: '${TEST_EMAIL}' },
|
|
337
|
+
{ type: 'fill', selector: 'input[type="password"]', value: '${TEST_PASSWORD}' },
|
|
338
|
+
{ type: 'click', selector: 'button[type="submit"]' }
|
|
339
|
+
]
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
url: '/',
|
|
343
|
+
expectedElements: ['header', 'nav']
|
|
344
|
+
}
|
|
345
|
+
] : [
|
|
346
|
+
{
|
|
347
|
+
url: '/',
|
|
348
|
+
expectedElements: ['body', 'main']
|
|
349
|
+
}
|
|
350
|
+
];
|
|
319
351
|
return {
|
|
320
352
|
version: 2,
|
|
321
353
|
name,
|
|
@@ -328,12 +360,7 @@ export class QA360Ask {
|
|
|
328
360
|
auth: auth.profileName,
|
|
329
361
|
config: {
|
|
330
362
|
baseUrl,
|
|
331
|
-
pages
|
|
332
|
-
{
|
|
333
|
-
url: '/',
|
|
334
|
-
expectedElements: ['body', 'main']
|
|
335
|
-
}
|
|
336
|
-
]
|
|
363
|
+
pages
|
|
337
364
|
}
|
|
338
365
|
},
|
|
339
366
|
'a11y': {
|
|
@@ -343,7 +370,7 @@ export class QA360Ask {
|
|
|
343
370
|
baseUrl,
|
|
344
371
|
pages: [
|
|
345
372
|
{
|
|
346
|
-
url: '/',
|
|
373
|
+
url: isLoginScenario ? '/login' : '/',
|
|
347
374
|
a11yRules: ['wcag2a', 'wcag2aa']
|
|
348
375
|
}
|
|
349
376
|
]
|
|
@@ -18,7 +18,14 @@ coverageCommand
|
|
|
18
18
|
.option('--verbose', 'Show detailed file-by-file coverage')
|
|
19
19
|
.action(async (options) => {
|
|
20
20
|
if (!options.file && !options.run) {
|
|
21
|
-
console.error('Error: Please specify --file or --run');
|
|
21
|
+
console.error('Error: Please specify --file or --run to show coverage\n');
|
|
22
|
+
console.error('Usage: qa360 coverage show --file <path>');
|
|
23
|
+
console.error(' or: qa360 coverage show --run <id>\n');
|
|
24
|
+
console.error('Examples:');
|
|
25
|
+
console.error(' qa360 coverage show --file coverage/coverage-final.json');
|
|
26
|
+
console.error(' qa360 coverage show --file coverage/lcov.info');
|
|
27
|
+
console.error(' qa360 coverage show --run abc123\n');
|
|
28
|
+
console.error('Tip: Use "qa360 coverage list" to find available coverage files in your project.');
|
|
22
29
|
process.exit(1);
|
|
23
30
|
}
|
|
24
31
|
const collector = new CoverageCollector();
|
|
@@ -82,7 +89,10 @@ coverageCommand
|
|
|
82
89
|
.option('--json', 'Output as JSON')
|
|
83
90
|
.action(async (options) => {
|
|
84
91
|
if (!options.file) {
|
|
85
|
-
console.error('Error: Please specify --file');
|
|
92
|
+
console.error('Error: Please specify --file to analyze coverage\n');
|
|
93
|
+
console.error('Usage: qa360 coverage analyze --file <path>\n');
|
|
94
|
+
console.error('Example: qa360 coverage analyze --file coverage/coverage-final.json\n');
|
|
95
|
+
console.error('Tip: Use "qa360 coverage list" to find available coverage files in your project.');
|
|
86
96
|
process.exit(1);
|
|
87
97
|
}
|
|
88
98
|
if (!existsSync(options.file)) {
|
|
@@ -218,7 +228,10 @@ coverageCommand
|
|
|
218
228
|
.option('--fail', 'Exit with error code if thresholds not met')
|
|
219
229
|
.action(async (options) => {
|
|
220
230
|
if (!options.file) {
|
|
221
|
-
console.error('Error: Please specify --file');
|
|
231
|
+
console.error('Error: Please specify --file to check coverage\n');
|
|
232
|
+
console.error('Usage: qa360 coverage check --file <path>\n');
|
|
233
|
+
console.error('Example: qa360 coverage check --file coverage/coverage-final.json\n');
|
|
234
|
+
console.error('Tip: Use "qa360 coverage list" to find available coverage files in your project.');
|
|
222
235
|
process.exit(1);
|
|
223
236
|
}
|
|
224
237
|
if (!existsSync(options.file)) {
|
|
@@ -91,10 +91,17 @@ export async function generateCommand(source, options = {}) {
|
|
|
91
91
|
const { available, models, recommended } = await checkGenerationAvailability();
|
|
92
92
|
if (!available) {
|
|
93
93
|
spinner.fail('Ollama not available');
|
|
94
|
-
console.log(chalk.red('\
|
|
95
|
-
console.log(chalk.yellow('\
|
|
96
|
-
console.log(chalk.gray('
|
|
97
|
-
console.log(chalk.
|
|
94
|
+
console.log(chalk.red('\n❌ Ollama is not running or not reachable'));
|
|
95
|
+
console.log(chalk.yellow('\n🔧 Troubleshooting:'));
|
|
96
|
+
console.log(chalk.gray(' 1. Check if Ollama is running:'));
|
|
97
|
+
console.log(chalk.cyan(' ollama list'));
|
|
98
|
+
console.log(chalk.gray(' 2. Start Ollama if needed:'));
|
|
99
|
+
console.log(chalk.cyan(' ollama serve'));
|
|
100
|
+
console.log(chalk.gray(' 3. Test QA360 connection to Ollama:'));
|
|
101
|
+
console.log(chalk.cyan(' qa360 ollama test'));
|
|
102
|
+
console.log(chalk.gray(' 4. Set custom Ollama URL if needed:'));
|
|
103
|
+
console.log(chalk.cyan(' qa360 ollama config --set-url http://localhost:11434'));
|
|
104
|
+
console.log(chalk.yellow('\n📦 Install Ollama:'));
|
|
98
105
|
console.log(chalk.gray(' brew install ollama # macOS'));
|
|
99
106
|
console.log(chalk.gray(' # Or visit: https://ollama.com\n'));
|
|
100
107
|
return;
|
package/dist/commands/ollama.js
CHANGED
|
@@ -19,12 +19,20 @@ export async function ollamaTestCommand(options = {}) {
|
|
|
19
19
|
const check = await checkOllamaSetup(model);
|
|
20
20
|
if (!check.available) {
|
|
21
21
|
spinner.fail('Ollama is not available');
|
|
22
|
-
console.log(chalk.red('\n❌ Ollama is not running or not
|
|
22
|
+
console.log(chalk.red('\n❌ Ollama is not running or not reachable'));
|
|
23
|
+
console.log(chalk.yellow('\n🔧 Troubleshooting steps:'));
|
|
24
|
+
console.log(chalk.gray('\n 1. Verify Ollama is running:'));
|
|
25
|
+
console.log(chalk.cyan(' ollama list'));
|
|
26
|
+
console.log(chalk.gray('\n 2. Start Ollama if not running:'));
|
|
27
|
+
console.log(chalk.cyan(' ollama serve'));
|
|
28
|
+
console.log(chalk.gray('\n 3. Check Ollama is listening on default port:'));
|
|
29
|
+
console.log(chalk.cyan(' curl http://localhost:11434/api/tags'));
|
|
30
|
+
console.log(chalk.gray('\n 4. Set custom URL if Ollama is on different port/host:'));
|
|
31
|
+
console.log(chalk.cyan(' qa360 ollama config --set-url http://localhost:11434'));
|
|
32
|
+
console.log(chalk.gray('\n 5. Check for firewall/proxy issues blocking localhost:11434'));
|
|
23
33
|
console.log(chalk.yellow('\n📦 To install Ollama:'));
|
|
24
34
|
console.log(chalk.gray(' brew install ollama # macOS'));
|
|
25
|
-
console.log(chalk.gray(' # Or visit: https://ollama.com'));
|
|
26
|
-
console.log(chalk.yellow('\n🚀 To start Ollama:'));
|
|
27
|
-
console.log(chalk.gray(' ollama serve'));
|
|
35
|
+
console.log(chalk.gray(' # Or visit: https://ollama.com\n'));
|
|
28
36
|
return;
|
|
29
37
|
}
|
|
30
38
|
spinner.succeed('Ollama is running!');
|
|
@@ -175,7 +183,7 @@ export async function ollamaGenerateCommand(prompt, options = {}) {
|
|
|
175
183
|
export async function ollamaConfigCommand(options = {}) {
|
|
176
184
|
const config = loadQA360Config();
|
|
177
185
|
const ollamaConfig = config.ollama || {};
|
|
178
|
-
const defaultBaseUrl = 'http://
|
|
186
|
+
const defaultBaseUrl = 'http://127.0.0.1:11434';
|
|
179
187
|
const defaultModel = 'deepseek-coder';
|
|
180
188
|
// Update config if options provided
|
|
181
189
|
if (options.setUrl || options.setModel) {
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -39,3 +39,7 @@ export declare function displayResults(result: Phase3RunResult): void;
|
|
|
39
39
|
*/
|
|
40
40
|
export declare function runCommand(packArg?: string, options?: RunOptions): Promise<void>;
|
|
41
41
|
export default runCommand;
|
|
42
|
+
/**
|
|
43
|
+
* Preflight command - Validate pack without executing tests
|
|
44
|
+
*/
|
|
45
|
+
export declare function preflightCommand(packArg?: string): Promise<void>;
|
package/dist/commands/run.js
CHANGED
|
@@ -170,3 +170,89 @@ export async function runCommand(packArg, options = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
// Export for CLI integration
|
|
172
172
|
export default runCommand;
|
|
173
|
+
/**
|
|
174
|
+
* Preflight command - Validate pack without executing tests
|
|
175
|
+
*/
|
|
176
|
+
export async function preflightCommand(packArg) {
|
|
177
|
+
try {
|
|
178
|
+
console.log(chalk.blue('\n🔍 QA360 Preflight - Dry-run validation\n'));
|
|
179
|
+
// Step 1: Find pack file
|
|
180
|
+
const packPath = findPackFile(packArg);
|
|
181
|
+
console.log(chalk.gray(`Pack file: ${packPath}`));
|
|
182
|
+
// Step 2: Load and validate pack
|
|
183
|
+
const pack = await loadPack(packPath);
|
|
184
|
+
console.log(chalk.green(`✅ Pack: ${pack.name} v${pack.version}`));
|
|
185
|
+
// Step 3: Validate structure
|
|
186
|
+
const issues = [];
|
|
187
|
+
// Check required fields
|
|
188
|
+
if (!pack.name) {
|
|
189
|
+
issues.push('Missing required field: name');
|
|
190
|
+
}
|
|
191
|
+
if (!pack.version) {
|
|
192
|
+
issues.push('Missing required field: version');
|
|
193
|
+
}
|
|
194
|
+
if (!pack.gates || Object.keys(pack.gates).length === 0) {
|
|
195
|
+
issues.push('No gates defined - nothing to test');
|
|
196
|
+
}
|
|
197
|
+
// Validate gates
|
|
198
|
+
const gateNames = Object.keys(pack.gates || {});
|
|
199
|
+
if (gateNames.length > 0) {
|
|
200
|
+
console.log(chalk.gray(`\nGates (${gateNames.length}):`));
|
|
201
|
+
for (const [gateName, gateConfig] of Object.entries(pack.gates || {})) {
|
|
202
|
+
const gate = gateConfig;
|
|
203
|
+
const status = gate.enabled === false ? chalk.yellow('(disabled)') : chalk.green('(enabled)');
|
|
204
|
+
console.log(` ${status} ${chalk.cyan(gateName)}`);
|
|
205
|
+
if (!gate.adapter) {
|
|
206
|
+
issues.push(` ⚠️ Gate "${gateName}" missing adapter`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
console.log(` Adapter: ${gate.adapter}`);
|
|
210
|
+
}
|
|
211
|
+
// Show pages/config summary
|
|
212
|
+
if (gate.config?.pages) {
|
|
213
|
+
const pages = gate.config.pages;
|
|
214
|
+
const pageCount = Array.isArray(pages) ? pages.length : Object.keys(pages).length;
|
|
215
|
+
console.log(` Pages: ${pageCount}`);
|
|
216
|
+
}
|
|
217
|
+
if (gate.config?.smoke) {
|
|
218
|
+
const smoke = gate.config.smoke;
|
|
219
|
+
const testCount = Array.isArray(smoke) ? smoke.length : 1;
|
|
220
|
+
console.log(` Smoke tests: ${testCount}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Validate auth config (only for v2 packs)
|
|
225
|
+
if (pack.version === 2 && pack.auth) {
|
|
226
|
+
const auth = pack.auth;
|
|
227
|
+
console.log(chalk.gray('\nAuthentication:'));
|
|
228
|
+
if (auth.api) {
|
|
229
|
+
console.log(` API: ${auth.api}`);
|
|
230
|
+
}
|
|
231
|
+
if (auth.ui) {
|
|
232
|
+
console.log(` UI: ${auth.ui}`);
|
|
233
|
+
}
|
|
234
|
+
if (auth.profiles) {
|
|
235
|
+
const profileNames = Object.keys(auth.profiles);
|
|
236
|
+
console.log(` Profiles: ${profileNames.join(', ') || 'none'}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Summary
|
|
240
|
+
console.log(chalk.bold('\n' + '─'.repeat(50)));
|
|
241
|
+
if (issues.length === 0) {
|
|
242
|
+
console.log(chalk.green('✅ Preflight check passed'));
|
|
243
|
+
console.log(chalk.gray(' Run "qa360 run" to execute tests'));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
console.log(chalk.yellow('⚠️ Issues found:'));
|
|
247
|
+
for (const issue of issues) {
|
|
248
|
+
console.log(chalk.yellow(` ${issue}`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
console.log('');
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
console.error(chalk.red('\n❌ Preflight validation failed:'));
|
|
255
|
+
console.error(chalk.red(` ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -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
|
}
|
|
@@ -26,6 +26,10 @@ export declare class OllamaProvider implements LLMProvider {
|
|
|
26
26
|
private readonly timeout;
|
|
27
27
|
constructor(config?: OllamaConfig);
|
|
28
28
|
isAvailable(): Promise<boolean>;
|
|
29
|
+
/**
|
|
30
|
+
* Ensure URL has valid format (http:// prefix)
|
|
31
|
+
*/
|
|
32
|
+
private ensureValidUrl;
|
|
29
33
|
generate(request: GenerationRequest): Promise<GenerationResponse>;
|
|
30
34
|
stream(request: GenerationRequest): AsyncIterable<string>;
|
|
31
35
|
countTokens(text: string): number;
|
|
@@ -26,17 +26,20 @@ export class OllamaProvider {
|
|
|
26
26
|
defaultModel;
|
|
27
27
|
timeout;
|
|
28
28
|
constructor(config = {}) {
|
|
29
|
-
|
|
29
|
+
// Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues
|
|
30
|
+
this.baseUrl = config.baseUrl || process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434';
|
|
30
31
|
this.defaultModel = config.model || process.env.OLLAMA_MODEL || 'deepseek-coder';
|
|
31
32
|
this.timeout = config.timeout || 120000; // 2 minutes default
|
|
32
33
|
}
|
|
33
34
|
async isAvailable() {
|
|
34
35
|
try {
|
|
36
|
+
// Ensure URL has proper format
|
|
37
|
+
const url = this.ensureValidUrl(this.baseUrl);
|
|
35
38
|
// Use a longer timeout (15 seconds) to accommodate slower systems
|
|
36
39
|
// Ollama can take time to respond, especially on first run or with many models
|
|
37
40
|
const controller = new AbortController();
|
|
38
41
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
|
39
|
-
const response = await fetch(`${
|
|
42
|
+
const response = await fetch(`${url}/api/tags`, {
|
|
40
43
|
signal: controller.signal,
|
|
41
44
|
});
|
|
42
45
|
clearTimeout(timeoutId);
|
|
@@ -49,21 +52,37 @@ export class OllamaProvider {
|
|
|
49
52
|
// Timeout - Ollama is slow to respond
|
|
50
53
|
console.debug(`[Ollama] Connection to ${this.baseUrl} timed out after 15s`);
|
|
51
54
|
}
|
|
55
|
+
else if (error.message.includes('ECONNREFUSED')) {
|
|
56
|
+
console.debug(`[Ollama] Connection refused - Ollama may not be running at ${this.baseUrl}`);
|
|
57
|
+
}
|
|
58
|
+
else if (error.message.includes('ENOTFOUND')) {
|
|
59
|
+
console.debug(`[Ollama] Host not found - check Ollama URL: ${this.baseUrl}`);
|
|
60
|
+
}
|
|
52
61
|
else {
|
|
53
|
-
// Other error
|
|
62
|
+
// Other error
|
|
54
63
|
console.debug(`[Ollama] Connection error: ${error.message}`);
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
return false;
|
|
58
67
|
}
|
|
59
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Ensure URL has valid format (http:// prefix)
|
|
71
|
+
*/
|
|
72
|
+
ensureValidUrl(url) {
|
|
73
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
74
|
+
return `http://${url}`;
|
|
75
|
+
}
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
60
78
|
async generate(request) {
|
|
61
79
|
const fullPrompt = this.buildFullPrompt(request);
|
|
80
|
+
const url = this.ensureValidUrl(this.baseUrl);
|
|
62
81
|
// Use timeout with AbortController for compatibility
|
|
63
82
|
const controller = new AbortController();
|
|
64
83
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
65
84
|
try {
|
|
66
|
-
const response = await fetch(`${
|
|
85
|
+
const response = await fetch(`${url}/api/generate`, {
|
|
67
86
|
method: 'POST',
|
|
68
87
|
headers: { 'Content-Type': 'application/json' },
|
|
69
88
|
signal: controller.signal,
|
|
@@ -101,7 +120,8 @@ export class OllamaProvider {
|
|
|
101
120
|
}
|
|
102
121
|
async *stream(request) {
|
|
103
122
|
const fullPrompt = this.buildFullPrompt(request);
|
|
104
|
-
const
|
|
123
|
+
const url = this.ensureValidUrl(this.baseUrl);
|
|
124
|
+
const response = await fetch(`${url}/api/generate`, {
|
|
105
125
|
method: 'POST',
|
|
106
126
|
headers: { 'Content-Type': 'application/json' },
|
|
107
127
|
body: JSON.stringify({
|
|
@@ -153,7 +173,8 @@ export class OllamaProvider {
|
|
|
153
173
|
* List available models from Ollama
|
|
154
174
|
*/
|
|
155
175
|
async listModels() {
|
|
156
|
-
const
|
|
176
|
+
const url = this.ensureValidUrl(this.baseUrl);
|
|
177
|
+
const response = await fetch(`${url}/api/tags`);
|
|
157
178
|
if (!response.ok) {
|
|
158
179
|
throw new OllamaError('Failed to list models');
|
|
159
180
|
}
|
|
@@ -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)) {
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import { doctorCommand } from './commands/doctor.js';
|
|
|
35
35
|
import { askCommand } from './commands/ask.js';
|
|
36
36
|
import { initCommand } from './commands/init.js';
|
|
37
37
|
import { examplesListCommand, examplesCopyCommand, examplesShowCommand } from './commands/examples.js';
|
|
38
|
-
import { runCommand } from './commands/run.js';
|
|
38
|
+
import { runCommand, preflightCommand } from './commands/run.js';
|
|
39
39
|
import { reportCommand } from './commands/report.js';
|
|
40
40
|
import { verifyCommand } from './commands/verify.js';
|
|
41
41
|
import { explainCommand } from './commands/explain.js';
|
|
@@ -125,8 +125,7 @@ program
|
|
|
125
125
|
.command('preflight [pack]')
|
|
126
126
|
.description('Dry-run validation without execution')
|
|
127
127
|
.action(async (pack) => {
|
|
128
|
-
|
|
129
|
-
console.log(chalk.yellow('⚠️ Phase 2 implementation coming soon...'));
|
|
128
|
+
await preflightCommand(pack);
|
|
130
129
|
});
|
|
131
130
|
program
|
|
132
131
|
.command('report')
|
package/dist/utils/config.js
CHANGED
|
@@ -109,7 +109,31 @@ function parseYaml(content) {
|
|
|
109
109
|
if (listMatch) {
|
|
110
110
|
const value = listMatch[1].trim();
|
|
111
111
|
if (Array.isArray(parent)) {
|
|
112
|
-
|
|
112
|
+
// Check if this is an object item (key: value) or a simple value
|
|
113
|
+
const colonIdx = value.indexOf(':');
|
|
114
|
+
if (colonIdx > 0 && !value.startsWith("'") && !value.startsWith('"')) {
|
|
115
|
+
// Object item like "- url: /" or "- key: value"
|
|
116
|
+
const objKey = value.slice(0, colonIdx).trim();
|
|
117
|
+
let objValue = value.slice(colonIdx + 1).trim();
|
|
118
|
+
// Check if next line is more indented (nested object properties)
|
|
119
|
+
if (i < lines.length - 1) {
|
|
120
|
+
const nextIndent = lines[i + 1].search(/\S|$/);
|
|
121
|
+
if (nextIndent > indent) {
|
|
122
|
+
// This is the start of a nested object
|
|
123
|
+
const obj = {};
|
|
124
|
+
obj[objKey] = parseValue(objValue);
|
|
125
|
+
parent.push(obj);
|
|
126
|
+
stack.push({ obj, indent });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Single-line object item
|
|
131
|
+
parent.push({ [objKey]: parseValue(objValue) });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Simple value item
|
|
135
|
+
parent.push(parseValue(value));
|
|
136
|
+
}
|
|
113
137
|
}
|
|
114
138
|
continue;
|
|
115
139
|
}
|
|
@@ -144,6 +168,9 @@ function parseYaml(content) {
|
|
|
144
168
|
if (i < lines.length - 1) {
|
|
145
169
|
const nextIndent = lines[i + 1].search(/\S|$/);
|
|
146
170
|
if (nextIndent > indent) {
|
|
171
|
+
const nextLine = lines[i + 1].trim();
|
|
172
|
+
// Check if next line starts a list (array) or a key-value (object)
|
|
173
|
+
const isList = nextLine.startsWith('-');
|
|
147
174
|
if (Array.isArray(parent[key])) {
|
|
148
175
|
// Already an array
|
|
149
176
|
}
|
|
@@ -151,12 +178,18 @@ function parseYaml(content) {
|
|
|
151
178
|
// Nested object
|
|
152
179
|
stack.push({ obj: parent[key], indent });
|
|
153
180
|
}
|
|
154
|
-
else {
|
|
155
|
-
// Start of array
|
|
181
|
+
else if (isList) {
|
|
182
|
+
// Start of array (next line is a list item)
|
|
156
183
|
const arr = [];
|
|
157
184
|
parent[key] = arr;
|
|
158
185
|
stack.push({ obj: arr, indent });
|
|
159
186
|
}
|
|
187
|
+
else {
|
|
188
|
+
// Start of object (next line is a key-value pair)
|
|
189
|
+
const obj = {};
|
|
190
|
+
parent[key] = obj;
|
|
191
|
+
stack.push({ obj, indent });
|
|
192
|
+
}
|
|
160
193
|
}
|
|
161
194
|
}
|
|
162
195
|
}
|