@detiq/crawler 1.0.6 → 1.0.7
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/crawler.js +680 -38
- package/dist/crawler.js.map +3 -3
- package/dist/extractors/advanced.js +20 -0
- package/dist/extractors/advanced.js.map +2 -2
- package/dist/interactive-probe.js +334 -2
- package/dist/interactive-probe.js.map +3 -3
- package/dist/screen-reader.js +4 -3
- package/dist/screen-reader.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +758 -52
- package/src/extractors/advanced.ts +27 -1
- package/src/interactive-probe.ts +407 -3
- package/src/screen-reader.ts +12 -6
- package/types/extractors/advanced.d.ts +10 -0
- package/types/interactive-probe.d.ts +36 -0
|
@@ -17,11 +17,18 @@ export interface PageMetadata {
|
|
|
17
17
|
ogImage: string | null;
|
|
18
18
|
ogUrl: string | null;
|
|
19
19
|
ogType: string | null;
|
|
20
|
+
ogSiteName: string | null;
|
|
21
|
+
ogLocale: string | null;
|
|
20
22
|
twitterCard: string | null;
|
|
21
23
|
twitterTitle: string | null;
|
|
22
24
|
twitterDescription: string | null;
|
|
25
|
+
twitterImage: string | null;
|
|
26
|
+
twitterSite: string | null;
|
|
23
27
|
canonical: string | null;
|
|
24
28
|
robots: string | null;
|
|
29
|
+
hreflang: Array<{ lang: string; href: string }>;
|
|
30
|
+
themeColor: string | null;
|
|
31
|
+
favicon: string | null;
|
|
25
32
|
jsonLd: object[];
|
|
26
33
|
schemaTypes: string[];
|
|
27
34
|
lang: string | null;
|
|
@@ -109,8 +116,12 @@ export function extractMetadata(html: string, url: string): PageMetadata {
|
|
|
109
116
|
const meta: PageMetadata = {
|
|
110
117
|
title: null, description: null, keywords: null,
|
|
111
118
|
ogTitle: null, ogDescription: null, ogImage: null, ogUrl: null, ogType: null,
|
|
119
|
+
ogSiteName: null, ogLocale: null,
|
|
112
120
|
twitterCard: null, twitterTitle: null, twitterDescription: null,
|
|
113
|
-
|
|
121
|
+
twitterImage: null, twitterSite: null,
|
|
122
|
+
canonical: null, robots: null,
|
|
123
|
+
hreflang: [], themeColor: null, favicon: null,
|
|
124
|
+
jsonLd: [], schemaTypes: [], lang: null,
|
|
114
125
|
};
|
|
115
126
|
try {
|
|
116
127
|
const { document } = parseHTML(html);
|
|
@@ -128,16 +139,31 @@ export function extractMetadata(html: string, url: string): PageMetadata {
|
|
|
128
139
|
if (name === 'twitter:card') meta.twitterCard = content;
|
|
129
140
|
if (name === 'twitter:title') meta.twitterTitle = content;
|
|
130
141
|
if (name === 'twitter:description') meta.twitterDescription = content;
|
|
142
|
+
if (name === 'twitter:image') meta.twitterImage = content;
|
|
143
|
+
if (name === 'twitter:site') meta.twitterSite = content;
|
|
144
|
+
if (name === 'theme-color') meta.themeColor = content;
|
|
131
145
|
if (prop === 'og:title') meta.ogTitle = content;
|
|
132
146
|
if (prop === 'og:description') meta.ogDescription = content;
|
|
133
147
|
if (prop === 'og:image') meta.ogImage = content;
|
|
134
148
|
if (prop === 'og:url') meta.ogUrl = content;
|
|
135
149
|
if (prop === 'og:type') meta.ogType = content;
|
|
150
|
+
if (prop === 'og:site_name') meta.ogSiteName = content;
|
|
151
|
+
if (prop === 'og:locale') meta.ogLocale = content;
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
const canonical = (document as any).querySelector('link[rel="canonical"]');
|
|
139
155
|
if (canonical) meta.canonical = canonical.getAttribute('href') ?? null;
|
|
140
156
|
|
|
157
|
+
const faviconEl = (document as any).querySelector('link[rel="icon"], link[rel="shortcut icon"]');
|
|
158
|
+
if (faviconEl) meta.favicon = faviconEl.getAttribute('href') ?? null;
|
|
159
|
+
|
|
160
|
+
const hreflangEls = [...(document as any).querySelectorAll('link[rel="alternate"][hreflang]')];
|
|
161
|
+
for (const el of hreflangEls) {
|
|
162
|
+
const lang = (el as any).getAttribute('hreflang') ?? '';
|
|
163
|
+
const href = (el as any).getAttribute('href') ?? '';
|
|
164
|
+
if (lang && href) meta.hreflang.push({ lang, href });
|
|
165
|
+
}
|
|
166
|
+
|
|
141
167
|
const jsonLdScripts = [...(document as any).querySelectorAll('script[type="application/ld+json"]')];
|
|
142
168
|
for (const s of jsonLdScripts) {
|
|
143
169
|
try {
|
package/src/interactive-probe.ts
CHANGED
|
@@ -51,6 +51,41 @@ export interface ConditionalReveal {
|
|
|
51
51
|
revealedText: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export interface TooltipCapture {
|
|
55
|
+
triggerLabel: string;
|
|
56
|
+
tooltipText: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TabPanelCapture {
|
|
60
|
+
tabLabel: string;
|
|
61
|
+
panelContent: string;
|
|
62
|
+
panelHeading?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CarouselSlide {
|
|
66
|
+
slideIndex: number;
|
|
67
|
+
heading?: string;
|
|
68
|
+
content: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WizardStep {
|
|
72
|
+
stepIndex: number;
|
|
73
|
+
stepLabel?: string;
|
|
74
|
+
content: string;
|
|
75
|
+
fields: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FocusTrapResult {
|
|
79
|
+
modalTrigger: string;
|
|
80
|
+
focusTrapped: boolean;
|
|
81
|
+
sampledFocused: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ContextMenuCapture {
|
|
85
|
+
trigger: string;
|
|
86
|
+
items: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
export interface ProbeResult {
|
|
55
90
|
screenId: string;
|
|
56
91
|
url: string;
|
|
@@ -59,6 +94,13 @@ export interface ProbeResult {
|
|
|
59
94
|
modals: ModalObservation[];
|
|
60
95
|
conditionals: ConditionalReveal[];
|
|
61
96
|
loadingPatterns: string[];
|
|
97
|
+
tooltips?: TooltipCapture[];
|
|
98
|
+
tabPanels?: TabPanelCapture[];
|
|
99
|
+
carouselSlides?: CarouselSlide[];
|
|
100
|
+
wizardSteps?: WizardStep[];
|
|
101
|
+
focusTraps?: FocusTrapResult[];
|
|
102
|
+
skipNavIssues?: string[];
|
|
103
|
+
contextMenus?: ContextMenuCapture[];
|
|
62
104
|
}
|
|
63
105
|
|
|
64
106
|
// ── CSS selectors that reliably find error messages across frameworks ────────
|
|
@@ -170,7 +212,7 @@ async function findModalTriggers(page: Page): Promise<Array<{ selector: string;
|
|
|
170
212
|
while (p) { if (NAV_PARENTS.has(p.tagName)) return false; p = p.parentElement; }
|
|
171
213
|
return true;
|
|
172
214
|
})
|
|
173
|
-
.slice(0,
|
|
215
|
+
.slice(0, 20) // probe at most 20 buttons per screen
|
|
174
216
|
.map((el) => {
|
|
175
217
|
const label = (el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || 'button';
|
|
176
218
|
return { selector: `[aria-label="${label}"]`, label: label.slice(0, 60) };
|
|
@@ -244,8 +286,22 @@ export async function probeScreen(
|
|
|
244
286
|
await page.waitForTimeout(1500);
|
|
245
287
|
|
|
246
288
|
const errors = await collectErrors(page);
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
// Prefer DOM-proximity mapping via aria-invalid + aria-describedby
|
|
290
|
+
const ariaFieldErrors = await page.evaluate(() =>
|
|
291
|
+
Array.from(document.querySelectorAll('[aria-invalid="true"]')).map((el) => {
|
|
292
|
+
const describedBy = el.getAttribute('aria-describedby');
|
|
293
|
+
const errorEl = describedBy ? document.getElementById(describedBy) : null;
|
|
294
|
+
const label = el.getAttribute('aria-label') || el.getAttribute('placeholder') || (el as HTMLInputElement).name || '';
|
|
295
|
+
return { label, errorText: errorEl?.textContent?.trim() || '' };
|
|
296
|
+
}).filter((f) => f.errorText)
|
|
297
|
+
);
|
|
298
|
+
const ariaMatched = new Set<string>();
|
|
299
|
+
for (const af of ariaFieldErrors) {
|
|
300
|
+
ariaMatched.add(af.errorText);
|
|
301
|
+
result.validationErrors.push({ field: af.label || 'unknown', trigger: 'empty_submit', errorText: af.errorText.slice(0, 150) });
|
|
302
|
+
}
|
|
303
|
+
// Fallback: text-proximity match for errors not covered by aria-invalid
|
|
304
|
+
for (const errorText of errors.filter((e) => !ariaMatched.has(e))) {
|
|
249
305
|
const matchedField = formInputs.find((el) =>
|
|
250
306
|
errorText.toLowerCase().includes(el.meaning.toLowerCase().split(' ')[0]) ||
|
|
251
307
|
el.meaning.toLowerCase().includes(errorText.toLowerCase().split(' ')[0])
|
|
@@ -338,6 +394,30 @@ export async function probeScreen(
|
|
|
338
394
|
content: dialogText.content,
|
|
339
395
|
fields: dialogText.fields.slice(0, 10),
|
|
340
396
|
});
|
|
397
|
+
} else {
|
|
398
|
+
// No modal appeared — check for accordion/disclosure panel reveals
|
|
399
|
+
// Clicks on accordion triggers change aria-expanded but don't open a dialog.
|
|
400
|
+
const accordionText = await page.evaluate(() => {
|
|
401
|
+
const panels = Array.from(document.querySelectorAll(
|
|
402
|
+
'[aria-expanded="true"] + *, details[open] > :not(summary), [data-state="open"]:not([role="dialog"]):not([class*="modal" i]), [data-headlessui-state="open"] > *'
|
|
403
|
+
)).filter((el) => {
|
|
404
|
+
const s = window.getComputedStyle(el as HTMLElement);
|
|
405
|
+
return s.display !== 'none' && s.visibility !== 'hidden';
|
|
406
|
+
});
|
|
407
|
+
if (panels.length === 0) return null;
|
|
408
|
+
const panel = panels[0] as HTMLElement;
|
|
409
|
+
const heading = panel.querySelector('h2,h3,h4')?.textContent?.replace(/\s+/g, ' ').trim() || '';
|
|
410
|
+
const content = panel.innerText?.replace(/\s+/g, ' ').trim().slice(0, 400) || '';
|
|
411
|
+
return content.length > 15 ? { heading, content } : null;
|
|
412
|
+
});
|
|
413
|
+
if (accordionText) {
|
|
414
|
+
result.modals.push({
|
|
415
|
+
triggerElement: trigger.label,
|
|
416
|
+
title: accordionText.heading || `${trigger.label} (revealed)`,
|
|
417
|
+
content: accordionText.content,
|
|
418
|
+
fields: [],
|
|
419
|
+
});
|
|
420
|
+
}
|
|
341
421
|
}
|
|
342
422
|
|
|
343
423
|
// Dismiss: Escape key first, then close button
|
|
@@ -359,6 +439,41 @@ export async function probeScreen(
|
|
|
359
439
|
result.conditionals.push(...conditionals);
|
|
360
440
|
} catch { /* non-fatal */ }
|
|
361
441
|
|
|
442
|
+
// ── Probe 5: Tab panels — click each tab, capture revealed content ────────────
|
|
443
|
+
try {
|
|
444
|
+
result.tabPanels = await probeTabPanels(page);
|
|
445
|
+
} catch { /* non-fatal */ }
|
|
446
|
+
|
|
447
|
+
// ── Probe 6: Tooltips — hover/title/describedby capture ─────────────────────
|
|
448
|
+
try {
|
|
449
|
+
result.tooltips = await probeTooltips(page);
|
|
450
|
+
} catch { /* non-fatal */ }
|
|
451
|
+
|
|
452
|
+
// ── Probe 7: Carousel/slider slides ──────────────────────────────────────────
|
|
453
|
+
try {
|
|
454
|
+
result.carouselSlides = await probeCarousels(page);
|
|
455
|
+
} catch { /* non-fatal */ }
|
|
456
|
+
|
|
457
|
+
// ── Probe 8: Wizard/multi-step form steps ────────────────────────────────────
|
|
458
|
+
try {
|
|
459
|
+
result.wizardSteps = await probeWizardSteps(page);
|
|
460
|
+
} catch { /* non-fatal */ }
|
|
461
|
+
|
|
462
|
+
// ── Probe 9: Focus-trap detection on any open dialogs ────────────────────────
|
|
463
|
+
try {
|
|
464
|
+
result.focusTraps = await detectFocusTraps(page);
|
|
465
|
+
} catch { /* non-fatal */ }
|
|
466
|
+
|
|
467
|
+
// ── Probe 10: Skip navigation compliance (WCAG 2.4.1) ────────────────────────
|
|
468
|
+
try {
|
|
469
|
+
result.skipNavIssues = await probeSkipNavigation(page);
|
|
470
|
+
} catch { /* non-fatal */ }
|
|
471
|
+
|
|
472
|
+
// ── Probe 11: Context menu capture on canvas/chart elements ──────────────────
|
|
473
|
+
try {
|
|
474
|
+
result.contextMenus = await probeContextMenus(page);
|
|
475
|
+
} catch { /* non-fatal */ }
|
|
476
|
+
|
|
362
477
|
return result;
|
|
363
478
|
}
|
|
364
479
|
|
|
@@ -382,6 +497,27 @@ export function probeResultToObservations(result: ProbeResult): Array<{
|
|
|
382
497
|
for (const lp of result.loadingPatterns) {
|
|
383
498
|
obs.push({ elementMeaning: '(submit button)', observationType: 'LOADING_STATE', observedText: lp, triggeredBy: 'form_submit' });
|
|
384
499
|
}
|
|
500
|
+
for (const tp of result.tabPanels ?? []) {
|
|
501
|
+
obs.push({ elementMeaning: tp.tabLabel, observationType: 'TAB_PANEL', observedText: tp.panelContent, triggeredBy: `tab_click:${tp.tabLabel}` });
|
|
502
|
+
}
|
|
503
|
+
for (const tt of result.tooltips ?? []) {
|
|
504
|
+
obs.push({ elementMeaning: tt.triggerLabel, observationType: 'TOOLTIP', observedText: tt.tooltipText, triggeredBy: 'hover_or_title' });
|
|
505
|
+
}
|
|
506
|
+
for (const cs of result.carouselSlides ?? []) {
|
|
507
|
+
obs.push({ elementMeaning: `Slide ${cs.slideIndex + 1}`, observationType: 'CAROUSEL_SLIDE', observedText: cs.content, triggeredBy: `carousel_next:${cs.slideIndex}` });
|
|
508
|
+
}
|
|
509
|
+
for (const ws of result.wizardSteps ?? []) {
|
|
510
|
+
obs.push({ elementMeaning: ws.stepLabel ?? `Step ${ws.stepIndex + 1}`, observationType: 'WIZARD_STEP', observedText: ws.content, triggeredBy: `wizard_next:${ws.stepIndex}` });
|
|
511
|
+
}
|
|
512
|
+
for (const ft of result.focusTraps ?? []) {
|
|
513
|
+
obs.push({ elementMeaning: ft.modalTrigger, observationType: 'FOCUS_TRAP', observedText: ft.focusTrapped ? 'Focus correctly trapped inside dialog' : 'FAIL: focus escaped dialog — WCAG 2.1.2 violation', triggeredBy: 'tab_key_sequence' });
|
|
514
|
+
}
|
|
515
|
+
for (const issue of result.skipNavIssues ?? []) {
|
|
516
|
+
obs.push({ elementMeaning: '(page)', observationType: 'SKIP_NAV_ISSUE', observedText: issue, triggeredBy: 'skip_nav_audit' });
|
|
517
|
+
}
|
|
518
|
+
for (const cm of result.contextMenus ?? []) {
|
|
519
|
+
obs.push({ elementMeaning: cm.trigger, observationType: 'CONTEXT_MENU', observedText: cm.items.join(' | '), triggeredBy: 'right_click' });
|
|
520
|
+
}
|
|
385
521
|
return obs;
|
|
386
522
|
}
|
|
387
523
|
|
|
@@ -419,6 +555,44 @@ export function probeResultToMarkdown(result: ProbeResult): {
|
|
|
419
555
|
}
|
|
420
556
|
}
|
|
421
557
|
|
|
558
|
+
if ((result.tabPanels ?? []).length > 0) {
|
|
559
|
+
lines.push('\n### Tab Panel Contents (observed)');
|
|
560
|
+
for (const tp of result.tabPanels!) {
|
|
561
|
+
lines.push(`- **Tab: ${tp.tabLabel}**${tp.panelHeading ? ` — ${tp.panelHeading}` : ''}: ${tp.panelContent.slice(0, 200)}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if ((result.tooltips ?? []).length > 0) {
|
|
566
|
+
lines.push('\n### Tooltips (observed)');
|
|
567
|
+
for (const tt of result.tooltips!) {
|
|
568
|
+
lines.push(`- **${tt.triggerLabel}**: ${tt.tooltipText}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if ((result.carouselSlides ?? []).length > 0) {
|
|
573
|
+
lines.push('\n### Carousel Slides (observed)');
|
|
574
|
+
for (const cs of result.carouselSlides!) {
|
|
575
|
+
lines.push(`- **Slide ${cs.slideIndex + 1}**${cs.heading ? ` — ${cs.heading}` : ''}: ${cs.content.slice(0, 200)}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if ((result.wizardSteps ?? []).length > 0) {
|
|
580
|
+
lines.push('\n### Wizard / Multi-Step Form (observed)');
|
|
581
|
+
for (const ws of result.wizardSteps!) {
|
|
582
|
+
const label = ws.stepLabel ? ` — ${ws.stepLabel}` : '';
|
|
583
|
+
lines.push(`- **Step ${ws.stepIndex + 1}${label}**: ${ws.content.slice(0, 200)}`);
|
|
584
|
+
if (ws.fields.length > 0) lines.push(` - Fields: ${ws.fields.join(', ')}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if ((result.focusTraps ?? []).length > 0) {
|
|
589
|
+
lines.push('\n### Focus-Trap Audit (observed)');
|
|
590
|
+
for (const ft of result.focusTraps!) {
|
|
591
|
+
const verdict = ft.focusTrapped ? '✅ PASS' : '❌ FAIL (WCAG 2.1.2)';
|
|
592
|
+
lines.push(`- **${ft.modalTrigger}**: ${verdict} — focused: [${ft.sampledFocused.slice(0, 4).join(', ')}]`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
422
596
|
if (result.conditionals.length > 0) {
|
|
423
597
|
lines.push('\n### Conditional UI (observed)');
|
|
424
598
|
for (const c of result.conditionals) {
|
|
@@ -551,6 +725,236 @@ export interface KeyboardNavResult {
|
|
|
551
725
|
issues: string[];
|
|
552
726
|
}
|
|
553
727
|
|
|
728
|
+
/** Verify skip-navigation links exist and their targets are present in the DOM. */
|
|
729
|
+
async function probeSkipNavigation(page: Page): Promise<string[]> {
|
|
730
|
+
const issues: string[] = [];
|
|
731
|
+
try {
|
|
732
|
+
const skipLinks = await page.evaluate(() =>
|
|
733
|
+
Array.from(document.querySelectorAll('a[href^="#"]'))
|
|
734
|
+
.filter((el) => /skip|jump to|main content|content|nav/i.test((el as HTMLElement).innerText?.toLowerCase().trim() || ''))
|
|
735
|
+
.map((el) => ({
|
|
736
|
+
text: (el as HTMLElement).innerText?.trim() || '',
|
|
737
|
+
href: el.getAttribute('href') || '',
|
|
738
|
+
hasTarget: !!document.querySelector(el.getAttribute('href') || '#__invalid__'),
|
|
739
|
+
}))
|
|
740
|
+
);
|
|
741
|
+
if (skipLinks.length === 0) {
|
|
742
|
+
issues.push('No skip navigation links found (WCAG 2.4.1 — keyboard users cannot bypass nav blocks)');
|
|
743
|
+
}
|
|
744
|
+
for (const link of skipLinks) {
|
|
745
|
+
if (!link.hasTarget) issues.push(`Skip link "${link.text}" → "${link.href}" target not found in DOM`);
|
|
746
|
+
}
|
|
747
|
+
} catch { /* non-fatal */ }
|
|
748
|
+
return issues;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Right-click on canvas, chart, and context-menu elements to capture context menu items. */
|
|
752
|
+
async function probeContextMenus(page: Page): Promise<ContextMenuCapture[]> {
|
|
753
|
+
const results: ContextMenuCapture[] = [];
|
|
754
|
+
try {
|
|
755
|
+
const targets = await page.$$('canvas, [data-contextmenu], [oncontextmenu], [class*="chart" i]:not(script)');
|
|
756
|
+
for (const t of targets.slice(0, 4)) {
|
|
757
|
+
try {
|
|
758
|
+
await t.click({ button: 'right', timeout: 1000 });
|
|
759
|
+
await page.waitForTimeout(350);
|
|
760
|
+
const items = await page.evaluate(() =>
|
|
761
|
+
Array.from(document.querySelectorAll('[role="menu"] [role="menuitem"], .context-menu-item, [class*="contextmenu" i] li'))
|
|
762
|
+
.map((el) => (el as HTMLElement).innerText?.trim())
|
|
763
|
+
.filter(Boolean).slice(0, 12)
|
|
764
|
+
);
|
|
765
|
+
if (items.length > 0) {
|
|
766
|
+
const trigger = await t.evaluate((el) => (el as HTMLElement).getAttribute('aria-label') || (el as HTMLElement).className.slice(0, 40) || el.tagName).catch(() => '');
|
|
767
|
+
results.push({ trigger, items });
|
|
768
|
+
}
|
|
769
|
+
await page.keyboard.press('Escape').catch(() => { });
|
|
770
|
+
} catch { /* skip this target */ }
|
|
771
|
+
}
|
|
772
|
+
} catch { /* non-fatal */ }
|
|
773
|
+
return results;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Click carousel next buttons up to 8 slides and record content of each revealed slide. */
|
|
777
|
+
async function probeCarousels(page: Page): Promise<CarouselSlide[]> {
|
|
778
|
+
const results: CarouselSlide[] = [];
|
|
779
|
+
try {
|
|
780
|
+
const CAROUSEL_SEL = '[class*="carousel" i], [class*="slider" i], [class*="swiper" i], [role="region"][aria-roledescription*="carousel" i]';
|
|
781
|
+
const hasCarousel = await page.evaluate((sel) => !!document.querySelector(sel), CAROUSEL_SEL);
|
|
782
|
+
if (!hasCarousel) return results;
|
|
783
|
+
|
|
784
|
+
const NEXT_SEL = '[aria-label*="next" i], [aria-label*="Next"], [class*="next" i]:not([class*="footnext"]):not(a), button[class*="arrow-right" i], button[class*="chevron-right" i]';
|
|
785
|
+
|
|
786
|
+
// Capture slide 0 (current state)
|
|
787
|
+
const slide0 = await page.evaluate((carSel) => {
|
|
788
|
+
const car = document.querySelector(carSel) as HTMLElement | null;
|
|
789
|
+
if (!car) return null;
|
|
790
|
+
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]') as HTMLElement | null;
|
|
791
|
+
const target = active ?? car;
|
|
792
|
+
return { heading: target.querySelector('h2,h3,h4')?.textContent?.trim() ?? '', content: target.innerText?.replace(/\s+/g, ' ').trim().slice(0, 300) ?? '' };
|
|
793
|
+
}, CAROUSEL_SEL);
|
|
794
|
+
if (slide0?.content && slide0.content.length > 15) results.push({ slideIndex: 0, heading: slide0.heading || undefined, content: slide0.content });
|
|
795
|
+
|
|
796
|
+
const seen = new Set<string>(slide0?.content ? [slide0.content] : []);
|
|
797
|
+
for (let i = 1; i < 8; i++) {
|
|
798
|
+
try {
|
|
799
|
+
const nextBtn = page.locator(NEXT_SEL).first();
|
|
800
|
+
const visible = await nextBtn.isVisible().catch(() => false);
|
|
801
|
+
if (!visible) break;
|
|
802
|
+
await nextBtn.click({ timeout: 1000 });
|
|
803
|
+
await page.waitForTimeout(600);
|
|
804
|
+
const slide = await page.evaluate((carSel) => {
|
|
805
|
+
const car = document.querySelector(carSel) as HTMLElement | null;
|
|
806
|
+
if (!car) return null;
|
|
807
|
+
const active = car.querySelector('[aria-hidden="false"], .active, [class*="active" i], [class*="current" i]') as HTMLElement | null;
|
|
808
|
+
const target = active ?? car;
|
|
809
|
+
return { heading: target.querySelector('h2,h3,h4')?.textContent?.trim() ?? '', content: target.innerText?.replace(/\s+/g, ' ').trim().slice(0, 300) ?? '' };
|
|
810
|
+
}, CAROUSEL_SEL);
|
|
811
|
+
if (!slide?.content || slide.content.length < 15 || seen.has(slide.content)) break;
|
|
812
|
+
seen.add(slide.content);
|
|
813
|
+
results.push({ slideIndex: i, heading: slide.heading || undefined, content: slide.content });
|
|
814
|
+
} catch { break; }
|
|
815
|
+
}
|
|
816
|
+
} catch { /* non-fatal */ }
|
|
817
|
+
return results;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Follow Next/Continue buttons to capture each wizard step's fields and content. */
|
|
821
|
+
async function probeWizardSteps(page: Page): Promise<WizardStep[]> {
|
|
822
|
+
const results: WizardStep[] = [];
|
|
823
|
+
try {
|
|
824
|
+
const NEXT_LABELS = /^(next|continue|proceed|step \d|›|→|forward)/i;
|
|
825
|
+
const BACK_LABELS = /^(back|previous|prev|‹|←)/i;
|
|
826
|
+
const WIZARD_INDICATORS = '[role="progressbar"], [class*="stepper" i], [class*="wizard" i], [class*="progress-step" i], [aria-label*="step" i]';
|
|
827
|
+
const isWizard = await page.evaluate((sel) => !!document.querySelector(sel), WIZARD_INDICATORS);
|
|
828
|
+
if (!isWizard) return results;
|
|
829
|
+
|
|
830
|
+
const captureStep = async (stepIndex: number): Promise<WizardStep> => {
|
|
831
|
+
const data = await page.evaluate(() => {
|
|
832
|
+
const content = (document.querySelector('main, [role="main"], form') as HTMLElement | null)?.innerText?.replace(/\s+/g, ' ').trim().slice(0, 400) ?? '';
|
|
833
|
+
const stepLabel = document.querySelector('[aria-current="step"], [class*="step-label" i], [class*="stepper-label" i]')?.textContent?.trim() ?? '';
|
|
834
|
+
const fields = Array.from(document.querySelectorAll('input:not([type="hidden"]), select, textarea'))
|
|
835
|
+
.map((el) => (el as HTMLElement).getAttribute('aria-label') || (el as HTMLInputElement).placeholder || (el as HTMLInputElement).name || '')
|
|
836
|
+
.filter(Boolean).slice(0, 10);
|
|
837
|
+
return { content, stepLabel, fields };
|
|
838
|
+
});
|
|
839
|
+
return { stepIndex, stepLabel: data.stepLabel || undefined, content: data.content, fields: data.fields };
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
results.push(await captureStep(0));
|
|
843
|
+
|
|
844
|
+
for (let i = 1; i < 6; i++) {
|
|
845
|
+
try {
|
|
846
|
+
const nextLabel = await page.evaluate((nextRe) => {
|
|
847
|
+
const btns = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
848
|
+
const btn = btns.find((b) => new RegExp(nextRe).test((b as HTMLElement).innerText?.trim() || ''));
|
|
849
|
+
return (btn as HTMLElement)?.innerText?.trim() || null;
|
|
850
|
+
}, NEXT_LABELS.source);
|
|
851
|
+
if (!nextLabel) break;
|
|
852
|
+
const btn = page.locator(`button:has-text("${nextLabel}")`).first();
|
|
853
|
+
await btn.click({ timeout: 1500 });
|
|
854
|
+
await page.waitForTimeout(800);
|
|
855
|
+
results.push(await captureStep(i));
|
|
856
|
+
} catch { break; }
|
|
857
|
+
}
|
|
858
|
+
} catch { /* non-fatal */ }
|
|
859
|
+
return results;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** For each open modal, Tab 8× and verify focus stays inside the dialog (focus-trap check). */
|
|
863
|
+
async function detectFocusTraps(page: Page): Promise<FocusTrapResult[]> {
|
|
864
|
+
const results: FocusTrapResult[] = [];
|
|
865
|
+
try {
|
|
866
|
+
const openDialogs = await page.evaluate(() => {
|
|
867
|
+
return Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"]'))
|
|
868
|
+
.filter((el) => window.getComputedStyle(el).display !== 'none')
|
|
869
|
+
.map((el) => el.getAttribute('aria-label') || el.querySelector('[role="heading"]')?.textContent?.trim() || 'dialog');
|
|
870
|
+
});
|
|
871
|
+
for (const dialogLabel of openDialogs.slice(0, 3)) {
|
|
872
|
+
const sampled: string[] = [];
|
|
873
|
+
let allInside = true;
|
|
874
|
+
for (let i = 0; i < 8; i++) {
|
|
875
|
+
await page.keyboard.press('Tab');
|
|
876
|
+
await page.waitForTimeout(80);
|
|
877
|
+
const focused = await page.evaluate(() => {
|
|
878
|
+
const el = document.activeElement as HTMLElement | null;
|
|
879
|
+
const inDialog = !!el?.closest('[role="dialog"], [role="alertdialog"]');
|
|
880
|
+
return { label: el?.getAttribute('aria-label') || el?.textContent?.trim().slice(0, 40) || el?.tagName || '', inDialog };
|
|
881
|
+
});
|
|
882
|
+
sampled.push(focused.label);
|
|
883
|
+
if (!focused.inDialog) { allInside = false; break; }
|
|
884
|
+
}
|
|
885
|
+
results.push({ modalTrigger: dialogLabel, focusTrapped: allInside, sampledFocused: sampled });
|
|
886
|
+
}
|
|
887
|
+
} catch { /* non-fatal */ }
|
|
888
|
+
return results;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Click each visible tab and record the panel content revealed. */
|
|
892
|
+
async function probeTabPanels(page: Page): Promise<TabPanelCapture[]> {
|
|
893
|
+
const results: TabPanelCapture[] = [];
|
|
894
|
+
try {
|
|
895
|
+
const tabs = await page.$$('[role="tab"]:not([aria-disabled="true"])');
|
|
896
|
+
for (const tab of tabs.slice(0, 10)) {
|
|
897
|
+
try {
|
|
898
|
+
const tabLabel = await tab.evaluate((el) => (el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || '');
|
|
899
|
+
if (!tabLabel) continue;
|
|
900
|
+
await tab.click({ timeout: 1000 });
|
|
901
|
+
await page.waitForTimeout(400);
|
|
902
|
+
const panelContent = await page.evaluate(() => {
|
|
903
|
+
const panel = document.querySelector('[role="tabpanel"]:not([hidden])') as HTMLElement | null;
|
|
904
|
+
if (!panel) return null;
|
|
905
|
+
const style = window.getComputedStyle(panel);
|
|
906
|
+
if (style.display === 'none' || style.visibility === 'hidden') return null;
|
|
907
|
+
return {
|
|
908
|
+
heading: panel.querySelector('h2,h3,h4')?.textContent?.trim() ?? '',
|
|
909
|
+
content: panel.innerText?.replace(/\s+/g, ' ').trim().slice(0, 400) ?? '',
|
|
910
|
+
};
|
|
911
|
+
});
|
|
912
|
+
if (panelContent?.content && panelContent.content.length > 15) {
|
|
913
|
+
results.push({ tabLabel: tabLabel.slice(0, 60), panelContent: panelContent.content, panelHeading: panelContent.heading || undefined });
|
|
914
|
+
}
|
|
915
|
+
} catch { /* this tab failed, try next */ }
|
|
916
|
+
}
|
|
917
|
+
} catch { /* non-fatal */ }
|
|
918
|
+
return results;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Hover elements with title/aria-describedby to capture tooltip text. */
|
|
922
|
+
async function probeTooltips(page: Page): Promise<TooltipCapture[]> {
|
|
923
|
+
const results: TooltipCapture[] = [];
|
|
924
|
+
try {
|
|
925
|
+
const candidates = await page.evaluate(() => {
|
|
926
|
+
return Array.from(document.querySelectorAll('[title], [aria-describedby], [data-tooltip], [data-tip], [class*="tooltip" i]'))
|
|
927
|
+
.filter((el) => {
|
|
928
|
+
const style = window.getComputedStyle(el as HTMLElement);
|
|
929
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
930
|
+
})
|
|
931
|
+
.slice(0, 8)
|
|
932
|
+
.map((el) => ({
|
|
933
|
+
label: (el as HTMLElement).innerText?.trim().slice(0, 60) || el.getAttribute('aria-label') || '',
|
|
934
|
+
title: el.getAttribute('title') || '',
|
|
935
|
+
describedBy: el.getAttribute('aria-describedby') || '',
|
|
936
|
+
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
|
|
937
|
+
}));
|
|
938
|
+
});
|
|
939
|
+
for (const c of candidates) {
|
|
940
|
+
try {
|
|
941
|
+
// Prefer title attribute directly — no need to hover for static tooltips
|
|
942
|
+
if (c.title && c.title.length > 5) {
|
|
943
|
+
results.push({ triggerLabel: c.label || c.selector, tooltipText: c.title.slice(0, 200) });
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (c.describedBy) {
|
|
947
|
+
const tooltipText = await page.evaluate((id) => document.getElementById(id)?.textContent?.trim() ?? '', c.describedBy);
|
|
948
|
+
if (tooltipText && tooltipText.length > 5) {
|
|
949
|
+
results.push({ triggerLabel: c.label || c.selector, tooltipText: tooltipText.slice(0, 200) });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} catch { /* skip this element */ }
|
|
953
|
+
}
|
|
954
|
+
} catch { /* non-fatal */ }
|
|
955
|
+
return results;
|
|
956
|
+
}
|
|
957
|
+
|
|
554
958
|
/**
|
|
555
959
|
* Probe keyboard navigation accessibility by pressing Tab up to 50 times and
|
|
556
960
|
* recording each focused element's properties and focus-indicator visibility.
|
package/src/screen-reader.ts
CHANGED
|
@@ -20,23 +20,29 @@ export async function readScreen(page: Page): Promise<RawElement[]> {
|
|
|
20
20
|
const out: { role: string; label: string; selectorHint: string; expectedData?: string }[] = [];
|
|
21
21
|
const seen = new Set<string>();
|
|
22
22
|
|
|
23
|
-
// Noise patterns to skip — cookie banners, footer
|
|
23
|
+
// Noise patterns to skip — cookie banners, footer chrome, version strings
|
|
24
|
+
// Intentionally excludes standalone "close" and "copy" — too many false positives
|
|
25
|
+
// (Close account, Close preview, Copy link, Copy to clipboard are all valid test targets)
|
|
24
26
|
const noiseLabel =
|
|
25
|
-
/^(cookie|consent|privacy policy|terms of use|copyright|©|\bv\d+\.\d+|
|
|
27
|
+
/^(cookie|consent|privacy policy|terms of use|copyright|©|\bv\d+\.\d+|got it|dismiss|skip to|powered by|copied|\s*$)/i;
|
|
26
28
|
|
|
27
|
-
//
|
|
29
|
+
// Include ARIA-role elements — Headless UI, Radix, Ant Design, Shadcn all use these instead of native tags
|
|
28
30
|
const nodes = document.querySelectorAll(
|
|
29
|
-
'input:not([type=hidden]), select, textarea, button, a[href]'
|
|
31
|
+
'input:not([type=hidden]), select, textarea, button, a[href], ' +
|
|
32
|
+
'[role="button"], [role="combobox"], [role="slider"], [role="switch"], ' +
|
|
33
|
+
'[role="tab"], [role="menuitem"], [role="searchbox"], [role="spinbutton"]'
|
|
30
34
|
);
|
|
31
35
|
|
|
32
36
|
nodes.forEach((el) => {
|
|
33
37
|
const htmlEl = el as HTMLElement;
|
|
34
38
|
|
|
35
|
-
// Skip invisible
|
|
39
|
+
// Skip invisible elements
|
|
36
40
|
const style = window.getComputedStyle(htmlEl);
|
|
37
41
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return;
|
|
38
42
|
const rect = el.getBoundingClientRect();
|
|
39
|
-
|
|
43
|
+
// Allow zero-size elements that carry an accessible label (sr-only icon buttons, skip-nav links)
|
|
44
|
+
const hasAccessibleLabel = !!(el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'));
|
|
45
|
+
if (rect.width === 0 && rect.height === 0 && !hasAccessibleLabel) return;
|
|
40
46
|
|
|
41
47
|
// Derive role
|
|
42
48
|
const tag = el.tagName.toLowerCase();
|
|
@@ -13,11 +13,21 @@ export interface PageMetadata {
|
|
|
13
13
|
ogImage: string | null;
|
|
14
14
|
ogUrl: string | null;
|
|
15
15
|
ogType: string | null;
|
|
16
|
+
ogSiteName: string | null;
|
|
17
|
+
ogLocale: string | null;
|
|
16
18
|
twitterCard: string | null;
|
|
17
19
|
twitterTitle: string | null;
|
|
18
20
|
twitterDescription: string | null;
|
|
21
|
+
twitterImage: string | null;
|
|
22
|
+
twitterSite: string | null;
|
|
19
23
|
canonical: string | null;
|
|
20
24
|
robots: string | null;
|
|
25
|
+
hreflang: Array<{
|
|
26
|
+
lang: string;
|
|
27
|
+
href: string;
|
|
28
|
+
}>;
|
|
29
|
+
themeColor: string | null;
|
|
30
|
+
favicon: string | null;
|
|
21
31
|
jsonLd: object[];
|
|
22
32
|
schemaTypes: string[];
|
|
23
33
|
lang: string | null;
|
|
@@ -43,6 +43,35 @@ export interface ConditionalReveal {
|
|
|
43
43
|
triggerValue: string;
|
|
44
44
|
revealedText: string;
|
|
45
45
|
}
|
|
46
|
+
export interface TooltipCapture {
|
|
47
|
+
triggerLabel: string;
|
|
48
|
+
tooltipText: string;
|
|
49
|
+
}
|
|
50
|
+
export interface TabPanelCapture {
|
|
51
|
+
tabLabel: string;
|
|
52
|
+
panelContent: string;
|
|
53
|
+
panelHeading?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface CarouselSlide {
|
|
56
|
+
slideIndex: number;
|
|
57
|
+
heading?: string;
|
|
58
|
+
content: string;
|
|
59
|
+
}
|
|
60
|
+
export interface WizardStep {
|
|
61
|
+
stepIndex: number;
|
|
62
|
+
stepLabel?: string;
|
|
63
|
+
content: string;
|
|
64
|
+
fields: string[];
|
|
65
|
+
}
|
|
66
|
+
export interface FocusTrapResult {
|
|
67
|
+
modalTrigger: string;
|
|
68
|
+
focusTrapped: boolean;
|
|
69
|
+
sampledFocused: string[];
|
|
70
|
+
}
|
|
71
|
+
export interface ContextMenuCapture {
|
|
72
|
+
trigger: string;
|
|
73
|
+
items: string[];
|
|
74
|
+
}
|
|
46
75
|
export interface ProbeResult {
|
|
47
76
|
screenId: string;
|
|
48
77
|
url: string;
|
|
@@ -51,6 +80,13 @@ export interface ProbeResult {
|
|
|
51
80
|
modals: ModalObservation[];
|
|
52
81
|
conditionals: ConditionalReveal[];
|
|
53
82
|
loadingPatterns: string[];
|
|
83
|
+
tooltips?: TooltipCapture[];
|
|
84
|
+
tabPanels?: TabPanelCapture[];
|
|
85
|
+
carouselSlides?: CarouselSlide[];
|
|
86
|
+
wizardSteps?: WizardStep[];
|
|
87
|
+
focusTraps?: FocusTrapResult[];
|
|
88
|
+
skipNavIssues?: string[];
|
|
89
|
+
contextMenus?: ContextMenuCapture[];
|
|
54
90
|
}
|
|
55
91
|
/**
|
|
56
92
|
* Run interactive probing on a single screen page.
|