/**
* Tool: a11y_scanInteractiveByText
* Tests accessibility of interactive components by finding them using visible text
*/
import { z } from 'zod';
import { Page, Locator } from 'playwright';
import { getBrowser } from '../utils/browser.js';
import { runAccessibilityScan } from '../utils/axe-scanner.js';
import { findContainerByText } from '../utils/container-finder.js';
import { captureNamedScreenshot } from '../utils/screenshot.js';
import { INTERACTIVE_ELEMENT_SELECTORS } from '../constants/selectors.js';
import {
DEFAULT_NAVIGATION_TIMEOUT,
DEFAULT_INTERACTION_WAIT,
MAX_ELEMENTS_PER_TYPE
} from '../constants/config.js';
import type { InteractiveStateScanResult, InteractiveScanResult } from '../types/scan-result.js';
export const scanInteractiveByTextSchema = z.object({
url: z.string().describe('URL to navigate to'),
containerText: z
.string()
.describe(
"Visible text to find the container by (e.g., 'Rewards', 'Navigation', 'User Profile'). " +
'Will search for this text in headings, labels, buttons, and ARIA labels.'
),
autoDiscover: z
.boolean()
.optional()
.default(true)
.describe('Automatically discover and test all interactive elements (buttons, links, inputs) within the container'),
customInteractions: z
.array(
z.object({
stateName: z.string().describe('Name for this interaction state'),
elementText: z.string().describe("Visible text of element to interact with (e.g., 'Open Menu', 'Show More')"),
action: z.enum(['click', 'hover', 'focus']).describe('Interaction type'),
waitAfter: z.number().optional().default(DEFAULT_INTERACTION_WAIT)
})
)
.optional()
.describe('Optional custom interactions in addition to auto-discovery'),
captureScreenshots: z.boolean().optional().default(false),
timeout: z.number().optional().default(DEFAULT_NAVIGATION_TIMEOUT)
});
export type ScanInteractiveByTextParams = z.infer<typeof scanInteractiveByTextSchema>;
interface DiscoveredScenario {
type: string;
text: string;
element: Locator;
hasExpandedState: boolean;
hasPressedState: boolean;
hasCheckedState: boolean;
}
/**
* Execute interactive accessibility scan
*/
export async function executeScanInteractiveByText(params: ScanInteractiveByTextParams) {
const { url, containerText, autoDiscover, customInteractions, captureScreenshots, timeout } = params;
let page: Page | null = null;
try {
const browser = await getBrowser();
const context = await browser.newContext();
page = await context.newPage();
await page.goto(url, {
waitUntil: 'networkidle',
timeout
});
// Find container by text
const { locator: containerLocator, selector: containerSelector } = await findContainerByText(
page,
containerText
);
const results: InteractiveStateScanResult[] = [];
// Initial state scan
await scanInitialState(page, containerLocator, containerSelector, captureScreenshots, results);
// Auto-discover interactive elements
if (autoDiscover) {
await autoDiscoverAndTest(page, containerLocator, containerSelector, captureScreenshots, results);
}
// Custom interactions
if (customInteractions) {
await executeCustomInteractions(
page,
containerLocator,
containerSelector,
customInteractions,
captureScreenshots,
results
);
}
await context.close();
const result: InteractiveScanResult = {
url,
containerText,
containerFound: true,
timestamp: new Date().toISOString(),
totalStates: results.length,
states: results
};
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
if (page) await page.close().catch(() => {});
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
url,
containerText,
error: error instanceof Error ? error.message : String(error)
},
null,
2
)
}
],
isError: true
};
}
}
/**
* Scan the initial state of the container
*/
async function scanInitialState(
page: Page,
containerLocator: Locator,
containerSelector: string,
captureScreenshots: boolean,
results: InteractiveStateScanResult[]
): Promise<void> {
try {
console.log('š Scanning initial state...');
const { violations, passes, incomplete } = await runAccessibilityScan(page, containerSelector);
console.log(` Initial state: ${violations.length} violations, ${passes} passes\n`);
let screenshotPath: string | undefined;
if (captureScreenshots) {
screenshotPath = await captureNamedScreenshot(containerLocator, 'initial-state');
}
results.push({
stateName: 'Initial State',
summary: {
violations: violations.length,
passes,
incomplete
},
violations,
screenshotPath
});
} catch (error) {
results.push({
stateName: 'Initial State',
summary: { violations: 0, passes: 0, incomplete: 0 },
violations: [],
error: error instanceof Error ? error.message : String(error)
});
}
}
/**
* Auto-discover and test interactive elements
*/
async function autoDiscoverAndTest(
page: Page,
containerLocator: Locator,
containerSelector: string,
captureScreenshots: boolean,
results: InteractiveStateScanResult[]
): Promise<void> {
console.log('š Auto-discovering interactive scenarios...');
const discoveredScenarios = await discoverInteractiveElements(containerLocator);
console.log(`\nā Discovered ${discoveredScenarios.length} interactive scenarios\n`);
// Test each discovered scenario
for (const scenario of discoveredScenarios) {
try {
// Click/interact with the element
await scenario.element.click();
await page.waitForTimeout(DEFAULT_INTERACTION_WAIT);
// Scan accessibility
const { violations, passes, incomplete } = await runAccessibilityScan(page, containerSelector);
let screenshotPath: string | undefined;
if (captureScreenshots) {
screenshotPath = await captureNamedScreenshot(containerLocator, `${scenario.type}-${scenario.text}`);
}
results.push({
stateName: `After interacting with ${scenario.type}: "${scenario.text}"`,
action: 'click',
elementType: scenario.type,
elementText: scenario.text,
hasStateManagement: scenario.hasExpandedState || scenario.hasPressedState || scenario.hasCheckedState,
summary: {
violations: violations.length,
passes,
incomplete
},
violations,
screenshotPath
});
console.log(` ā Tested: ${scenario.type} "${scenario.text}" - ${violations.length} violations`);
} catch (error) {
console.log(` ā Skipped: ${scenario.type} "${scenario.text}"`);
continue;
}
}
}
/**
* Discover interactive elements in the container
*/
async function discoverInteractiveElements(containerLocator: Locator): Promise<DiscoveredScenario[]> {
const discoveredScenarios: DiscoveredScenario[] = [];
for (const [type, selector] of Object.entries(INTERACTIVE_ELEMENT_SELECTORS)) {
const elements = containerLocator.locator(selector);
const count = await elements.count();
if (count > 0) {
console.log(` Found ${count} ${type}`);
}
// Test up to MAX_ELEMENTS_PER_TYPE of each type
for (let i = 0; i < Math.min(count, MAX_ELEMENTS_PER_TYPE); i++) {
try {
const element = elements.nth(i);
const isVisible = await element.isVisible();
if (!isVisible) continue;
const text =
(await element.textContent()) ||
(await element.getAttribute('aria-label')) ||
(await element.getAttribute('title')) ||
`${type} ${i + 1}`;
// Check for special states
const ariaExpanded = await element.getAttribute('aria-expanded');
const ariaPressed = await element.getAttribute('aria-pressed');
const ariaChecked = await element.getAttribute('aria-checked');
discoveredScenarios.push({
type,
text: text.trim(),
element,
hasExpandedState: ariaExpanded !== null,
hasPressedState: ariaPressed !== null,
hasCheckedState: ariaChecked !== null
});
} catch (error) {
continue;
}
}
}
return discoveredScenarios;
}
/**
* Execute custom interactions defined by the user
*/
async function executeCustomInteractions(
page: Page,
containerLocator: Locator,
containerSelector: string,
customInteractions: Array<{
stateName: string;
elementText: string;
action: 'click' | 'hover' | 'focus';
waitAfter?: number;
}>,
captureScreenshots: boolean,
results: InteractiveStateScanResult[]
): Promise<void> {
for (const interaction of customInteractions) {
try {
// Find element by text
const element = containerLocator.getByText(interaction.elementText, { exact: false }).first();
if ((await element.count()) === 0) {
results.push({
stateName: interaction.stateName,
elementText: interaction.elementText,
summary: { violations: 0, passes: 0, incomplete: 0 },
violations: [],
error: `Could not find element with text "${interaction.elementText}"`
});
continue;
}
// Perform action
switch (interaction.action) {
case 'click':
await element.click();
break;
case 'hover':
await element.hover();
break;
case 'focus':
await element.focus();
break;
}
await page.waitForTimeout(interaction.waitAfter || DEFAULT_INTERACTION_WAIT);
// Scan accessibility
const { violations, passes, incomplete } = await runAccessibilityScan(page, containerSelector);
let screenshotPath: string | undefined;
if (captureScreenshots) {
screenshotPath = await captureNamedScreenshot(containerLocator, `custom-${interaction.stateName}`);
}
results.push({
stateName: interaction.stateName,
action: interaction.action,
elementText: interaction.elementText,
summary: {
violations: violations.length,
passes,
incomplete
},
violations,
screenshotPath
});
} catch (error) {
results.push({
stateName: interaction.stateName,
elementText: interaction.elementText,
summary: { violations: 0, passes: 0, incomplete: 0 },
violations: [],
error: error instanceof Error ? error.message : String(error)
});
}
}
}