import type { Page } from 'playwright'; export interface DropdownOption { value: string; label: string; disabled?: boolean; } export interface DropdownDef { kind: 'native-select' | 'combobox' | 'listbox' | 'multiselect'; label: string; locatorHint: string; options: DropdownOption[]; defaultValue?: string; cascadesFrom?: string; } export interface DropdownEnumerationResult { dropdowns: DropdownDef[]; conditionalRevealMap: Record; totalOptionsFound: number; } async function enumerateNativeSelects(page: Page): Promise { const selects = await page.$$('select:not([multiple])'); const limited = selects.slice(0, 50); const results: DropdownDef[] = []; for (const handle of limited) { try { const data = await handle.evaluate((el: HTMLSelectElement) => { const name = el.name || el.id || ''; const ariaLabel = el.getAttribute('aria-label') || ''; let labelText = ariaLabel; if (!labelText && el.id) { const lbl = document.querySelector(`label[for="${el.id}"]`); if (lbl) labelText = lbl.textContent?.trim() || ''; } if (!labelText && name) labelText = name; const options = Array.from(el.options).map((o) => ({ value: o.value, label: o.text.trim(), disabled: o.disabled || undefined, })); const selectedOption = el.options[el.selectedIndex]; const defaultValue = selectedOption ? selectedOption.value : undefined; const locatorHint = name ? `select[name="${name}"]` : el.id ? `select#${el.id}` : 'select'; return { label: labelText, locatorHint, options, defaultValue }; }); results.push({ kind: 'native-select', label: data.label, locatorHint: data.locatorHint, options: data.options, defaultValue: data.defaultValue, }); } catch { // skip unresponsive handle } } return results; } async function enumerateCustomDropdowns(page: Page): Promise { const selectors = [ '[role="combobox"]', '[aria-haspopup="listbox"]:not([role="combobox"])', '[data-radix-select-trigger]', ]; const seen = new Set(); const handles: { handle: Awaited>[0]; hint: string }[] = []; for (const sel of selectors) { const els = await page.$$(sel); for (const el of els) { const hint = await el .evaluate((e: Element) => { const id = e.id ? `#${e.id}` : ''; const aria = e.getAttribute('aria-label') || ''; return id || aria || e.tagName.toLowerCase(); }) .catch(() => ''); if (!seen.has(hint)) { seen.add(hint); handles.push({ handle: el, hint }); } } } const limited = handles.slice(0, 20); const results: DropdownDef[] = []; for (const { handle, hint } of limited) { try { const ariaLabel = await handle .evaluate((e: Element) => e.getAttribute('aria-label') || e.textContent?.trim() || '') .catch(() => ''); await handle.click(); await page.waitForSelector('[role="listbox"]', { timeout: 2000 }); const options = await page.$$eval('[role="listbox"] [role="option"]', (els) => els.map((el) => ({ value: el.getAttribute('data-value') || el.getAttribute('value') || el.textContent?.trim() || '', label: el.textContent?.trim() || '', disabled: el.getAttribute('aria-disabled') === 'true' || undefined, })) ); await page.keyboard.press('Escape'); await page.waitForTimeout(300); results.push({ kind: 'combobox', label: ariaLabel, locatorHint: hint, options, }); } catch { try { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } catch { // ignore cleanup failure } } } return results; } async function enumerateMultiSelects(page: Page): Promise { const results: DropdownDef[] = []; const nativeMulti = await page.$$('select[multiple]'); for (const handle of nativeMulti) { try { const data = await handle.evaluate((el: HTMLSelectElement) => { const name = el.name || el.id || ''; const ariaLabel = el.getAttribute('aria-label') || ''; let labelText = ariaLabel; if (!labelText && el.id) { const lbl = document.querySelector(`label[for="${el.id}"]`); if (lbl) labelText = lbl.textContent?.trim() || ''; } if (!labelText && name) labelText = name; const options = Array.from(el.options).map((o) => ({ value: o.value, label: o.text.trim(), disabled: o.disabled || undefined, })); const locatorHint = name ? `select[multiple][name="${name}"]` : el.id ? `select[multiple]#${el.id}` : 'select[multiple]'; return { label: labelText, locatorHint, options }; }); results.push({ kind: 'multiselect', label: data.label, locatorHint: data.locatorHint, options: data.options, }); } catch { // skip } } const ariaMulti = await page.$$('[role="listbox"][aria-multiselectable="true"]'); for (const handle of ariaMulti) { try { const data = await handle.evaluate((el: Element) => { const ariaLabel = el.getAttribute('aria-label') || el.id || ''; const locatorHint = el.id ? `[role="listbox"]#${el.id}` : '[role="listbox"][aria-multiselectable="true"]'; const optionEls = el.querySelectorAll('[role="option"]'); const options = Array.from(optionEls).map((o) => ({ value: o.getAttribute('data-value') || o.getAttribute('value') || o.textContent?.trim() || '', label: o.textContent?.trim() || '', disabled: o.getAttribute('aria-disabled') === 'true' || undefined, })); return { label: ariaLabel, locatorHint, options }; }); results.push({ kind: 'multiselect', label: data.label, locatorHint: data.locatorHint, options: data.options, }); } catch { // skip } } return results; } async function detectCascading( page: Page, dropdowns: DropdownDef[] ): Promise> { const cascadeMap: Record = {}; const nativeParents = dropdowns.filter( (d) => d.kind === 'native-select' && d.options.length <= 8 ); for (const parent of nativeParents) { const parentName = parent.locatorHint.match(/name="([^"]+)"/)?.[1]; if (!parentName) continue; const otherNatives = dropdowns.filter( (d) => d.kind === 'native-select' && d.locatorHint !== parent.locatorHint ); if (otherNatives.length === 0) continue; const baselineCounts = await page .$$eval('select:not([multiple])', (els) => (els as HTMLSelectElement[]).map((el) => ({ name: el.name || el.id, count: el.options.length })) ) .catch(() => [] as { name: string; count: number }[]); let foundChildren: string[] = []; for (let i = 0; i < parent.options.length; i++) { try { await page.selectOption(`select[name="${parentName}"]`, { index: i }); await page.waitForTimeout(500); const newCounts = await page .$$eval('select:not([multiple])', (els) => (els as HTMLSelectElement[]).map((el) => ({ name: el.name || el.id, count: el.options.length })) ) .catch(() => [] as { name: string; count: number }[]); for (const curr of newCounts) { const base = baselineCounts.find((b) => b.name === curr.name); if (base && base.count !== curr.count && curr.name !== parentName) { const childHint = `select[name="${curr.name}"]`; const childDef = dropdowns.find((d) => d.locatorHint === childHint); if (childDef && !foundChildren.includes(childHint)) { foundChildren.push(childHint); } } } } catch { // skip option that fails } } // reset to index 0 try { await page.selectOption(`select[name="${parentName}"]`, { index: 0 }); } catch { // ignore reset failure } if (foundChildren.length > 0) { cascadeMap[parent.locatorHint] = foundChildren; } } return cascadeMap; } export async function enumerateDropdowns(page: Page): Promise { try { const [native, custom, multi] = await Promise.all([ enumerateNativeSelects(page).catch(() => [] as DropdownDef[]), enumerateCustomDropdowns(page).catch(() => [] as DropdownDef[]), enumerateMultiSelects(page).catch(() => [] as DropdownDef[]), ]); const seen = new Set(); const dropdowns: DropdownDef[] = []; for (const d of [...native, ...custom, ...multi]) { if (!seen.has(d.locatorHint)) { seen.add(d.locatorHint); dropdowns.push(d); } } const conditionalRevealMap = await detectCascading(page, dropdowns).catch( () => ({} as Record) ); for (const [parent, children] of Object.entries(conditionalRevealMap)) { for (const childHint of children) { const child = dropdowns.find((d) => d.locatorHint === childHint); if (child) child.cascadesFrom = parent; } } const totalOptionsFound = dropdowns.reduce((sum, d) => sum + d.options.length, 0); return { dropdowns, conditionalRevealMap, totalOptionsFound }; } catch { return { dropdowns: [], conditionalRevealMap: {}, totalOptionsFound: 0 }; } }