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