Created
May 27, 2025 19:24
-
-
Save jlia0/774126de395f9e910b8b46665dc9c282 to your computer and use it in GitHub Desktop.
AgentActionMap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useEffect, useRef, type ReactNode } from "react"; | |
interface AgentActionMapProps { | |
children: ReactNode; | |
componentName?: string; | |
description?: string; | |
generateLocators?: boolean; | |
customAttributes?: Record<string, string>; | |
} | |
interface ElementLocator { | |
element: string; | |
selector: string; | |
testId: string; | |
role?: string; | |
text?: string; | |
placeholder?: string; | |
} | |
export function AgentActionMap({ | |
children, | |
componentName = "component", | |
description = "", | |
generateLocators = true, | |
customAttributes = {}, | |
}: AgentActionMapProps) { | |
const containerRef = useRef<HTMLDivElement>(null); | |
const actionMapRef = useRef<HTMLDivElement>(null); | |
const createdTimestamp = useRef<string>(new Date().toISOString()); | |
const lastUpdateRef = useRef<string>(""); | |
const debounceTimeoutRef = useRef<number | undefined>(undefined); | |
const generateElementLocators = (): ElementLocator[] => { | |
if (!containerRef.current) return []; | |
const locators: ElementLocator[] = []; | |
const elements = containerRef.current.querySelectorAll("*"); | |
elements.forEach((element, index) => { | |
const tagName = element.tagName.toLowerCase(); | |
const className = element.className; | |
const id = element.id; | |
const role = element.getAttribute("role"); | |
const placeholder = element.getAttribute("placeholder"); | |
const type = element.getAttribute("type"); | |
const textContent = element.textContent?.trim().substring(0, 50); | |
// Generate test-id if not exists | |
let testId = element.getAttribute("data-testid"); | |
if ( | |
!testId && | |
(tagName === "button" || | |
tagName === "input" || | |
tagName === "textarea" || | |
tagName === "select" || | |
tagName === "a" || | |
role === "button" || | |
element.getAttribute("onclick")) | |
) { | |
testId = `${componentName}-${tagName}-${index}`; | |
element.setAttribute("data-testid", testId); | |
} | |
// Generate comprehensive selector | |
let selector = tagName; | |
if (id) selector += `#${id}`; | |
if (className) selector += `.${className.split(" ").join(".")}`; | |
if (type) selector += `[type="${type}"]`; | |
if (placeholder) selector += `[placeholder="${placeholder}"]`; | |
// Only include interactive or meaningful elements | |
if ( | |
testId || | |
role || | |
tagName === "button" || | |
tagName === "input" || | |
tagName === "textarea" || | |
tagName === "select" || | |
tagName === "a" || | |
element.getAttribute("onclick") | |
) { | |
locators.push({ | |
element: `${tagName}${id ? `#${id}` : ""}${ | |
className ? `.${className.split(" ")[0]}` : "" | |
}`, | |
selector, | |
testId: testId || `${componentName}-${tagName}-${index}`, | |
role: role || undefined, | |
text: textContent, | |
placeholder: placeholder || undefined, | |
}); | |
} | |
}); | |
return locators; | |
}; | |
const updateActionMap = () => { | |
if (!generateLocators || !actionMapRef.current) return; | |
const locators = generateElementLocators(); | |
// Create a hash of the current locators to check if anything actually changed | |
const currentHash = JSON.stringify( | |
locators.map((loc) => ({ | |
testId: loc.testId, | |
selector: loc.selector, | |
element: loc.element, | |
})) | |
); | |
// Skip update if nothing changed | |
if (currentHash === lastUpdateRef.current) { | |
return; | |
} | |
lastUpdateRef.current = currentHash; | |
const actionMapData = { | |
componentName, | |
description, | |
timestamp: createdTimestamp.current, | |
totalElements: locators.length, | |
locators: locators.reduce((acc, loc) => { | |
acc[loc.testId] = { | |
selector: loc.selector, | |
element: loc.element, | |
...(loc.role && { role: loc.role }), | |
...(loc.text && { text: loc.text }), | |
...(loc.placeholder && { placeholder: loc.placeholder }), | |
}; | |
return acc; | |
}, {} as Record<string, any>), | |
playwrightSelectors: { | |
byTestId: locators.map((loc) => `page.getByTestId('${loc.testId}')`), | |
byRole: locators | |
.filter((loc) => loc.role) | |
.map((loc) => `page.getByRole('${loc.role}')`), | |
byText: locators | |
.filter((loc) => loc.text) | |
.map((loc) => `page.getByText('${loc.text}')`), | |
byPlaceholder: locators | |
.filter((loc) => loc.placeholder) | |
.map((loc) => `page.getByPlaceholder('${loc.placeholder}')`), | |
}, | |
}; | |
// Store as text content for easy reading | |
actionMapRef.current.textContent = JSON.stringify(actionMapData, null, 2); | |
}; | |
useEffect(() => { | |
// Initial generation | |
updateActionMap(); | |
if (!generateLocators) return; | |
// Improved debounced update function | |
const debouncedUpdate = () => { | |
if (debounceTimeoutRef.current) { | |
clearTimeout(debounceTimeoutRef.current); | |
} | |
debounceTimeoutRef.current = window.setTimeout(() => { | |
updateActionMap(); | |
}, 500); // Increased debounce time | |
}; | |
// Set up mutation observer with more selective monitoring | |
const observer = new MutationObserver((mutations) => { | |
let shouldUpdate = false; | |
for (const mutation of mutations) { | |
// Only update if structural changes or relevant attribute changes | |
if (mutation.type === "childList") { | |
// Check if added/removed nodes are interactive elements | |
const addedNodes = Array.from(mutation.addedNodes); | |
const removedNodes = Array.from(mutation.removedNodes); | |
const hasInteractiveChanges = [...addedNodes, ...removedNodes].some( | |
(node) => { | |
if (node.nodeType !== Node.ELEMENT_NODE) return false; | |
const element = node as Element; | |
const tagName = element.tagName?.toLowerCase(); | |
return ( | |
tagName === "button" || | |
tagName === "input" || | |
tagName === "textarea" || | |
tagName === "select" || | |
tagName === "a" || | |
element.getAttribute("role") === "button" || | |
element.getAttribute("onclick") | |
); | |
} | |
); | |
if (hasInteractiveChanges) { | |
shouldUpdate = true; | |
break; | |
} | |
} else if (mutation.type === "attributes") { | |
// Only update for relevant attribute changes | |
const target = mutation.target as Element; | |
const tagName = target.tagName?.toLowerCase(); | |
const isInteractive = | |
tagName === "button" || | |
tagName === "input" || | |
tagName === "textarea" || | |
tagName === "select" || | |
tagName === "a" || | |
target.getAttribute("role") === "button"; | |
if ( | |
isInteractive && | |
mutation.attributeName && | |
[ | |
"class", | |
"id", | |
"role", | |
"data-testid", | |
"type", | |
"placeholder", | |
].includes(mutation.attributeName) | |
) { | |
shouldUpdate = true; | |
break; | |
} | |
} | |
} | |
if (shouldUpdate) { | |
debouncedUpdate(); | |
} | |
}); | |
if (containerRef.current) { | |
observer.observe(containerRef.current, { | |
childList: true, | |
subtree: true, | |
attributes: true, | |
attributeFilter: [ | |
"class", | |
"id", | |
"role", | |
"data-testid", | |
"type", | |
"placeholder", | |
], | |
}); | |
} | |
return () => { | |
observer.disconnect(); | |
if (debounceTimeoutRef.current) { | |
clearTimeout(debounceTimeoutRef.current); | |
} | |
}; | |
}, [componentName, generateLocators]); | |
return ( | |
<div | |
ref={containerRef} | |
data-agent-component={componentName} | |
data-automation-ready="true" | |
{...customAttributes} | |
> | |
<div | |
ref={actionMapRef} | |
id={`agent-action-map-${componentName}`} | |
style={{ display: "none" }} | |
> | |
Agent Action Map for {componentName} | |
</div> | |
{children} | |
</div> | |
); | |
} | |
// Utility function to extract action map from any component | |
export function getActionMap(componentName: string): any { | |
const element = document.getElementById(`agent-action-map-${componentName}`); | |
if (!element) return null; | |
try { | |
return JSON.parse(element.textContent || "{}"); | |
} catch { | |
return null; | |
} | |
} | |
// Utility function to get all available action maps | |
export function getAllActionMaps(): Record<string, any> { | |
const maps: Record<string, any> = {}; | |
const elements = document.querySelectorAll('[id^="agent-action-map-"]'); | |
elements.forEach((element) => { | |
try { | |
const actionMapData = JSON.parse(element.textContent || "{}"); | |
if (actionMapData.componentName) { | |
maps[actionMapData.componentName] = actionMapData; | |
} | |
} catch { | |
// Skip invalid JSON | |
} | |
}); | |
return maps; | |
} | |
export default AgentActionMap; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment