/** * Shadow DOM crawler — enumerate shadow hosts and detect web component libraries. * * Walks the live DOM recursively (up to depth 8) to find all open shadow roots * and custom elements with closed shadow roots, then classifies the component * framework in use. Results feed ZeTa's test generator so it can account for * elements that are invisible to standard Playwright locators. */ import type { Page } from 'playwright'; export interface ShadowInteractiveEl { role: string; label: string; type: string; tagName: string; } export interface ShadowHostInfo { tagName: string; hostId: string; shadowMode: 'open' | 'closed'; depth: number; interactiveCount: number; interactiveElements: ShadowInteractiveEl[]; slotNames: string[]; childShadowHosts: number; } export interface ShadowDomCrawlResult { shadowHostCount: number; openShadowCount: number; closedShadowCount: number; maxNestingDepth: number; hosts: ShadowHostInfo[]; totalInteractiveInShadow: number; detectedLibraries: string[]; blindSpots: string[]; } interface RawShadowHost { tagName: string; hostId: string; shadowMode: 'open' | 'closed'; depth: number; interactiveCount: number; interactiveDetails: Array<{ role: string; label: string; type: string; tagName: string }>; slotNames: string[]; childShadowHosts: number; } async function enumerateShadowHosts(page: any): Promise { const raw: RawShadowHost[] = await page.evaluate(() => { // esbuild injects __name() for named function declarations; polyfill prevents ReferenceError in browser. if (typeof (globalThis as any).__name === 'undefined') (globalThis as any).__name = (fn: any) => fn; function collectHosts(root: Document | ShadowRoot, depth: number): RawShadowHost[] { if (depth > 8) return []; const hosts: RawShadowHost[] = []; const els = root.querySelectorAll('*'); for (const el of Array.from(els)) { const htmlEl = el as HTMLElement & { shadowRoot: ShadowRoot | null }; if (htmlEl.shadowRoot) { const interactive = htmlEl.shadowRoot.querySelectorAll( 'button,input,select,textarea,a[href],[role="button"],[role="link"],[role="checkbox"],[role="menuitem"],[role="tab"],[role="switch"]' ); const slots = Array.from(htmlEl.shadowRoot.querySelectorAll('slot')).map( (s) => (s as HTMLSlotElement).name || 'default' ); const nestedHosts = Array.from(htmlEl.shadowRoot.querySelectorAll('*')).filter( (c) => (c as HTMLElement & { shadowRoot: ShadowRoot | null }).shadowRoot ).length; hosts.push({ tagName: htmlEl.tagName.toLowerCase(), hostId: htmlEl.id || (htmlEl.className?.toString()?.split(' ')[0]) || '', shadowMode: 'open', depth, interactiveCount: interactive.length, interactiveDetails: Array.from(interactive) .slice(0, 8) .map((i) => { const iEl = i as HTMLElement; return { role: iEl.getAttribute('role') || iEl.tagName.toLowerCase(), label: ( iEl.getAttribute('aria-label') || iEl.getAttribute('placeholder') || iEl.textContent?.trim() || '' ).slice(0, 40), type: iEl.getAttribute('type') || '', tagName: iEl.tagName.toLowerCase(), }; }), slotNames: slots, childShadowHosts: nestedHosts, }); const nested = collectHosts(htmlEl.shadowRoot, depth + 1); hosts.push(...nested); } else if (el.tagName.includes('-') && !(el as HTMLElement & { shadowRoot: ShadowRoot | null }).shadowRoot) { hosts.push({ tagName: el.tagName.toLowerCase(), hostId: (el as HTMLElement).id || '', shadowMode: 'closed', depth, interactiveCount: 0, interactiveDetails: [], slotNames: [], childShadowHosts: 0, }); } } return hosts; } return collectHosts(document, 0); }); return raw.slice(0, 100).map((h) => ({ tagName: h.tagName, hostId: h.hostId, shadowMode: h.shadowMode, depth: h.depth, interactiveCount: h.interactiveCount, interactiveElements: h.interactiveDetails, slotNames: h.slotNames, childShadowHosts: h.childShadowHosts, })); } async function detectWebComponentLibrary(page: any): Promise { return page.evaluate(() => { const checks: [string, string][] = [ ['sl-button', 'Shoelace'], ['fast-button', 'Microsoft FAST'], ['mwc-button', 'Material Web Components'], ['ion-button', 'Ionic'], ['vaadin-button', 'Vaadin'], ['fluent-button', 'Fluent UI Web Components'], ['hy-icon', 'Hydra'], ]; const detected: string[] = []; for (const [tag, name] of checks) { if (customElements.get(tag)) detected.push(name); } const allTags = Array.from(document.querySelectorAll('*')) .map((e) => e.tagName.toLowerCase()) .filter((t) => t.includes('-')); const uniqueTags = Array.from(new Set(allTags)); if (uniqueTags.some((t) => t.startsWith('sl-')) && !detected.includes('Shoelace')) detected.push('Shoelace'); if (uniqueTags.some((t) => t.startsWith('fast-')) && !detected.includes('Microsoft FAST')) detected.push('Microsoft FAST'); if (uniqueTags.some((t) => t.startsWith('ion-')) && !detected.includes('Ionic')) detected.push('Ionic'); return detected; }); } export async function crawlShadowDom(page: any): Promise { const empty: ShadowDomCrawlResult = { shadowHostCount: 0, openShadowCount: 0, closedShadowCount: 0, maxNestingDepth: 0, hosts: [], totalInteractiveInShadow: 0, detectedLibraries: [], blindSpots: [], }; try { const [hosts, detectedLibraries] = await Promise.all([ enumerateShadowHosts(page), detectWebComponentLibrary(page), ]); const openHosts = hosts.filter((h) => h.shadowMode === 'open'); const closedHosts = hosts.filter((h) => h.shadowMode === 'closed'); const maxNestingDepth = hosts.reduce((max, h) => Math.max(max, h.depth), 0); const totalInteractiveInShadow = openHosts.reduce((sum, h) => sum + h.interactiveCount, 0); const blindSpots = [...new Set(closedHosts.map((h) => h.tagName))]; return { shadowHostCount: hosts.length, openShadowCount: openHosts.length, closedShadowCount: closedHosts.length, maxNestingDepth, hosts, totalInteractiveInShadow, detectedLibraries, blindSpots, }; } catch { return empty; } }