Skip to content

Instantly share code, notes, and snippets.

@jlia0
Created May 27, 2025 19:24
Show Gist options
  • Save jlia0/774126de395f9e910b8b46665dc9c282 to your computer and use it in GitHub Desktop.
Save jlia0/774126de395f9e910b8b46665dc9c282 to your computer and use it in GitHub Desktop.
AgentActionMap
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