@detiq/crawler 1.0.5 → 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.
@@ -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
- canonical: null, robots: null, jsonLd: [], schemaTypes: [], lang: null,
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 {
@@ -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, 8) // probe at most 8 buttons per screen to keep it fast
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
- for (const errorText of errors) {
248
- // Try to match error back to a field
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.
@@ -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 junk, marketing copy buttons, version strings
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+|accept|decline|got it|dismiss|close|skip to|powered by|copy|copied|\s*$)/i;
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
- // Select only truly interactive elements (not generic [role] containers)
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 or zero-size elements
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
- if (rect.width === 0 && rect.height === 0) return;
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.