diff --git a/maxun-core/src/browserSide/scraper.js b/maxun-core/src/browserSide/scraper.js index 9321693bf..ba688c474 100644 --- a/maxun-core/src/browserSide/scraper.js +++ b/maxun-core/src/browserSide/scraper.js @@ -424,26 +424,214 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, */ window.scrapeList = async function ({ listSelector, fields, limit = 10 }) { // XPath evaluation functions - const evaluateXPath = (rootElement, xpath) => { + const queryInsideContext = (context, part) => { try { - const ownerDoc = - rootElement.nodeType === Node.DOCUMENT_NODE - ? rootElement - : rootElement.ownerDocument; + const { tagName, conditions } = parseXPathPart(part); - if (!ownerDoc) return null; + const candidateElements = Array.from(context.querySelectorAll(tagName)); + if (candidateElements.length === 0) { + return []; + } - const result = ownerDoc.evaluate( + const matchingElements = candidateElements.filter((el) => { + return elementMatchesConditions(el, conditions); + }); + + return matchingElements; + } catch (err) { + console.error("Error in queryInsideContext:", err); + return []; + } + }; + + // Helper function to parse XPath part + const parseXPathPart = (part) => { + const tagMatch = part.match(/^([a-zA-Z0-9-]+)/); + const tagName = tagMatch ? tagMatch[1] : "*"; + + const conditionMatches = part.match(/\[([^\]]+)\]/g); + const conditions = conditionMatches + ? conditionMatches.map((c) => c.slice(1, -1)) + : []; + + return { tagName, conditions }; + }; + + // Helper function to check if element matches all conditions + const elementMatchesConditions = (element, conditions) => { + for (const condition of conditions) { + if (!elementMatchesCondition(element, condition)) { + return false; + } + } + return true; + }; + + // Helper function to check if element matches a single condition + const elementMatchesCondition = (element, condition) => { + condition = condition.trim(); + + if (/^\d+$/.test(condition)) { + return true; + } + + // Handle @attribute="value" + const attrMatch = condition.match(/^@([^=]+)=["']([^"']+)["']$/); + if (attrMatch) { + const [, attr, value] = attrMatch; + const elementValue = element.getAttribute(attr); + return elementValue === value; + } + + // Handle contains(@class, 'value') + const classContainsMatch = condition.match( + /^contains\(@class,\s*["']([^"']+)["']\)$/ + ); + if (classContainsMatch) { + const className = classContainsMatch[1]; + return element.classList.contains(className); + } + + // Handle contains(@attribute, 'value') + const attrContainsMatch = condition.match( + /^contains\(@([^,]+),\s*["']([^"']+)["']\)$/ + ); + if (attrContainsMatch) { + const [, attr, value] = attrContainsMatch; + const elementValue = element.getAttribute(attr) || ""; + return elementValue.includes(value); + } + + // Handle text()="value" + const textMatch = condition.match(/^text\(\)=["']([^"']+)["']$/); + if (textMatch) { + const expectedText = textMatch[1]; + const elementText = element.textContent?.trim() || ""; + return elementText === expectedText; + } + + // Handle contains(text(), 'value') + const textContainsMatch = condition.match( + /^contains\(text\(\),\s*["']([^"']+)["']\)$/ + ); + if (textContainsMatch) { + const expectedText = textContainsMatch[1]; + const elementText = element.textContent?.trim() || ""; + return elementText.includes(expectedText); + } + + // Handle count(*)=0 (element has no children) + if (condition === "count(*)=0") { + return element.children.length === 0; + } + + // Handle other count conditions + const countMatch = condition.match(/^count\(\*\)=(\d+)$/); + if (countMatch) { + const expectedCount = parseInt(countMatch[1]); + return element.children.length === expectedCount; + } + + return true; + }; + + const evaluateXPath = (document, xpath, isShadow = false) => { + try { + const result = document.evaluate( xpath, - rootElement, + document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null - ); + ).singleNodeValue; - return result.singleNodeValue; - } catch (error) { - console.warn("XPath evaluation failed:", xpath, error); + if (!isShadow) { + if (result === null) { + return null; + } + return result; + } + + let cleanPath = xpath; + let isIndexed = false; + + const indexedMatch = xpath.match(/^\((.*?)\)\[(\d+)\](.*)$/); + if (indexedMatch) { + cleanPath = indexedMatch[1] + indexedMatch[3]; + isIndexed = true; + } + + const pathParts = cleanPath + .replace(/^\/\//, "") + .split("/") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + let currentContexts = [document]; + + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + const nextContexts = []; + + for (const ctx of currentContexts) { + const positionalMatch = part.match(/^([^[]+)\[(\d+)\]$/); + let partWithoutPosition = part; + let requestedPosition = null; + + if (positionalMatch) { + partWithoutPosition = positionalMatch[1]; + requestedPosition = parseInt(positionalMatch[2]); + } + + const matched = queryInsideContext(ctx, partWithoutPosition); + + let elementsToAdd = matched; + if (requestedPosition !== null) { + const index = requestedPosition - 1; // XPath is 1-based, arrays are 0-based + if (index >= 0 && index < matched.length) { + elementsToAdd = [matched[index]]; + } else { + console.warn( + `Position ${requestedPosition} out of range (${matched.length} elements found)` + ); + elementsToAdd = []; + } + } + + elementsToAdd.forEach((el) => { + nextContexts.push(el); + if (el.shadowRoot) { + nextContexts.push(el.shadowRoot); + } + }); + } + + if (nextContexts.length === 0) { + return null; + } + + currentContexts = nextContexts; + } + + if (currentContexts.length > 0) { + if (isIndexed && indexedMatch) { + const requestedIndex = parseInt(indexedMatch[2]) - 1; + if (requestedIndex >= 0 && requestedIndex < currentContexts.length) { + return currentContexts[requestedIndex]; + } else { + console.warn( + `Requested index ${requestedIndex + 1} out of range (${currentContexts.length} elements found)` + ); + return null; + } + } + + return currentContexts[0]; + } + + return null; + } catch (err) { + console.error("Critical XPath failure:", xpath, err); return null; } }; @@ -1018,7 +1206,7 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, listSelector, containerIndex + 1 ); - element = evaluateXPath(document, indexedSelector); + element = evaluateXPath(document, indexedSelector, field.isShadow); } else { // Fallback for CSS selectors within XPath containers const container = containers[containerIndex]; diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index d3143f32c..1b9321444 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -147,7 +147,18 @@ export const BrowserWindow = () => { const { browserWidth, browserHeight } = useBrowserDimensionsStore(); const [canvasRef, setCanvasReference] = useState | undefined>(undefined); const [screenShot, setScreenShot] = useState(""); - const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[], groupElements?: Array<{ element: HTMLElement; rect: DOMRect } >} | null>(null); + const [highlighterData, setHighlighterData] = useState<{ + rect: DOMRect; + selector: string; + elementInfo: ElementInfo | null; + isShadow?: boolean; + childSelectors?: string[]; + groupElements?: Array<{ element: HTMLElement; rect: DOMRect }>; + similarElements?: { + elements: HTMLElement[]; + rects: DOMRect[]; + }; + } | null>(null); const [showAttributeModal, setShowAttributeModal] = useState(false); const [attributeOptions, setAttributeOptions] = useState([]); const [selectedElement, setSelectedElement] = useState<{ selector: string, info: ElementInfo | null } | null>(null); @@ -161,11 +172,20 @@ export const BrowserWindow = () => { const [paginationSelector, setPaginationSelector] = useState(''); const highlighterUpdateRef = useRef(0); + const [isCachingChildSelectors, setIsCachingChildSelectors] = useState(false); + const [cachedListSelector, setCachedListSelector] = useState( + null + ); + const [pendingNotification, setPendingNotification] = useState<{ + type: "error" | "warning" | "info" | "success"; + message: string; + count?: number; + } | null>(null); const { socket } = useSocketStore(); const { notify, currentTextActionId, currentListActionId, updateDOMMode, isDOMMode, currentSnapshot } = useGlobalInfoStore(); const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext(); - const { addTextStep, addListStep, updateListStepData } = useBrowserSteps(); + const { addTextStep, addListStep } = useBrowserSteps(); const [currentGroupInfo, setCurrentGroupInfo] = useState<{ isGroupElement: boolean; @@ -270,17 +290,6 @@ export const BrowserWindow = () => { [user?.id, socket, updateDOMMode] ); - const screenshotModeHandler = useCallback( - (data: any) => { - if (!data.userId || data.userId === user?.id) { - updateDOMMode(false); - socket?.emit("screenshot-mode-enabled"); - setIsLoading(false); - } - }, - [user?.id, updateDOMMode] - ); - const domModeErrorHandler = useCallback( (data: any) => { if (!data.userId || data.userId === user?.id) { @@ -300,28 +309,68 @@ export const BrowserWindow = () => { }, [isDOMMode, getList, listSelector, paginationMode]); useEffect(() => { - if (isDOMMode && listSelector) { - socket?.emit("setGetList", { getList: true }); - socket?.emit("listSelector", { selector: listSelector }); - - clientSelectorGenerator.setListSelector(listSelector); + if (isDOMMode && listSelector) { + socket?.emit("setGetList", { getList: true }); + socket?.emit("listSelector", { selector: listSelector }); + + clientSelectorGenerator.setListSelector(listSelector); + + if (currentSnapshot && cachedListSelector !== listSelector) { + setCachedChildSelectors([]); + setIsCachingChildSelectors(true); + setCachedListSelector(listSelector); + + const iframeElement = document.querySelector( + "#dom-browser-iframe" + ) as HTMLIFrameElement; + + if (iframeElement?.contentDocument) { + setTimeout(() => { + try { + const childSelectors = + clientSelectorGenerator.getChildSelectors( + iframeElement.contentDocument as Document, + listSelector + ); + + clientSelectorGenerator.precomputeChildSelectorMappings( + childSelectors, + iframeElement.contentDocument as Document + ); - setCachedChildSelectors([]); + setCachedChildSelectors(childSelectors); + } catch (error) { + console.error("Error during child selector caching:", error); + } finally { + setIsCachingChildSelectors(false); - if (currentSnapshot) { - const iframeElement = document.querySelector( - "#dom-browser-iframe" - ) as HTMLIFrameElement; - if (iframeElement?.contentDocument) { - const childSelectors = clientSelectorGenerator.getChildSelectors( - iframeElement.contentDocument, - listSelector - ); - setCachedChildSelectors(childSelectors); + if (pendingNotification) { + notify(pendingNotification.type, pendingNotification.message); + setPendingNotification(null); } - } + } + }, 100); + } else { + setIsCachingChildSelectors(false); + } + } + } + }, [ + isDOMMode, + listSelector, + socket, + getList, + currentSnapshot, + cachedListSelector, + pendingNotification, + notify, + ]); + + useEffect(() => { + if (!listSelector) { + setCachedListSelector(null); } - }, [isDOMMode, listSelector, socket, getList, currentSnapshot]); + }, [listSelector]); useEffect(() => { coordinateMapper.updateDimensions(dimensions.width, dimensions.height, viewportInfo.width, viewportInfo.height); @@ -389,7 +438,6 @@ export const BrowserWindow = () => { socket.on("screencast", screencastHandler); socket.on("domcast", rrwebSnapshotHandler); socket.on("dom-mode-enabled", domModeHandler); - // socket.on("screenshot-mode-enabled", screenshotModeHandler); socket.on("dom-mode-error", domModeErrorHandler); } @@ -399,11 +447,9 @@ export const BrowserWindow = () => { return () => { if (socket) { - console.log("Cleaning up DOM streaming event listeners"); socket.off("screencast", screencastHandler); socket.off("domcast", rrwebSnapshotHandler); socket.off("dom-mode-enabled", domModeHandler); - // socket.off("screenshot-mode-enabled", screenshotModeHandler); socket.off("dom-mode-error", domModeErrorHandler); } }; @@ -415,7 +461,6 @@ export const BrowserWindow = () => { screencastHandler, rrwebSnapshotHandler, domModeHandler, - // screenshotModeHandler, domModeErrorHandler, ]); @@ -425,12 +470,17 @@ export const BrowserWindow = () => { selector: string; elementInfo: ElementInfo | null; childSelectors?: string[]; + isShadow?: boolean; groupInfo?: { isGroupElement: boolean; groupSize: number; groupElements: HTMLElement[]; groupFingerprint: ElementFingerprint; }; + similarElements?: { + elements: HTMLElement[]; + rects: DOMRect[]; + }; isDOMMode?: boolean; }) => { if (!getText && !getList) { @@ -460,6 +510,22 @@ export const BrowserWindow = () => { const iframeRect = iframeElement.getBoundingClientRect(); const IFRAME_BODY_PADDING = 16; + let mappedSimilarElements; + if (data.similarElements) { + mappedSimilarElements = { + elements: data.similarElements.elements, + rects: data.similarElements.rects.map( + (rect) => + new DOMRect( + rect.x + iframeRect.left - IFRAME_BODY_PADDING, + rect.y + iframeRect.top - IFRAME_BODY_PADDING, + rect.width, + rect.height + ) + ), + }; + } + if (data.groupInfo) { setCurrentGroupInfo(data.groupInfo); } else { @@ -477,6 +543,7 @@ export const BrowserWindow = () => { ...data, rect: absoluteRect, childSelectors: data.childSelectors || cachedChildSelectors, + similarElements: mappedSimilarElements, }; if (getList === true) { @@ -638,21 +705,6 @@ export const BrowserWindow = () => { } }, [getList, socket, listSelector, paginationMode, paginationType, limitMode]); - useEffect(() => { - document.addEventListener('mousemove', onMouseMove, false); - if (socket) { - socket.off("highlighter", highlighterHandler); - - socket.on("highlighter", highlighterHandler); - } - return () => { - document.removeEventListener('mousemove', onMouseMove); - if (socket) { - socket.off("highlighter", highlighterHandler); - } - }; - }, [socket, highlighterHandler, onMouseMove, getList, listSelector]); - useEffect(() => { document.addEventListener("mousemove", onMouseMove, false); if (socket) { @@ -669,7 +721,6 @@ export const BrowserWindow = () => { useEffect(() => { if (socket && listSelector) { - console.log('Syncing list selector with server:', listSelector); socket.emit('setGetList', { getList: true }); socket.emit('listSelector', { selector: listSelector }); } @@ -686,6 +737,7 @@ export const BrowserWindow = () => { (highlighterData: { rect: DOMRect; selector: string; + isShadow?: boolean; elementInfo: ElementInfo | null; childSelectors?: string[]; groupInfo?: { @@ -713,11 +765,17 @@ export const BrowserWindow = () => { ) ); addListStep( - listSelector!, - fields, - currentListId || 0, - currentListActionId || `list-${crypto.randomUUID()}`, - { type: paginationType, selector: highlighterData.selector } + listSelector!, + fields, + currentListId || 0, + currentListActionId || `list-${crypto.randomUUID()}`, + { + type: paginationType, + selector: highlighterData.selector, + isShadow: highlighterData.isShadow + }, + undefined, + highlighterData.isShadow ); socket?.emit("setPaginationMode", { pagination: false }); } @@ -776,7 +834,7 @@ export const BrowserWindow = () => { selectorObj: { selector: currentSelector, tag: highlighterData.elementInfo?.tagName, - shadow: highlighterData.elementInfo?.isShadowRoot, + isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot, attribute, }, }; @@ -794,7 +852,9 @@ export const BrowserWindow = () => { updatedFields, currentListId, currentListActionId || `list-${crypto.randomUUID()}`, - { type: "", selector: paginationSelector } + { type: "", selector: paginationSelector }, + undefined, + highlighterData.isShadow ); } } else { @@ -829,7 +889,7 @@ export const BrowserWindow = () => { { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, - shadow: highlighterData.elementInfo?.isShadowRoot, + isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot, attribute, }, currentTextActionId || `text-${crypto.randomUUID()}` @@ -908,7 +968,7 @@ export const BrowserWindow = () => { { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, - shadow: highlighterData.elementInfo?.isShadowRoot, + isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot, attribute, }, currentTextActionId || `text-${crypto.randomUUID()}` @@ -942,7 +1002,9 @@ export const BrowserWindow = () => { fields, currentListId || 0, currentListActionId || `list-${crypto.randomUUID()}`, - { type: paginationType, selector: highlighterData.selector } + { type: paginationType, selector: highlighterData.selector, isShadow: highlighterData.isShadow }, + undefined, + highlighterData.isShadow ); socket?.emit("setPaginationMode", { pagination: false }); } @@ -1000,7 +1062,7 @@ export const BrowserWindow = () => { selectorObj: { selector: currentSelector, tag: highlighterData.elementInfo?.tagName, - shadow: highlighterData.elementInfo?.isShadowRoot, + isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot, attribute, }, }; @@ -1018,7 +1080,9 @@ export const BrowserWindow = () => { updatedFields, currentListId, currentListActionId || `list-${crypto.randomUUID()}`, - { type: "", selector: paginationSelector } + { type: "", selector: paginationSelector, isShadow: highlighterData.isShadow }, + undefined, + highlighterData.isShadow ); } } else { @@ -1052,7 +1116,7 @@ export const BrowserWindow = () => { addTextStep('', data, { selector: selectedElement.selector, tag: selectedElement.info?.tagName, - shadow: selectedElement.info?.isShadowRoot, + isShadow: highlighterData?.isShadow || selectedElement.info?.isShadowRoot, attribute: attribute }, currentTextActionId || `text-${crypto.randomUUID()}`); } @@ -1065,7 +1129,7 @@ export const BrowserWindow = () => { selectorObj: { selector: selectedElement.selector, tag: selectedElement.info?.tagName, - shadow: selectedElement.info?.isShadowRoot, + isShadow: highlighterData?.isShadow || highlighterData?.elementInfo?.isShadowRoot, attribute: attribute } }; @@ -1083,7 +1147,9 @@ export const BrowserWindow = () => { updatedFields, currentListId, currentListActionId || `list-${crypto.randomUUID()}`, - { type: '', selector: paginationSelector } + { type: "", selector: paginationSelector, isShadow: highlighterData?.isShadow }, + undefined, + highlighterData?.isShadow ); } } @@ -1110,260 +1176,370 @@ export const BrowserWindow = () => { }, [paginationMode, resetPaginationSelector]); return ( -
- { - getText === true || getList === true ? ( - { - setShowAttributeModal(false); - setSelectedElement(null); - setAttributeOptions([]); - }} - canBeClosed={true} - modalStyle={modalStyle} +
+ {/* Attribute selection modal */} + {(getText === true || getList === true) && ( + { + setShowAttributeModal(false); + setSelectedElement(null); + setAttributeOptions([]); + }} + canBeClosed={true} + modalStyle={modalStyle} + > +
+

Select Attribute

+
+ {attributeOptions.map((option) => ( + - ))} -
-
-
- ) : null - } - - {datePickerInfo && ( - setDatePickerInfo(null)} - /> - )} - {dropdownInfo && ( - setDropdownInfo(null)} - /> - )} - {timePickerInfo && ( - setTimePickerInfo(null)} - /> - )} - {dateTimeLocalInfo && ( - setDateTimeLocalInfo(null)} - /> - )} - -
- {(getText === true || getList === true) && - !showAttributeModal && - highlighterData?.rect != null && ( - <> - {!isDOMMode && canvasRef?.current && ( - - )} - - {isDOMMode && highlighterData && ( - <> - {/* Individual element highlight (for non-group or hovered element) */} - {(!getList || - listSelector || - !currentGroupInfo?.isGroupElement) && ( -
- )} - - {/* Group elements highlighting with real-time coordinates */} - {getList && - !listSelector && - currentGroupInfo?.isGroupElement && - highlighterData.groupElements && - highlighterData.groupElements.map((groupElement, index) => ( - - {/* Highlight box */} -
- -
- List item {index + 1} -
- - ))} - - )} - + {option.label} + + + ))} +
+
+ + )} + + {datePickerInfo && ( + setDatePickerInfo(null)} + /> + )} + {dropdownInfo && ( + setDropdownInfo(null)} + /> + )} + {timePickerInfo && ( + setTimePickerInfo(null)} + /> + )} + {dateTimeLocalInfo && ( + setDateTimeLocalInfo(null)} + /> + )} + + {/* Main content area */} +
+ {/* Add CSS for the spinner animation */} + + + {(getText === true || getList === true) && + !showAttributeModal && + highlighterData?.rect != null && ( + <> + {!isDOMMode && canvasRef?.current && ( + )} - {isDOMMode ? ( - currentSnapshot ? ( - { - domHighlighterHandler(data); - }} - onElementSelect={handleDOMElementSelection} - onShowDatePicker={handleShowDatePicker} - onShowDropdown={handleShowDropdown} - onShowTimePicker={handleShowTimePicker} - onShowDateTimePicker={handleShowDateTimePicker} - /> - ) : ( -
+ {/* Individual element highlight (for non-group or hovered element) */} + {getText && !listSelector && ( +
-
-
- Loading website... -
- -
- ) - ) : ( - /* Screenshot mode canvas */ - + /> + )} + + {/* Group elements highlighting with real-time coordinates */} + {getList && + !listSelector && + currentGroupInfo?.isGroupElement && + highlighterData.groupElements && + highlighterData.groupElements.map( + (groupElement, index) => ( + + {/* Highlight box */} +
+ +
+ List item {index + 1} +
+ + ) + )} + + {getList && + listSelector && + !paginationMode && + !limitMode && + highlighterData?.similarElements && + highlighterData.similarElements.rects.map( + (rect, index) => ( + + {/* Highlight box for similar element */} +
+ + {/* Label for similar element */} +
+ Item {index + 1} +
+ + ) + )} + )} + + )} + + {isDOMMode ? ( +
+ {currentSnapshot ? ( + { + domHighlighterHandler(data); + }} + isCachingChildSelectors={isCachingChildSelectors} + onElementSelect={handleDOMElementSelection} + onShowDatePicker={handleShowDatePicker} + onShowDropdown={handleShowDropdown} + onShowTimePicker={handleShowTimePicker} + onShowDateTimePicker={handleShowDateTimePicker} + /> + ) : ( +
+
+
+ Loading website... +
+ +
+ )} + + {/* Loading overlay positioned specifically over DOM content */} + {isCachingChildSelectors && ( +
+
+
+ )}
+ ) : ( + /* Screenshot mode canvas */ + + )}
+
); }; diff --git a/src/components/recorder/DOMBrowserRenderer.tsx b/src/components/recorder/DOMBrowserRenderer.tsx index 2bd61e012..2828a114e 100644 --- a/src/components/recorder/DOMBrowserRenderer.tsx +++ b/src/components/recorder/DOMBrowserRenderer.tsx @@ -102,16 +102,20 @@ interface RRWebDOMBrowserRendererProps { paginationMode?: boolean; paginationType?: string; limitMode?: boolean; + isCachingChildSelectors?: boolean; onHighlight?: (data: { rect: DOMRect; selector: string; + isShadow?: boolean; elementInfo: ElementInfo | null; childSelectors?: string[]; groupInfo?: any; + similarElements?: any; }) => void; onElementSelect?: (data: { rect: DOMRect; selector: string; + isShadow?: boolean; elementInfo: ElementInfo | null; childSelectors?: string[]; groupInfo?: any; @@ -151,6 +155,7 @@ export const DOMBrowserRenderer: React.FC = ({ paginationMode = false, paginationType = "", limitMode = false, + isCachingChildSelectors = false, onHighlight, onElementSelect, onShowDatePicker, @@ -241,17 +246,15 @@ export const DOMBrowserRenderer: React.FC = ({ return; } - const { rect, selector, elementInfo, childSelectors, groupInfo } = + const { rect, selector, elementInfo, childSelectors, groupInfo, similarElements, isShadow } = highlighterData; let shouldHighlight = false; if (getList) { - // First phase: Allow any group to be highlighted for selection if (!listSelector && groupInfo?.isGroupElement) { shouldHighlight = true; } - // Second phase: Show valid children within selected group else if (listSelector) { if (limitMode) { shouldHighlight = false; @@ -262,19 +265,15 @@ export const DOMBrowserRenderer: React.FC = ({ ) { shouldHighlight = true; } else if (childSelectors && childSelectors.length > 0) { - console.log("✅ Child selectors present, highlighting enabled"); shouldHighlight = true; } else { - console.log("❌ No child selectors available"); shouldHighlight = false; } } - // No list selector - show regular highlighting else { shouldHighlight = true; } } else { - // getText mode - always highlight shouldHighlight = true; } @@ -302,8 +301,10 @@ export const DOMBrowserRenderer: React.FC = ({ isDOMMode: true, }, selector, + isShadow, childSelectors, - groupInfo, + groupInfo, + similarElements, // Pass similar elements data }); } } @@ -333,7 +334,6 @@ export const DOMBrowserRenderer: React.FC = ({ onHighlight, ] ); - /** * Set up enhanced interaction handlers for DOM mode */ @@ -408,6 +408,7 @@ export const DOMBrowserRenderer: React.FC = ({ rect: currentHighlight.rect, selector: currentHighlight.selector, elementInfo: currentHighlight.elementInfo, + isShadow: highlighterData?.isShadow, childSelectors: cachedChildSelectors.length > 0 ? cachedChildSelectors @@ -756,7 +757,7 @@ export const DOMBrowserRenderer: React.FC = ({ return; } - if (isInCaptureMode) { + if (isInCaptureMode || isCachingChildSelectors) { return; // Skip rendering in capture mode } @@ -867,7 +868,7 @@ export const DOMBrowserRenderer: React.FC = ({ showErrorInIframe(error); } }, - [setupIframeInteractions, isInCaptureMode] + [setupIframeInteractions, isInCaptureMode, isCachingChildSelectors] ); useEffect(() => { @@ -1083,7 +1084,7 @@ export const DOMBrowserRenderer: React.FC = ({ left: 0, right: 0, bottom: 0, - cursor: "pointer !important", + cursor: "pointer", pointerEvents: "none", zIndex: 999, borderRadius: "0px 0px 5px 5px", diff --git a/src/components/recorder/RightSidePanel.tsx b/src/components/recorder/RightSidePanel.tsx index ba6c63461..ab580352c 100644 --- a/src/components/recorder/RightSidePanel.tsx +++ b/src/components/recorder/RightSidePanel.tsx @@ -463,14 +463,15 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const getListSettingsObject = useCallback(() => { let settings: { listSelector?: string; - fields?: Record; - pagination?: { type: string; selector?: string }; + fields?: Record; + pagination?: { type: string; selector?: string; isShadow?: boolean }; limit?: number; + isShadow?: boolean } = {}; browserSteps.forEach(step => { if (step.type === 'list' && step.listSelector && Object.keys(step.fields).length > 0) { - const fields: Record = {}; + const fields: Record = {}; Object.entries(step.fields).forEach(([id, field]) => { if (field.selectorObj?.selector) { @@ -478,6 +479,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture selector: field.selectorObj.selector, tag: field.selectorObj.tag, attribute: field.selectorObj.attribute, + isShadow: field.selectorObj.isShadow }; } }); @@ -485,8 +487,9 @@ export const RightSidePanel: React.FC = ({ onFinishCapture settings = { listSelector: step.listSelector, fields: fields, - pagination: { type: paginationType, selector: step.pagination?.selector }, + pagination: { type: paginationType, selector: step.pagination?.selector, isShadow: step.isShadow }, limit: parseInt(limitType === 'custom' ? customLimit : limitType), + isShadow: step.isShadow }; } }); diff --git a/src/context/browserSteps.tsx b/src/context/browserSteps.tsx index 8b4769aa9..0931813e1 100644 --- a/src/context/browserSteps.tsx +++ b/src/context/browserSteps.tsx @@ -5,8 +5,9 @@ export interface TextStep { type: 'text'; label: string; data: string; + isShadow?: boolean; selectorObj: SelectorObject; - actionId?: string; + actionId?: string; } interface ScreenshotStep { @@ -14,20 +15,22 @@ interface ScreenshotStep { type: 'screenshot'; fullPage: boolean; actionId?: string; - screenshotData?: string; + screenshotData?: string; } export interface ListStep { id: number; type: 'list'; listSelector: string; + isShadow?: boolean; fields: { [key: string]: TextStep }; pagination?: { type: string; selector: string; + isShadow?: boolean; }; limit?: number; - actionId?: string; + actionId?: string; } export type BrowserStep = TextStep | ScreenshotStep | ListStep; @@ -36,14 +39,14 @@ export interface SelectorObject { selector: string; tag?: string; attribute?: string; - shadow?: boolean; + isShadow?: boolean; [key: string]: any; } interface BrowserStepsContextType { browserSteps: BrowserStep[]; addTextStep: (label: string, data: string, selectorObj: SelectorObject, actionId: string) => void; - addListStep: (listSelector: string, fields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string }, limit?: number) => void + addListStep: (listSelector: string, fields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string, isShadow?: boolean }, limit?: number, isShadow?: boolean) => void addScreenshotStep: (fullPage: boolean, actionId: string) => void; deleteBrowserStep: (id: number) => void; updateBrowserTextStepLabel: (id: number, newLabel: string) => void; @@ -51,7 +54,7 @@ interface BrowserStepsContextType { updateListStepLimit: (listId: number, limit: number) => void; updateListStepData: (listId: number, extractedData: any[]) => void; removeListTextField: (listId: number, fieldKey: string) => void; - deleteStepsByActionId: (actionId: string) => void; + deleteStepsByActionId: (actionId: string) => void; updateScreenshotStepData: (id: number, screenshotData: string) => void; } @@ -68,14 +71,22 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ ]); }; - const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string }, limit?: number) => { + const addListStep = ( + listSelector: string, + newFields: { [key: string]: TextStep }, + listId: number, + actionId: string, + pagination?: { type: string; selector: string; isShadow?: boolean }, + limit?: number, + isShadow?: boolean + ) => { setBrowserSteps(prevSteps => { const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId); if (existingListStepIndex !== -1) { const updatedSteps = [...prevSteps]; const existingListStep = updatedSteps[existingListStepIndex] as ListStep; - + // Preserve existing labels for fields const mergedFields = Object.entries(newFields).reduce((acc, [key, field]) => { if (!discardedFields.has(`${listId}-${key}`)) { @@ -95,13 +106,14 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ } return acc; }, {} as { [key: string]: TextStep }); - + updatedSteps[existingListStepIndex] = { ...existingListStep, fields: mergedFields, pagination: pagination || existingListStep.pagination, limit: limit, - actionId + actionId, + isShadow: isShadow !== undefined ? isShadow : existingListStep.isShadow }; return updatedSteps; } else { @@ -115,7 +127,16 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ return [ ...prevSteps, - { id: listId, type: 'list', listSelector, fields: fieldsWithActionId, pagination, limit, actionId } + { + id: listId, + type: 'list', + listSelector, + fields: fieldsWithActionId, + pagination, + limit, + actionId, + isShadow + } ]; } }); @@ -236,7 +257,7 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ updateListStepLimit, updateListStepData, removeListTextField, - deleteStepsByActionId, + deleteStepsByActionId, updateScreenshotStepData, }}> {children} diff --git a/src/helpers/clientListExtractor.ts b/src/helpers/clientListExtractor.ts index 790abdea2..9869944e2 100644 --- a/src/helpers/clientListExtractor.ts +++ b/src/helpers/clientListExtractor.ts @@ -6,7 +6,7 @@ interface TextStep { selectorObj: { selector: string; tag?: string; - shadow?: boolean; + isShadow?: boolean; attribute: string; }; } @@ -18,6 +18,8 @@ interface ExtractedListData { interface Field { selector: string; attribute: string; + tag?: string; + isShadow?: boolean; } class ClientListExtractor { @@ -156,50 +158,6 @@ class ClientListExtractor { } } - if ( - !nextElement && - "shadowRoot" in currentElement && - (currentElement as Element).shadowRoot - ) { - if ( - parts[i].startsWith("//") || - parts[i].startsWith("/") || - parts[i].startsWith("./") - ) { - nextElement = this.evaluateXPath( - (currentElement as Element).shadowRoot as unknown as Document, - parts[i] - ); - } else { - nextElement = (currentElement as Element).shadowRoot!.querySelector( - parts[i] - ); - } - } - - if (!nextElement && "children" in currentElement) { - const children: any = Array.from( - (currentElement as Element).children || [] - ); - for (const child of children) { - if (child.shadowRoot) { - if ( - parts[i].startsWith("//") || - parts[i].startsWith("/") || - parts[i].startsWith("./") - ) { - nextElement = this.evaluateXPath( - child.shadowRoot as unknown as Document, - parts[i] - ); - } else { - nextElement = child.shadowRoot.querySelector(parts[i]); - } - if (nextElement) break; - } - } - } - currentElement = nextElement; } @@ -265,43 +223,6 @@ class ClientListExtractor { nextElements.push(...Array.from(element.querySelectorAll(part))); } } - - if ("shadowRoot" in element && (element as Element).shadowRoot) { - if (part.startsWith("//") || part.startsWith("/")) { - nextElements.push( - ...this.evaluateXPathAll( - (element as Element).shadowRoot as unknown as Document, - part - ) - ); - } else { - nextElements.push( - ...Array.from( - (element as Element).shadowRoot!.querySelectorAll(part) - ) - ); - } - } - - if ("children" in element) { - const children = Array.from((element as Element).children || []); - for (const child of children) { - if (child.shadowRoot) { - if (part.startsWith("//") || part.startsWith("/")) { - nextElements.push( - ...this.evaluateXPathAll( - child.shadowRoot as unknown as Document, - part - ) - ); - } else { - nextElements.push( - ...Array.from(child.shadowRoot.querySelectorAll(part)) - ); - } - } - } - } } } @@ -328,14 +249,11 @@ class ClientListExtractor { } if (attribute === "innerText") { - // First try standard innerText/textContent let textContent = (element as HTMLElement).innerText?.trim() || (element as HTMLElement).textContent?.trim(); - // If empty, check for common data attributes that might contain the text if (!textContent) { - // Check for data-* attributes that commonly contain text values const dataAttributes = [ "data-600", "data-text", @@ -356,10 +274,8 @@ class ClientListExtractor { } else if (attribute === "innerHTML") { return element.innerHTML?.trim() || null; } else if (attribute === "href") { - // For href, we need to find the anchor tag if the current element isn't one let anchorElement = element; - // If current element is not an anchor, look for parent anchor if (element.tagName !== "A") { anchorElement = element.closest("a") || @@ -410,6 +326,7 @@ class ClientListExtractor { convertedFields[typedField.label] = { selector: typedField.selectorObj.selector, attribute: typedField.selectorObj.attribute, + isShadow: typedField.selectorObj.isShadow || false, }; } @@ -423,10 +340,8 @@ class ClientListExtractor { limit: number = 5 ): ExtractedListData[] => { try { - // Convert fields to the format expected by the extraction logic const convertedFields = this.convertFields(fields); - // Step 1: Get all container elements matching the list selector const containers = this.queryElementAll(iframeDocument, listSelector); if (containers.length === 0) { @@ -434,7 +349,6 @@ class ClientListExtractor { return []; } - // Step 2: Extract data from each container up to the limit const extractedData: ExtractedListData[] = []; const containersToProcess = Math.min(containers.length, limit); @@ -446,28 +360,27 @@ class ClientListExtractor { const container = containers[containerIndex]; const record: ExtractedListData = {}; - // Step 3: For each field, extract data from the current container - for (const [label, { selector, attribute }] of Object.entries( + for (const [label, { selector, attribute, isShadow }] of Object.entries( convertedFields )) { let element: Element | null = null; - // CORRECT APPROACH: Create indexed absolute XPath if (selector.startsWith("//")) { - // Convert the absolute selector to target the specific container instance const indexedSelector = this.createIndexedXPath( selector, listSelector, containerIndex + 1 ); - element = this.evaluateXPathSingle(iframeDocument, indexedSelector); + element = this.evaluateXPathSingle( + iframeDocument, + indexedSelector, + isShadow + ); } else { - // Fallback for non-XPath selectors element = this.queryElement(container, selector); } - // Step 4: Extract the value from the found element if (element) { const value = this.extractValue(element, attribute); if (value !== null && value !== "") { @@ -482,7 +395,6 @@ class ClientListExtractor { } } - // Step 5: Add record if it has any non-empty values if (Object.values(record).some((value) => value !== "")) { extractedData.push(record); } else { @@ -499,15 +411,12 @@ class ClientListExtractor { } }; - // Create indexed XPath for specific container instance private createIndexedXPath( childSelector: string, listSelector: string, containerIndex: number ): string { - // Check if the child selector contains the list selector pattern if (childSelector.includes(listSelector.replace("//", ""))) { - // Replace the list selector part with indexed version const listPattern = listSelector.replace("//", ""); const indexedListSelector = `(${listSelector})[${containerIndex}]`; @@ -518,8 +427,6 @@ class ClientListExtractor { return indexedSelector; } else { - // If pattern doesn't match, create a more generic indexed selector - // This is a fallback approach console.warn(` ⚠️ Pattern doesn't match, using fallback approach`); return `(${listSelector})[${containerIndex}]${childSelector.replace( "//", @@ -531,7 +438,8 @@ class ClientListExtractor { // Helper method for single XPath evaluation private evaluateXPathSingle = ( document: Document, - xpath: string + xpath: string, + isShadow: boolean = false ): Element | null => { try { const result = document.evaluate( @@ -540,20 +448,228 @@ class ClientListExtractor { null, XPathResult.FIRST_ORDERED_NODE_TYPE, null - ); + ).singleNodeValue as Element | null; - const element = result.singleNodeValue as Element | null; + if (!isShadow) { + if (result === null) { + return null; + } + return result; + } + + let cleanPath = xpath; + let isIndexed = false; - if (!element) { - console.warn(`❌ XPath found no element for: ${xpath}`); + const indexedMatch = xpath.match(/^\((.*?)\)\[(\d+)\](.*)$/); + if (indexedMatch) { + cleanPath = indexedMatch[1] + indexedMatch[3]; + isIndexed = true; } - return element; - } catch (error) { - console.error("❌ XPath evaluation failed:", xpath, error); + const pathParts = cleanPath + .replace(/^\/\//, "") + .split("/") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + let currentContexts: (Document | Element | ShadowRoot)[] = [document]; + + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + const nextContexts: (Element | ShadowRoot)[] = []; + + for (const ctx of currentContexts) { + const positionalMatch = part.match(/^([^[]+)\[(\d+)\]$/); + let partWithoutPosition = part; + let requestedPosition: number | null = null; + + if (positionalMatch) { + partWithoutPosition = positionalMatch[1]; + requestedPosition = parseInt(positionalMatch[2]); + } + + const matched = this.queryInsideContext(ctx, partWithoutPosition); + + let elementsToAdd = matched; + if (requestedPosition !== null) { + const index = requestedPosition - 1; // XPath is 1-based, arrays are 0-based + if (index >= 0 && index < matched.length) { + elementsToAdd = [matched[index]]; + } else { + console.warn( + ` ⚠️ Position ${requestedPosition} out of range (${matched.length} elements found)` + ); + elementsToAdd = []; + } + } + + elementsToAdd.forEach((el) => { + nextContexts.push(el); + if (el.shadowRoot) { + nextContexts.push(el.shadowRoot); + } + }); + } + + if (nextContexts.length === 0) { + return null; + } + + currentContexts = nextContexts; + } + + if (currentContexts.length > 0) { + if (isIndexed && indexedMatch) { + const requestedIndex = parseInt(indexedMatch[2]) - 1; // XPath is 1-based, array is 0-based + if (requestedIndex >= 0 && requestedIndex < currentContexts.length) { + return currentContexts[requestedIndex] as Element; + } else { + console.warn( + `⚠️ Requested index ${requestedIndex + 1} out of range (${ + currentContexts.length + } elements found)` + ); + return null; + } + } + + return currentContexts[0] as Element; + } + + return null; + } catch (err) { + console.error("💥 Critical XPath failure:", xpath, err); return null; } }; + + private queryInsideContext = ( + context: Document | Element | ShadowRoot, + part: string + ): Element[] => { + try { + const { tagName, conditions } = this.parseXPathPart(part); + + const candidateElements = Array.from(context.querySelectorAll(tagName)); + if (candidateElements.length === 0) { + return []; + } + + const matchingElements = candidateElements.filter((el) => { + const matches = this.elementMatchesConditions(el, conditions); + return matches; + }); + + return matchingElements; + } catch (err) { + console.error("Error in queryInsideContext:", err); + return []; + } + }; + + private parseXPathPart = ( + part: string + ): { tagName: string; conditions: string[] } => { + const tagMatch = part.match(/^([a-zA-Z0-9-]+)/); + const tagName = tagMatch ? tagMatch[1] : "*"; + + const conditionMatches = part.match(/\[([^\]]+)\]/g); + const conditions = conditionMatches + ? conditionMatches.map((c) => c.slice(1, -1)) + : []; + + return { tagName, conditions }; + }; + + // Check if element matches all given conditions + private elementMatchesConditions = ( + element: Element, + conditions: string[] + ): boolean => { + for (const condition of conditions) { + if (!this.elementMatchesCondition(element, condition)) { + return false; + } + } + return true; + }; + + private elementMatchesCondition = ( + element: Element, + condition: string + ): boolean => { + condition = condition.trim(); + + if (/^\d+$/.test(condition)) { + return true; + } + + // Handle @attribute="value" + const attrMatch = condition.match(/^@([^=]+)=["']([^"']+)["']$/); + if (attrMatch) { + const [, attr, value] = attrMatch; + const elementValue = element.getAttribute(attr); + const matches = elementValue === value; + return matches; + } + + // Handle contains(@class, 'value') + const classContainsMatch = condition.match( + /^contains\(@class,\s*["']([^"']+)["']\)$/ + ); + if (classContainsMatch) { + const className = classContainsMatch[1]; + const matches = element.classList.contains(className); + return matches; + } + + // Handle contains(@attribute, 'value') + const attrContainsMatch = condition.match( + /^contains\(@([^,]+),\s*["']([^"']+)["']\)$/ + ); + if (attrContainsMatch) { + const [, attr, value] = attrContainsMatch; + const elementValue = element.getAttribute(attr) || ""; + const matches = elementValue.includes(value); + return matches; + } + + // Handle text()="value" + const textMatch = condition.match(/^text\(\)=["']([^"']+)["']$/); + if (textMatch) { + const expectedText = textMatch[1]; + const elementText = element.textContent?.trim() || ""; + const matches = elementText === expectedText; + return matches; + } + + // Handle contains(text(), 'value') + const textContainsMatch = condition.match( + /^contains\(text\(\),\s*["']([^"']+)["']\)$/ + ); + if (textContainsMatch) { + const expectedText = textContainsMatch[1]; + const elementText = element.textContent?.trim() || ""; + const matches = elementText.includes(expectedText); + return matches; + } + + // Handle count(*)=0 (element has no children) + if (condition === "count(*)=0") { + const matches = element.children.length === 0; + return matches; + } + + // Handle other count conditions + const countMatch = condition.match(/^count\(\*\)=(\d+)$/); + if (countMatch) { + const expectedCount = parseInt(countMatch[1]); + const matches = element.children.length === expectedCount; + return matches; + } + + return true; + }; } export const clientListExtractor = new ClientListExtractor(); diff --git a/src/helpers/clientSelectorGenerator.ts b/src/helpers/clientSelectorGenerator.ts index a054dba06..fcd56984c 100644 --- a/src/helpers/clientSelectorGenerator.ts +++ b/src/helpers/clientSelectorGenerator.ts @@ -24,10 +24,6 @@ interface ElementInfo { shadowRootContent?: string; } -interface SelectorResult { - generalSelector: string; -} - interface Selectors { id?: string | null; generalSelector?: string | null; @@ -124,6 +120,18 @@ class ClientSelectorGenerator { excludeSelectors: ["script", "style", "meta", "link", "title", "head"], }; + private selectorElementCache = new Map(); + private elementSelectorCache = new WeakMap(); + private lastCachedDocument: Document | null = null; + private spatialIndex = new Map(); + + private performanceConfig = { + enableSpatialIndexing: true, + maxSelectorBatchSize: 50, + useElementCache: true, + debounceMs: 16, // ~60fps + }; + // Add setter methods for state management public setListSelector(selector: string): void { this.listSelector = selector; @@ -171,6 +179,21 @@ class ClientSelectorGenerator { if (element.nodeType !== Node.ELEMENT_NODE) return null; const tagName = element.tagName.toLowerCase(); + + const isCustomElement = tagName.includes("-"); + + const standardExcludeSelectors = [ + "script", + "style", + "meta", + "link", + "title", + "head", + ]; + if (!isCustomElement && standardExcludeSelectors.includes(tagName)) { + return null; + } + if (this.groupingConfig.excludeSelectors.includes(tagName)) return null; const children = Array.from(element.children); @@ -182,20 +205,26 @@ class ClientSelectorGenerator { const normalizedClasses = this.normalizeClasses(element.classList); - // Get attributes (excluding unique identifiers) const relevantAttributes = Array.from(element.attributes) - .filter( - (attr) => - !["id", "style", "data-reactid", "data-react-checksum"].includes( - attr.name.toLowerCase() - ) - ) - .filter( - (attr) => - !attr.name.startsWith("data-") || - attr.name === "data-type" || - attr.name === "data-role" - ) + .filter((attr) => { + if (isCustomElement) { + return ![ + "id", + "style", + "data-reactid", + "data-react-checksum", + ].includes(attr.name.toLowerCase()); + } else { + return ( + !["id", "style", "data-reactid", "data-react-checksum"].includes( + attr.name.toLowerCase() + ) && + (!attr.name.startsWith("data-") || + attr.name === "data-type" || + attr.name === "data-role") + ); + } + }) .map((attr) => `${attr.name}=${attr.value}`) .sort(); @@ -301,6 +330,41 @@ class ClientSelectorGenerator { return maxScore > 0 ? score / maxScore : 0; } + private getAllVisibleElementsWithShadow(doc: Document): HTMLElement[] { + const allElements: HTMLElement[] = []; + const visited = new Set(); + + const traverseContainer = (container: Document | ShadowRoot) => { + try { + const elements = Array.from(container.querySelectorAll("*")).filter( + (el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; // Only visible elements + } + ) as HTMLElement[]; + + elements.forEach((element) => { + if (!visited.has(element)) { + visited.add(element); + allElements.push(element); + + // Traverse shadow DOM if it exists + if (element.shadowRoot) { + traverseContainer(element.shadowRoot); + } + } + }); + } catch (error) { + console.warn(`⚠️ Error traversing container:`, error); + } + }; + + // Start from main document + traverseContainer(doc); + + return allElements; + } + public analyzeElementGroups(iframeDoc: Document): void { // Only re-analyze if document changed if ( @@ -315,13 +379,8 @@ class ClientSelectorGenerator { this.groupedElements.clear(); this.lastAnalyzedDocument = iframeDoc; - // Get all visible elements - const allElements = Array.from(iframeDoc.querySelectorAll("*")).filter( - (el) => { - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; // Only visible elements - } - ) as HTMLElement[]; + // Get all visible elements INCLUDING shadow DOM + const allElements = this.getAllVisibleElementsWithShadow(iframeDoc); // Create fingerprints for all elements const elementFingerprints = new Map(); @@ -351,6 +410,7 @@ class ClientSelectorGenerator { fingerprint, otherFingerprint ); + if (similarity >= this.groupingConfig.similarityThreshold) { currentGroup.push(otherElement); processedElements.add(otherElement); @@ -410,7 +470,9 @@ class ClientSelectorGenerator { private getMeaningfulChildren(element: HTMLElement): HTMLElement[] { const meaningfulChildren: HTMLElement[] = []; - const traverse = (el: HTMLElement) => { + const traverse = (el: HTMLElement, depth: number = 0) => { + if (depth > 5) return; + Array.from(el.children).forEach((child) => { const htmlChild = child as HTMLElement; @@ -419,9 +481,20 @@ class ClientSelectorGenerator { meaningfulChildren.push(htmlChild); } else { // If not meaningful itself, check its children - traverse(htmlChild); + traverse(htmlChild, depth + 1); } }); + + if (el.shadowRoot) { + Array.from(el.shadowRoot.children).forEach((shadowChild) => { + const htmlShadowChild = shadowChild as HTMLElement; + if (this.isMeaningfulElement(htmlShadowChild)) { + meaningfulChildren.push(htmlShadowChild); + } else { + traverse(htmlShadowChild, depth + 1); + } + }); + } }; traverse(element); @@ -436,8 +509,25 @@ class ClientSelectorGenerator { const text = (element.textContent || "").trim(); const hasHref = element.hasAttribute("href"); const hasSrc = element.hasAttribute("src"); + const isCustomElement = tagName.includes("-"); + + if (isCustomElement) { + const hasChildren = element.children.length > 0; + const hasSignificantAttributes = Array.from(element.attributes).some( + (attr) => !["class", "style", "id"].includes(attr.name.toLowerCase()) + ); + + return ( + text.length > 0 || + hasHref || + hasSrc || + hasChildren || + hasSignificantAttributes || + element.hasAttribute("role") || + element.hasAttribute("aria-label") + ); + } - // Meaningful if it has text content, is a link, image, or input return ( text.length > 0 || hasHref || @@ -460,6 +550,322 @@ class ClientSelectorGenerator { return this.elementGroups.get(element) || null; } + public getAllMatchingElements( + hoveredSelector: string, + childSelectors: string[], + iframeDoc: Document + ): HTMLElement[] { + try { + const matchingElements: HTMLElement[] = []; + + if (childSelectors.includes(hoveredSelector)) { + const directElements = this.evaluateXPath(hoveredSelector, iframeDoc); + matchingElements.push(...directElements); + + if (directElements.length === 0) { + const shadowElements = this.findElementsInShadowDOM( + hoveredSelector, + iframeDoc + ); + matchingElements.push(...shadowElements); + } + } else { + const hoveredPattern = this.extractSelectorPattern(hoveredSelector); + + childSelectors.forEach((childSelector) => { + const childPattern = this.extractSelectorPattern(childSelector); + + if (this.arePatternsRelated(hoveredPattern, childPattern)) { + const directElements = this.evaluateXPath(childSelector, iframeDoc); + matchingElements.push(...directElements); + + if (directElements.length === 0) { + const shadowElements = this.findElementsInShadowDOM( + childSelector, + iframeDoc + ); + matchingElements.push(...shadowElements); + } + } + }); + } + + return [...new Set(matchingElements)]; + } catch (error) { + console.error("Error getting matching elements:", error); + return []; + } + } + + /** + * Extract pattern components from selector for comparison + */ + private extractSelectorPattern(selector: string): { + tag: string; + classes: string[]; + hasPosition: boolean; + structure: string; + } { + // Handle XPath selectors + if (selector.startsWith("//") || selector.startsWith("/")) { + const tagMatch = selector.match(/\/\/(\w+)/); + const classMatches = + selector.match(/contains\(@class,'([^']+)'\)/g) || []; + const classes = classMatches + .map((match) => { + const classMatch = match.match(/contains\(@class,'([^']+)'\)/); + return classMatch ? classMatch[1] : ""; + }) + .filter((cls) => cls); + + return { + tag: tagMatch ? tagMatch[1] : "", + classes, + hasPosition: /\[\d+\]/.test(selector), + structure: selector.replace(/\[\d+\]/g, "").replace(/\/\/\w+/, "//TAG"), + }; + } + + // Handle CSS selectors + const parts = selector.split(" ").pop() || ""; + const tagMatch = parts.match(/^(\w+)/); + const classMatches = parts.match(/\.([^.#[\s]+)/g) || []; + const classes = classMatches.map((cls) => cls.substring(1)); + + return { + tag: tagMatch ? tagMatch[1] : "", + classes, + hasPosition: /:nth-child\(\d+\)/.test(selector), + structure: selector + .replace(/:nth-child\(\d+\)/g, "") + .replace(/\w+/g, "TAG"), + }; + } + + /** + * Check if two selector patterns are related/similar + */ + private arePatternsRelated(pattern1: any, pattern2: any): boolean { + if (pattern1.tag !== pattern2.tag || !pattern1.tag) { + return false; + } + + const commonClasses = pattern1.classes.filter((cls: any) => + pattern2.classes.includes(cls) + ); + + return ( + commonClasses.length > 0 || pattern1.structure === pattern2.structure + ); + } + + /** + * Find elements that match a child selector XPath by traversing shadow DOMs + * This handles cases where the child elements are nested within shadow roots of parent elements + */ + private findElementsInShadowDOM( + xpath: string, + iframeDoc: Document + ): HTMLElement[] { + try { + const matchingElements: HTMLElement[] = []; + + const xpathParts = this.parseChildXPath(xpath); + if (!xpathParts) { + console.warn("Could not parse child XPath:", xpath); + return []; + } + + const parentElements = this.evaluateXPath( + xpathParts.parentXPath, + iframeDoc + ); + + parentElements.forEach((parentElement, index) => { + const childElements = this.findChildrenInElementShadowDOM( + parentElement, + xpathParts.childPath, + xpathParts.childFilters + ); + + matchingElements.push(...childElements); + }); + + return matchingElements; + } catch (error) { + console.error("Error in findElementsInShadowDOM:", error); + return []; + } + } + + /** + * Parse a child XPath to extract parent selector and child path + */ + private parseChildXPath(xpath: string): { + parentXPath: string; + childPath: string[]; + childFilters: string[]; + } | null { + try { + const xpathPattern = + /^(\/\/[^\/]+(?:\[[^\]]*\])*)((?:\/[^\/]+(?:\[[^\]]*\])*)*)$/; + const match = xpath.match(xpathPattern); + + if (!match) { + console.warn("Could not match XPath pattern:", xpath); + return null; + } + + const parentXPath = match[1]; + const childPathString = match[2]; + + const childPath = childPathString + .split("/") + .filter((part) => part.length > 0); + + const childFilters = childPath + .map((part) => { + const filterMatch = part.match(/\[([^\]]+)\]/); + return filterMatch ? filterMatch[1] : ""; + }) + .filter((filter) => filter.length > 0); + + return { + parentXPath, + childPath, + childFilters, + }; + } catch (error) { + console.error("Error parsing child XPath:", error); + return null; + } + } + + /** + * Find child elements within a parent element's shadow DOM tree + */ + private findChildrenInElementShadowDOM( + parentElement: HTMLElement, + childPath: string[], + childFilters: string[] + ): HTMLElement[] { + const matchingChildren: HTMLElement[] = []; + const visited = new Set(); + + const traverseElement = (element: HTMLElement, depth: number = 0) => { + if (depth > 10 || visited.has(element)) return; + visited.add(element); + + if (element.shadowRoot) { + this.searchWithinShadowRoot( + element.shadowRoot, + childPath, + childFilters, + matchingChildren + ); + } + + Array.from(element.children).forEach((child) => { + traverseElement(child as HTMLElement, depth + 1); + }); + }; + + traverseElement(parentElement); + + return matchingChildren; + } + + /** + * Search within a shadow root for elements matching the child path + */ + private searchWithinShadowRoot( + shadowRoot: ShadowRoot, + childPath: string[], + childFilters: string[], + matchingChildren: HTMLElement[] + ): void { + try { + if (childPath.length === 0) { + const allElements = shadowRoot.querySelectorAll("*"); + matchingChildren.push(...(Array.from(allElements) as HTMLElement[])); + return; + } + + let currentElements: HTMLElement[] = Array.from( + shadowRoot.querySelectorAll("*") + ) as HTMLElement[]; + + for (let i = 0; i < childPath.length; i++) { + const pathPart = childPath[i]; + + const tagMatch = pathPart.match(/^([^[]+)/); + if (!tagMatch) continue; + + const tagName = tagMatch[1]; + const classMatches = pathPart.match(/contains\(@class,\s*'([^']+)'\)/g); + const requiredClasses = classMatches + ? classMatches + .map((classMatch) => { + const classNameMatch = classMatch.match( + /contains\(@class,\s*'([^']+)'\)/ + ); + return classNameMatch ? classNameMatch[1] : ""; + }) + .filter((cls) => cls.length > 0) + : []; + + const filteredElements = currentElements.filter((element) => { + if (element.tagName.toLowerCase() !== tagName.toLowerCase()) { + return false; + } + + for (const requiredClass of requiredClasses) { + if (!element.classList.contains(requiredClass)) { + return false; + } + } + + return true; + }); + + if (i === childPath.length - 1) { + matchingChildren.push(...filteredElements); + } else { + const nextElements: HTMLElement[] = []; + filteredElements.forEach((element) => { + Array.from(element.children).forEach((child) => { + nextElements.push(child as HTMLElement); + }); + + if (element.shadowRoot) { + Array.from(element.shadowRoot.querySelectorAll("*")).forEach( + (shadowChild) => { + nextElements.push(shadowChild as HTMLElement); + } + ); + } + }); + currentElements = nextElements; + } + } + + const elementsWithShadow = shadowRoot.querySelectorAll("*"); + elementsWithShadow.forEach((element) => { + const htmlElement = element as HTMLElement; + if (htmlElement.shadowRoot) { + this.searchWithinShadowRoot( + htmlElement.shadowRoot, + childPath, + childFilters, + matchingChildren + ); + } + }); + } catch (error) { + console.error("Error searching within shadow root:", error); + } + } + /** * Modified container finding that only returns grouped elements */ @@ -515,18 +921,7 @@ class ClientSelectorGenerator { } // For other modes or when list selector exists, return regular element - return this.getDeepestElementFromPoint(elementsAtPoint); - } - - private getElementDepth(element: HTMLElement): number { - let depth = 0; - let current = element; - while (current && current !== this.lastAnalyzedDocument?.body) { - depth++; - current = current.parentElement as HTMLElement; - if (depth > 50) break; - } - return depth; + return this.getDeepestElementFromPoint(elementsAtPoint, x, y); } public getElementInformation = ( @@ -1552,31 +1947,29 @@ class ClientSelectorGenerator { config.attr(attr.name, attr.value) ); - return attrs.map( - (attr): Node => { - let attrValue = attr.value; - - if (attr.name === "href" && attr.value.includes("://")) { - try { - const url = new URL(attr.value); - const siteOrigin = `${url.protocol}//${url.host}`; - attrValue = attr.value.replace(siteOrigin, ""); - } catch (e) { - // Keep original if URL parsing fails - } + return attrs.map((attr): Node => { + let attrValue = attr.value; + + if (attr.name === "href" && attr.value.includes("://")) { + try { + const url = new URL(attr.value); + const siteOrigin = `${url.protocol}//${url.host}`; + attrValue = attr.value.replace(siteOrigin, ""); + } catch (e) { + // Keep original if URL parsing fails } - - return { - name: - "[" + - cssesc(attr.name, { isIdentifier: true }) + - '="' + - cssesc(attrValue) + - '"]', - penalty: 0.5, - }; } - ); + + return { + name: + "[" + + cssesc(attr.name, { isIdentifier: true }) + + '="' + + cssesc(attrValue) + + '"]', + penalty: 0.5, + }; + }); } function classNames(input: Element): Node[] { @@ -2376,458 +2769,794 @@ class ClientSelectorGenerator { return null; }; - private getNonUniqueSelectors = ( + public getChildSelectors = ( iframeDoc: Document, - coordinates: Coordinates, - listSelector: string - ): SelectorResult => { - interface DOMContext { - type: "shadow"; - element: HTMLElement; - container: ShadowRoot; - host: HTMLElement; - } - + parentSelector: string + ): string[] => { try { - if (!listSelector) { - function generateXPathSelector( - element: HTMLElement, - relative: boolean = false - ): string { - let xpath = relative - ? element.tagName.toLowerCase() - : `//${element.tagName.toLowerCase()}`; - - // Handle table cells specially - if (element.tagName === "TD" || element.tagName === "TH") { - if (element.parentElement) { - const siblings = Array.from(element.parentElement.children); - const position = siblings.indexOf(element) + 1; - return relative - ? `${element.tagName.toLowerCase()}[${position}]` - : `//tr/${element.tagName.toLowerCase()}[${position}]`; - } - } - - // Add class-based predicates - if (element.className) { - const classes = element.className - .split(/\s+/) - .filter((cls: string) => Boolean(cls)) - .filter( - (cls: string) => !cls.startsWith("!") && !cls.includes(":") - ); - - if (classes.length > 0) { - const classPredicates = classes - .map((cls) => `contains(@class,'${cls}')`) - .join(" and "); - xpath += `[${classPredicates}]`; - } - } + let parentElements: HTMLElement[] = []; - // Add positional predicate if there are similar siblings - if (element.parentElement) { - const siblings = Array.from(element.parentElement.children); - const elementClasses = Array.from(element.classList || []); + if (parentSelector.includes(">>")) { + const selectorParts = parentSelector + .split(">>") + .map((part) => part.trim()); - const similarSiblings = siblings.filter((sibling) => { - if (sibling === element) return false; - const siblingClasses = Array.from(sibling.classList || []); - return siblingClasses.some((cls) => elementClasses.includes(cls)); - }); + parentElements = this.evaluateXPath(selectorParts[0], iframeDoc); - if (similarSiblings.length > 0) { - const position = siblings.indexOf(element) + 1; - // Remove existing predicates and add position-based one - const baseXpath = relative - ? element.tagName.toLowerCase() - : `//${element.tagName.toLowerCase()}`; - xpath = `${baseXpath}[${position}]`; + for (let i = 1; i < selectorParts.length; i++) { + const newParentElements: HTMLElement[] = []; + for (const element of parentElements) { + if (element.shadowRoot) { + const shadowChildren = this.evaluateXPath( + selectorParts[i], + element.shadowRoot as any + ); + newParentElements.push(...shadowChildren); } } - - return xpath; + parentElements = newParentElements; } + } else { + parentElements = this.evaluateXPath(parentSelector, iframeDoc); + } - function getContextPath(element: HTMLElement): DOMContext[] { - const path: DOMContext[] = []; - let current = element; - let depth = 0; - const MAX_DEPTH = 4; + if (parentElements.length === 0) { + console.warn("No parent elements found for selector:", parentSelector); + return []; + } - while (current && depth < MAX_DEPTH) { - const rootNode = current.getRootNode(); - if (rootNode instanceof ShadowRoot) { - path.unshift({ - type: "shadow", - element: current, - container: rootNode, - host: rootNode.host as HTMLElement, - }); - current = rootNode.host as HTMLElement; - depth++; - continue; - } - break; - } + const allChildSelectors = new Set(); - return path; - } + parentElements.forEach((parentElement) => { + const childSelectors = this.generateOptimizedChildXPaths( + parentElement, + parentSelector, + iframeDoc + ); + childSelectors.forEach((selector) => allChildSelectors.add(selector)); + }); - function getXPathSelectorPath(element: HTMLElement | null): string { - if (!element) return ""; + const childSelectors = Array.from(allChildSelectors).sort(); + return childSelectors; + } catch (error) { + console.error("Error in getChildSelectors:", error); + return []; + } + }; - const contextPath = getContextPath(element); - if (contextPath.length > 0) { - const selectorParts: string[] = []; + private getAllDescendantsIncludingShadow( + parentElement: HTMLElement + ): HTMLElement[] { + const allDescendants: HTMLElement[] = []; + const visited = new Set(); + const shadowRootsSeen = new Set(); - contextPath.forEach((context, index) => { - const containerSelector = generateXPathSelector(context.host); + const traverseShadowRoot = (shadowRoot: ShadowRoot, depth: number = 0) => { + if (depth > 10) return; - if (index === contextPath.length - 1) { - const elementSelector = generateXPathSelector(element); - selectorParts.push( - `${containerSelector} >> ${elementSelector}` - ); - } else { - selectorParts.push(containerSelector); - } - }); + try { + const shadowElements = Array.from( + shadowRoot.querySelectorAll("*") + ) as HTMLElement[]; - return selectorParts.join(" >> "); - } + shadowElements.forEach((shadowElement) => { + if (!visited.has(shadowElement)) { + visited.add(shadowElement); + allDescendants.push(shadowElement); - const elementSelector = generateXPathSelector(element); + if ( + shadowElement.shadowRoot && + !shadowRootsSeen.has(shadowElement.shadowRoot) + ) { + shadowRootsSeen.add(shadowElement.shadowRoot); + traverseShadowRoot(shadowElement.shadowRoot, depth + 1); + } + } + }); - // For simple cases, return the element selector + Array.from(shadowRoot.children).forEach((child) => { + const htmlChild = child as HTMLElement; if ( - elementSelector.includes("contains(@class") || - elementSelector.includes("[") + htmlChild.shadowRoot && + !shadowRootsSeen.has(htmlChild.shadowRoot) ) { - return elementSelector; + shadowRootsSeen.add(htmlChild.shadowRoot); + traverseShadowRoot(htmlChild.shadowRoot, depth + 1); } + }); + } catch (error) { + console.warn(`Error traversing shadow root:`, error); + } + }; - // Build path with limited depth - const path: string[] = []; - let currentElement = element; - const MAX_DEPTH = 2; - let depth = 0; + const regularDescendants = Array.from( + parentElement.querySelectorAll("*") + ) as HTMLElement[]; + regularDescendants.forEach((descendant) => { + if (!visited.has(descendant)) { + visited.add(descendant); + allDescendants.push(descendant); + } + }); - while ( - currentElement && - currentElement !== iframeDoc.body && - depth < MAX_DEPTH - ) { - const selector = generateXPathSelector(currentElement); - path.unshift(selector.replace("//", "")); + const elementsWithShadow = [parentElement, ...regularDescendants].filter( + (el) => el.shadowRoot + ); + elementsWithShadow.forEach((element) => { + if (!shadowRootsSeen.has(element.shadowRoot!)) { + shadowRootsSeen.add(element.shadowRoot!); + traverseShadowRoot(element.shadowRoot!, 0); + } + }); - if (!currentElement.parentElement) break; - currentElement = currentElement.parentElement; - depth++; - } + return allDescendants; + } - return "//" + path.join("/"); - } + private generateOptimizedChildXPaths( + parentElement: HTMLElement, + listSelector: string, + document: Document + ): string[] { + const selectors: string[] = []; + const processedElements = new Set(); - const originalEl = this.findGroupedContainerAtPoint( - coordinates.x, - coordinates.y, - iframeDoc - ); - if (!originalEl) return { generalSelector: "" }; + // Get all meaningful descendants (not just direct children) + const allDescendants = this.getAllDescendantsIncludingShadow(parentElement); - let element = originalEl; + allDescendants.forEach((descendant, i) => { + if (processedElements.has(descendant)) return; + processedElements.add(descendant); - if (element.tagName === "TD" || element.tagName === "TH") { - const tableParent = element.closest("table"); - if (tableParent) { - element = tableParent; - } - } + const absolutePath = this.buildOptimizedAbsoluteXPath( + descendant, + listSelector, + parentElement, + document + ); - const generalSelector = getXPathSelectorPath(element); - return { generalSelector }; - } else { - // Similar logic for when listSelector exists - const getDeepestElementFromPoint = ( - x: number, - y: number - ): HTMLElement | null => { - let elements = iframeDoc.elementsFromPoint(x, y) as HTMLElement[]; - if (!elements.length) return null; + if (absolutePath) { + selectors.push(absolutePath); + } + }); - const findDeepestElement = ( - elements: HTMLElement[] - ): HTMLElement | null => { - if (!elements.length) return null; - if (elements.length === 1) return elements[0]; + const shadowElements = this.getShadowDOMDescendants(parentElement); + shadowElements.forEach((shadowElement) => { + const shadowPath = this.buildOptimizedAbsoluteXPath( + shadowElement, + listSelector, + parentElement, + document + ); + if (shadowPath) { + selectors.push(shadowPath); + } + }); - let deepestElement = elements[0]; - let maxDepth = 0; + return [...new Set(selectors)]; + } - for (const element of elements) { - let depth = 0; - let current = element; + private generateOptimizedStructuralStep( + element: HTMLElement, + rootElement?: HTMLElement, + addPositionToAll: boolean = false + ): string { + const tagName = element.tagName.toLowerCase(); - while (current) { - depth++; - if (current.parentElement) { - current = current.parentElement; - } else { - break; - } - } + const parent = + element.parentElement || + ((element.getRootNode() as ShadowRoot).host as HTMLElement | null); - if (depth > maxDepth) { - maxDepth = depth; - deepestElement = element; - } - } + if (!parent) { + return tagName; + } - return deepestElement; - }; + const classes = Array.from(element.classList); + if (classes.length > 0 && !addPositionToAll) { + const classSelector = classes + .map((cls) => `contains(@class, '${cls}')`) + .join(" and "); + + const hasConflictingElement = rootElement + ? this.queryElementsInScope(rootElement, element.tagName.toLowerCase()) + .filter((el) => el !== element) + .some((el) => + classes.every((cls) => + (el as HTMLElement).classList.contains(cls) + ) + ) + : false; + + if (!hasConflictingElement) { + return `${tagName}[${classSelector}]`; + } else { + const position = this.getSiblingPosition(element, parent); + return `${tagName}[${classSelector}][${position}]`; + } + } - let deepestElement = findDeepestElement(elements); - if (!deepestElement) return null; + if (!addPositionToAll) { + const meaningfulAttrs = ["role", "type", "name", "src", "aria-label"]; + for (const attrName of meaningfulAttrs) { + if (element.hasAttribute(attrName)) { + const value = element.getAttribute(attrName)!.replace(/'/g, "\\'"); + return `${tagName}[@${attrName}='${value}']`; + } + } + } - const traverseShadowDOM = (element: HTMLElement): HTMLElement => { - let current = element; - let shadowRoot = current.shadowRoot; - let deepest = current; - let depth = 0; - const MAX_SHADOW_DEPTH = 4; + const testId = element.getAttribute("data-testid"); + if (testId && !addPositionToAll) { + return `${tagName}[@data-testid='${testId}']`; + } - while (shadowRoot && depth < MAX_SHADOW_DEPTH) { - const shadowElement = shadowRoot.elementFromPoint( - x, - y - ) as HTMLElement; - if (!shadowElement || shadowElement === current) break; + if (element.id && !element.id.match(/^\d/) && !addPositionToAll) { + return `${tagName}[@id='${element.id}']`; + } - deepest = shadowElement; - current = shadowElement; - shadowRoot = current.shadowRoot; - depth++; - } + if (!addPositionToAll) { + for (const attr of Array.from(element.attributes)) { + if ( + attr.name.startsWith("data-") && + attr.name !== "data-testid" && + attr.name !== "data-mx-id" && + attr.value + ) { + return `${tagName}[@${attr.name}='${attr.value}']`; + } + } + } - return deepest; - }; + const position = this.getSiblingPosition(element, parent); - deepestElement = traverseShadowDOM(deepestElement); - return deepestElement; - }; + if (addPositionToAll || classes.length === 0) { + return `${tagName}[${position}]`; + } - function generateRelativeXPathSelector(element: HTMLElement): string { - let xpath = element.tagName.toLowerCase(); + return tagName; + } - if (xpath === "td" && element.parentElement) { - const siblings = Array.from(element.parentElement.children); - const position = siblings.indexOf(element) + 1; - return `${xpath}[${position}]`; - } + // Helper method to get sibling position (works for both light and shadow DOM) + private getSiblingPosition( + element: HTMLElement, + parent: HTMLElement + ): number { + const siblings = Array.from(parent.children || []).filter( + (child) => child.tagName === element.tagName + ); + return siblings.indexOf(element) + 1; + } - const className = - typeof element.className === "string" ? element.className : ""; + // Helper method to query elements in scope (handles both light and shadow DOM) + private queryElementsInScope( + rootElement: HTMLElement, + tagName: string + ): HTMLElement[] { + // Check if we're dealing with shadow DOM + if (rootElement.shadowRoot || this.isInShadowDOM(rootElement)) { + return this.deepQuerySelectorAll(rootElement, tagName); + } else { + // Standard light DOM query + return Array.from(rootElement.querySelectorAll(tagName)); + } + } - if (element.parentElement) { - const allSiblings = Array.from(element.parentElement.children); - const sameTagSiblings = allSiblings.filter( - (sibling) => sibling.tagName === element.tagName - ); + // Helper method to check if element is in shadow DOM + private isInShadowDOM(element: HTMLElement): boolean { + return element.getRootNode() instanceof ShadowRoot; + } - if (sameTagSiblings.length > 1) { - // Multiple siblings with same tag - MUST use position - const position = sameTagSiblings.indexOf(element) + 1; + // Deep query selector for shadow DOM (from second version) + private deepQuerySelectorAll( + root: HTMLElement | ShadowRoot, + selector: string + ): HTMLElement[] { + const elements: HTMLElement[] = []; - if (className) { - const classes = className - .split(/\s+/) - .filter((cls: string) => Boolean(cls)) - .filter( - (cls: string) => !cls.startsWith("!") && !cls.includes(":") - ); + const process = (node: Element | ShadowRoot) => { + if (node instanceof Element && node.matches(selector)) { + elements.push(node as HTMLElement); + } - if (classes.length > 0) { - const classPredicates = classes - .map((cls) => `contains(@class,'${cls}')`) - .join(" and "); - xpath += `[${classPredicates}][${position}]`; - } else { - xpath += `[${position}]`; - } - } else { - xpath += `[${position}]`; - } - } else { - // Only one sibling with this tag - classes are sufficient - if (className) { - const classes = className - .split(/\s+/) - .filter((cls: string) => Boolean(cls)) - .filter( - (cls: string) => !cls.startsWith("!") && !cls.includes(":") - ); + for (const child of node.children) { + process(child); + } - if (classes.length > 0) { - const classPredicates = classes - .map((cls) => `contains(@class,'${cls}')`) - .join(" and "); - xpath += `[${classPredicates}]`; - } - } - } - } else if (className) { - // No parent but has classes - const classes = className - .split(/\s+/) - .filter((cls: string) => Boolean(cls)) - .filter( - (cls: string) => !cls.startsWith("!") && !cls.includes(":") - ); + if (node instanceof HTMLElement && node.shadowRoot) { + process(node.shadowRoot); + } + }; - if (classes.length > 0) { - const classPredicates = classes - .map((cls) => `contains(@class,'${cls}')`) - .join(" and "); - xpath += `[${classPredicates}]`; - } - } + process(root); + return elements; + } - return `./${xpath}`; // Make it relative - } - function getContextPath(element: HTMLElement): DOMContext[] { - const path: DOMContext[] = []; - let current = element; - let depth = 0; - const MAX_DEPTH = 4; + private buildOptimizedAbsoluteXPath( + targetElement: HTMLElement, + listSelector: string, + listElement: HTMLElement, + document: Document + ): string | null { + try { + let xpath = listSelector; + const pathFromList = this.getOptimizedStructuralPath( + targetElement, + listElement + ); - while (current && depth < MAX_DEPTH) { - const rootNode = current.getRootNode(); - if (rootNode instanceof ShadowRoot) { - path.unshift({ - type: "shadow", - element: current, - container: rootNode, - host: rootNode.host as HTMLElement, - }); - current = rootNode.host as HTMLElement; - depth++; - continue; - } - break; - } + if (!pathFromList) return null; - return path; + const fullXPath = xpath + pathFromList; + + if (targetElement.tagName.toLowerCase() === "a") { + // Ensure the XPath ends with an anchor selector + if (!fullXPath.includes("/a[") && !fullXPath.endsWith("/a")) { + console.warn( + "Generated XPath for anchor element does not target anchor:", + fullXPath + ); } + } - function getRelativeXPathSelectorPath( - element: HTMLElement | null - ): string { - if (!element) return ""; + return fullXPath; + } catch (error) { + console.error("Error building optimized absolute XPath:", error); + return null; + } + } - const contextPath = getContextPath(element); - if (contextPath.length > 0) { - const selectorParts: string[] = []; + // Unified path optimization (works for both light and shadow DOM) + private getOptimizedStructuralPath( + targetElement: HTMLElement, + rootElement: HTMLElement + ): string | null { + if ( + !this.elementContains(rootElement, targetElement) || + targetElement === rootElement + ) { + return null; + } - contextPath.forEach((context, index) => { - const containerSelector = generateRelativeXPathSelector( - context.host - ); + // First, check if target element has conflicting classes + const classes = Array.from(targetElement.classList); + const hasConflictingElement = + classes.length > 0 && rootElement + ? this.queryElementsInScope( + rootElement, + targetElement.tagName.toLowerCase() + ) + .filter((el) => el !== targetElement) + .some((el) => + classes.every((cls) => + (el as HTMLElement).classList.contains(cls) + ) + ) + : false; - if (index === contextPath.length - 1) { - const elementSelector = generateRelativeXPathSelector(element); - selectorParts.push( - `${containerSelector} >> ${elementSelector}` - ); - } else { - selectorParts.push(containerSelector); - } - }); + const pathParts: string[] = []; + let current: HTMLElement | null = targetElement; - return selectorParts.join(" >> "); - } + // Build path from target up to root + while (current && current !== rootElement) { + const pathPart = this.generateOptimizedStructuralStep( + current, + rootElement, + hasConflictingElement + ); + if (pathPart) { + pathParts.unshift(pathPart); + } - const elementSelector = generateRelativeXPathSelector(element); - return elementSelector; - } + // Move to parent (either regular parent or shadow host) + current = + current.parentElement || + ((current.getRootNode() as ShadowRoot).host as HTMLElement | null); - const originalEl = getDeepestElementFromPoint( - coordinates.x, - coordinates.y - ); - if (!originalEl) return { generalSelector: "" }; + if (!current) break; + } + + return pathParts.length > 0 ? "/" + pathParts.join("/") : null; + } + + // Helper method to check containment (works for both light and shadow DOM) + private elementContains( + container: HTMLElement, + element: HTMLElement + ): boolean { + // Standard containment check + if (container.contains(element)) { + return true; + } - let element = originalEl; - const generalSelector = getRelativeXPathSelectorPath(element); - return { generalSelector }; + // Check shadow DOM containment + let current: HTMLElement | null = element; + while (current) { + if (current === container) { + return true; } - } catch (error) { - console.error("Error in getNonUniqueSelectors:", error); - return { generalSelector: "" }; + + // Move to parent or shadow host + current = + current.parentElement || + ((current.getRootNode() as ShadowRoot).host as HTMLElement | null); } - }; - public getChildSelectors = ( - iframeDoc: Document, - parentSelector: string - ): string[] => { + return false; + } + + // Simplified validation + private validateXPath(xpath: string, document: Document): boolean { try { - // Use XPath evaluation to find parent elements - let parentElements: HTMLElement[] = []; + const result = document.evaluate( + xpath, + document, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + return result.snapshotLength > 0; + } catch (error) { + return false; + } + } - if (parentSelector.includes(">>")) { - // Handle shadow DOM - const selectorParts = parentSelector - .split(">>") - .map((part) => part.trim()); + // findMatchingAbsoluteXPath with better matching algorithm + private precomputeSelectorMappings( + childSelectors: string[], + document: Document + ): void { + if ( + this.lastCachedDocument === document && + this.selectorElementCache.size > 0 + ) { + return; + } - // Evaluate the first part with XPath - parentElements = this.evaluateXPath(selectorParts[0], iframeDoc); + console.time("Precomputing selector mappings"); + this.selectorElementCache.clear(); + this.elementSelectorCache = new WeakMap(); + this.spatialIndex.clear(); - // Handle shadow DOM traversal - for (let i = 1; i < selectorParts.length; i++) { - const newParentElements: HTMLElement[] = []; - for (const element of parentElements) { - if (element.shadowRoot) { - const shadowChildren = this.evaluateXPath( - selectorParts[i], - element.shadowRoot as any - ); - newParentElements.push(...shadowChildren); + // Batch process selectors to avoid blocking + const batchSize = this.performanceConfig.maxSelectorBatchSize; + + for (let i = 0; i < childSelectors.length; i += batchSize) { + const batch = childSelectors.slice(i, i + batchSize); + + batch.forEach((selector) => { + try { + const elements = this.evaluateXPath(selector, document); + this.selectorElementCache.set(selector, elements); + + // Build reverse mapping: element -> selectors that match it + elements.forEach((element) => { + const existingSelectors = + this.elementSelectorCache.get(element) || []; + existingSelectors.push(selector); + this.elementSelectorCache.set(element, existingSelectors); + + // Add to spatial index if enabled + if (this.performanceConfig.enableSpatialIndexing) { + const gridKey = this.getElementGridKey(element); + const gridSelectors = this.spatialIndex.get(gridKey) || []; + gridSelectors.push(selector); + this.spatialIndex.set(gridKey, gridSelectors); } + }); + } catch (error) { + // Skip invalid selectors silently + } + }); + } + + this.lastCachedDocument = document; + console.timeEnd("Precomputing selector mappings"); + } + + // Simple spatial indexing for proximity-based filtering + private getElementGridKey(element: HTMLElement): string { + const rect = element.getBoundingClientRect(); + const gridSize = 100; // 100px grid cells + const x = Math.floor(rect.left / gridSize); + const y = Math.floor(rect.top / gridSize); + return `${x},${y}`; + } + + // Get nearby selectors using spatial indexing + private getNearbySelectorCandidates(element: HTMLElement): string[] { + if (!this.performanceConfig.enableSpatialIndexing) { + return Array.from(this.selectorElementCache.keys()); + } + + const gridKey = this.getElementGridKey(element); + const rect = element.getBoundingClientRect(); + const gridSize = 100; + + // Check current cell and adjacent cells + const candidates = new Set(); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const x = Math.floor(rect.left / gridSize) + dx; + const y = Math.floor(rect.top / gridSize) + dy; + const key = `${x},${y}`; + const selectors = this.spatialIndex.get(key) || []; + selectors.forEach((s) => candidates.add(s)); + } + } + + return Array.from(candidates); + } + + // Ultra-fast direct lookup using cached mappings + private findDirectMatches( + targetElement: HTMLElement, + childSelectors: string[], + document: Document + ): string[] { + // Use cached reverse mapping if available + if ( + this.performanceConfig.useElementCache && + this.elementSelectorCache.has(targetElement) + ) { + const cachedSelectors = + this.elementSelectorCache.get(targetElement) || []; + // Filter to only selectors in the current child selectors list + const matches = cachedSelectors.filter((selector) => + childSelectors.includes(selector) + ); + + // positional selectors over non-positional ones + return this.sortByPositionalPriority(matches); + } + + // Fallback to spatial filtering + selective evaluation + const candidateSelectors = this.getNearbySelectorCandidates(targetElement); + const relevantCandidates = candidateSelectors.filter((selector) => + childSelectors.includes(selector) + ); + + const matches: string[] = []; + + // Process in smaller batches to avoid blocking + for (const selector of relevantCandidates.slice(0, 20)) { + // Limit to top 20 candidates + try { + const cachedElements = this.selectorElementCache.get(selector); + if (cachedElements && cachedElements.includes(targetElement)) { + matches.push(selector); + } + } catch (error) { + continue; + } + } + + // positional selectors and sort by specificity + return this.sortByPositionalPriority(matches); + } + + /** + * Sort selectors to prioritize positional ones over non-positional + */ + private sortByPositionalPriority(selectors: string[]): string[] { + return selectors.sort((a, b) => { + const aIsPositional = /\[\d+\]/.test(a); + const bIsPositional = /\[\d+\]/.test(b); + + // Positional selectors get higher priority + if (aIsPositional && !bIsPositional) return -1; + if (!aIsPositional && bIsPositional) return 1; + + // If both are positional or both are non-positional, sort by specificity + return ( + this.calculateXPathSpecificity(b) - this.calculateXPathSpecificity(a) + ); + }); + } + + // Fast element proximity check instead of full similarity calculation + private findProximityMatch( + targetElement: HTMLElement, + childSelectors: string[], + document: Document + ): string | null { + const targetRect = targetElement.getBoundingClientRect(); + const targetCenter = { + x: targetRect.left + targetRect.width / 2, + y: targetRect.top + targetRect.height / 2, + }; + + let bestMatch = null; + let bestDistance = Infinity; + let bestScore = 0; + + // Use spatial filtering to reduce candidates + const candidateSelectors = this.getNearbySelectorCandidates(targetElement) + .filter((selector) => childSelectors.includes(selector)) + .slice(0, 30); // Limit candidates + + for (const selector of candidateSelectors) { + try { + const cachedElements = this.selectorElementCache.get(selector) || []; + + for (const element of cachedElements.slice(0, 5)) { + // Check max 5 elements per selector + const rect = element.getBoundingClientRect(); + const center = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + + const distance = Math.sqrt( + Math.pow(center.x - targetCenter.x, 2) + + Math.pow(center.y - targetCenter.y, 2) + ); + + // Quick element similarity check (just tag + basic attributes) + const similarity = this.calculateQuickSimilarity( + targetElement, + element + ); + + if (similarity > 0.7 && distance < bestDistance) { + bestDistance = distance; + bestMatch = selector; + bestScore = similarity; } - parentElements = newParentElements; } - } else { - // Use XPath evaluation directly for regular DOM - parentElements = this.evaluateXPath(parentSelector, iframeDoc); + } catch (error) { + continue; + } + } + + return bestMatch; + } + + // Lightweight similarity calculation for real-time use + private calculateQuickSimilarity( + element1: HTMLElement, + element2: HTMLElement + ): number { + if (element1 === element2) return 1.0; + + let score = 0; + let maxScore = 0; + + // Tag name (most important) + maxScore += 4; + if (element1.tagName === element2.tagName) { + score += 4; + } else { + return 0; + } + + // Quick class check (just count common classes) + maxScore += 3; + const classes1 = element1.classList; + const classes2 = element2.classList; + let commonClasses = 0; + for (const cls of classes1) { + if (classes2.contains(cls)) commonClasses++; + } + if (classes1.length > 0 && classes2.length > 0) { + score += (commonClasses / Math.max(classes1.length, classes2.length)) * 3; + } + + // Quick attribute check (just a few key ones) + maxScore += 2; + const keyAttrs = ["data-testid", "role", "type"]; + let matchingAttrs = 0; + for (const attr of keyAttrs) { + if (element1.getAttribute(attr) === element2.getAttribute(attr)) { + matchingAttrs++; + } + } + score += (matchingAttrs / keyAttrs.length) * 2; + + return maxScore > 0 ? score / maxScore : 0; + } + + // Main matching function with early exits and caching + private findMatchingAbsoluteXPath( + targetElement: HTMLElement, + childSelectors: string[], + listSelector: string, + iframeDocument: Document + ): string | null { + try { + // Ensure mappings are precomputed + this.precomputeSelectorMappings(childSelectors, iframeDocument); + + // Strategy 1: Ultra-fast direct lookup (usually finds match immediately) + const directMatches = this.findDirectMatches( + targetElement, + childSelectors, + iframeDocument + ); + + if (directMatches.length > 0) { + return directMatches[0]; // Return best direct match + } + + const proximityMatch = this.findProximityMatch( + targetElement, + childSelectors, + iframeDocument + ); + if (proximityMatch) { + return proximityMatch; } - if (parentElements.length === 0) { - console.warn("No parent elements found for selector:", parentSelector); - return []; + // Strategy 3: Build and validate new XPath only if no cached matches found + const builtXPath = this.buildTargetXPath( + targetElement, + listSelector, + iframeDocument + ); + if (builtXPath) { + return builtXPath; } - const allChildSelectors = new Set(); + return null; + } catch (error) { + console.error("Error in optimized matching:", error); + return null; + } + } - parentElements.forEach((parentElement) => { - const childSelectors = this.generateAbsoluteChildXPaths( - parentElement, - parentSelector - ); - childSelectors.forEach((selector) => allChildSelectors.add(selector)); - }); + // Public method to precompute mappings when child selectors are first generated + public precomputeChildSelectorMappings( + childSelectors: string[], + document: Document + ): void { + this.precomputeSelectorMappings(childSelectors, document); + } - // Convert Set back to array to get unique selectors - const childSelectors = Array.from(allChildSelectors); + // Calculate XPath specificity for better matching + private calculateXPathSpecificity(xpath: string): number { + let score = 0; - return childSelectors; + // Count specific attributes + score += (xpath.match(/@id=/g) || []).length * 10; + score += (xpath.match(/@data-testid=/g) || []).length * 8; + score += (xpath.match(/contains\(@class/g) || []).length * 3; + score += (xpath.match(/@\w+=/g) || []).length * 2; + score += (xpath.match(/\[\d+\]/g) || []).length * 1; // Position predicates + + // Penalty for overly generic selectors + if (xpath.match(/^\/\/\w+$/) && !xpath.includes("[")) { + score -= 5; // Just a tag name + } + + return score; + } + + // Build XPath for target element + private buildTargetXPath( + targetElement: HTMLElement, + listSelector: string, + document: Document + ): string | null { + try { + const parentElements = this.evaluateXPath(listSelector, document); + const containingParent = parentElements[0]; + + if (!containingParent) { + return null; + } + + const structuralPath = this.getOptimizedStructuralPath( + targetElement, + containingParent + ); + if (!structuralPath) { + return null; + } + + return listSelector + structuralPath; } catch (error) { - console.error("Error in optimized getChildSelectors:", error); - return []; + console.error("Error building target XPath:", error); + return null; } - }; + } private evaluateXPath( xpath: string, @@ -2867,15 +3596,17 @@ class ClientSelectorGenerator { } private isXPathSelector(selector: string): boolean { - return selector.startsWith('//') || - selector.startsWith('/') || - selector.startsWith('./') || - selector.includes('contains(@') || - selector.includes('[count(') || - selector.includes('@class=') || - selector.includes('@id=') || - selector.includes(' and ') || - selector.includes(' or '); + return ( + selector.startsWith("//") || + selector.startsWith("/") || + selector.startsWith("./") || + selector.includes("contains(@") || + selector.includes("[count(") || + selector.includes("@class=") || + selector.includes("@id=") || + selector.includes(" and ") || + selector.includes(" or ") + ); } private fallbackXPathEvaluation( @@ -2922,49 +3653,6 @@ class ClientSelectorGenerator { } } - private generateAbsoluteChildXPaths( - parentElement: HTMLElement, - listSelector: string - ): string[] { - const selectors: string[] = []; - const processedElements = new Set(); - - // More efficient traversal - use querySelectorAll to get all descendants at once - const allDescendants = Array.from( - parentElement.querySelectorAll("*") - ) as HTMLElement[]; - - allDescendants.forEach((descendant, index) => { - if (processedElements.has(descendant)) return; - processedElements.add(descendant); - - const absolutePath = this.buildAbsoluteXPath( - descendant, - listSelector, - parentElement - ); - - if (absolutePath) { - selectors.push(absolutePath); - } - }); - - // Handle shadow DOM descendants - const shadowElements = this.getShadowDOMDescendants(parentElement); - shadowElements.forEach((shadowElement) => { - const shadowPath = this.buildAbsoluteXPath( - shadowElement, - listSelector, - parentElement - ); - if (shadowPath) { - selectors.push(shadowPath); - } - }); - - return selectors; - } - private getShadowDOMDescendants(element: HTMLElement): HTMLElement[] { const shadowDescendants: HTMLElement[] = []; @@ -2984,73 +3672,6 @@ class ClientSelectorGenerator { return shadowDescendants; } - private buildAbsoluteXPath( - targetElement: HTMLElement, - listSelector: string, - listElement: HTMLElement - ): string | null { - try { - // Start with the list selector as base - let xpath = listSelector; - - // Build path from list element to target element - const pathFromList = this.getStructuralPath(targetElement, listElement); - - if (!pathFromList) return null; - - // Append the structural path to the list selector - return xpath + pathFromList; - } catch (error) { - console.error("Error building absolute XPath:", error); - return null; - } - } - - private getStructuralPath( - targetElement: HTMLElement, - rootElement: HTMLElement - ): string | null { - if (!rootElement.contains(targetElement) || targetElement === rootElement) { - return null; - } - - const pathParts: string[] = []; - let current = targetElement; - - // Build path from target up to root - while (current && current !== rootElement && current.parentElement) { - const pathPart = this.generateStructuralStep(current); - if (pathPart) { - pathParts.unshift(pathPart); - } - current = current.parentElement; - } - - return pathParts.length > 0 ? "/" + pathParts.join("/") : null; - } - - private generateStructuralStep(element: HTMLElement): string { - const tagName = element.tagName.toLowerCase(); - - if (!element.parentElement) { - return tagName; - } - - // Get all sibling elements with the same tag name - const siblings = Array.from(element.parentElement.children).filter( - (sibling) => sibling.tagName === element.tagName - ); - - if (siblings.length === 1) { - // Only one element with this tag - no position needed - return tagName; - } else { - // Multiple elements with same tag - use position - const position = siblings.indexOf(element) + 1; - return `${tagName}[${position}]`; - } - } - private getBestSelectorForAction = (action: Action) => { switch (action.type) { case ActionType.Click: @@ -3150,6 +3771,23 @@ class ClientSelectorGenerator { return null; }; + /** + * Determines if an element is within a Shadow DOM + */ + private isElementInShadowDOM(element: HTMLElement): boolean { + try { + const rootNode = element.getRootNode(); + + return ( + rootNode.constructor.name === "ShadowRoot" || + (rootNode && "host" in rootNode && "mode" in rootNode) + ); + } catch (error) { + console.warn("Error checking shadow DOM:", error); + return false; + } + } + /** * Enhanced highlighting that detects and highlights entire groups */ @@ -3163,12 +3801,17 @@ class ClientSelectorGenerator { selector: string; elementInfo: ElementInfo | null; childSelectors?: string[]; + isShadow?: boolean; groupInfo?: { isGroupElement: boolean; groupSize: number; groupElements: HTMLElement[]; groupFingerprint: ElementFingerprint; }; + similarElements?: { + elements: HTMLElement[]; + rects: DOMRect[]; + }; } | null { try { if (this.getList === true) { @@ -3185,6 +3828,9 @@ class ClientSelectorGenerator { const elementGroup = this.getElementGroup(elementAtPoint); const isGroupElement = elementGroup !== null; + let isShadow = false; + let targetElement = elementAtPoint; + const rect = this.getRect( iframeDocument, coordinates, @@ -3206,21 +3852,35 @@ class ClientSelectorGenerator { let displaySelector: string | null; let childSelectors: string[] = []; + let similarElements: + | { elements: HTMLElement[]; rects: DOMRect[] } + | undefined; if (this.getList === true && this.listSelector !== "") { childSelectors = cachedChildSelectors.length > 0 ? cachedChildSelectors : this.getChildSelectors(iframeDocument, this.listSelector); + + if (cachedChildSelectors.length > 0) { + this.precomputeChildSelectorMappings( + cachedChildSelectors, + iframeDocument + ); + } } if (isGroupElement && this.getList === true && this.listSelector === "") { displaySelector = this.generateGroupContainerSelector(elementGroup!); + targetElement = elementGroup!.representative; + isShadow = this.isElementInShadowDOM(targetElement); + return { rect, selector: displaySelector, elementInfo, + isShadow, groupInfo: { isGroupElement: true, groupSize: elementGroup!.elements.length, @@ -3234,15 +3894,56 @@ class ClientSelectorGenerator { childSelectors.length > 0 && this.paginationMode === false ) { - // For child elements within a list, find the matching absolute XPath displaySelector = this.findMatchingAbsoluteXPath( elementAtPoint, childSelectors, this.listSelector, iframeDocument ); + + if (displaySelector) { + const matchingElements = this.getAllMatchingElements( + displaySelector, + childSelectors, + iframeDocument + ); + + if (matchingElements.length > 1) { + const rects = matchingElements.map((el) => { + const elementRect = el.getBoundingClientRect(); + if (isDOMMode) { + return elementRect; + } else { + let adjustedRect = elementRect; + let currentWindow = el.ownerDocument.defaultView; + + while (currentWindow !== window.top) { + const frameElement = + currentWindow?.frameElement as HTMLIFrameElement; + if (!frameElement) break; + + const frameRect = frameElement.getBoundingClientRect(); + adjustedRect = new DOMRect( + adjustedRect.x + frameRect.x, + adjustedRect.y + frameRect.y, + adjustedRect.width, + adjustedRect.height + ); + + currentWindow = frameElement.ownerDocument.defaultView; + } + + return adjustedRect; + } + }); + + similarElements = { + elements: matchingElements, + rects, + }; + } + } } else { - // Fall back to regular selector generation for non-list elements displaySelector = this.generateSelector( iframeDocument, coordinates, @@ -3254,11 +3955,15 @@ class ClientSelectorGenerator { return null; } + targetElement = elementAtPoint; + isShadow = this.isElementInShadowDOM(targetElement); + return { rect, selector: displaySelector, elementInfo, childSelectors: childSelectors.length > 0 ? childSelectors : undefined, + isShadow, groupInfo: isGroupElement ? { isGroupElement: true, @@ -3267,6 +3972,7 @@ class ClientSelectorGenerator { groupFingerprint: elementGroup!.fingerprint, } : undefined, + similarElements, }; } catch (error) { console.error("Error generating highlighter data:", error); @@ -3274,132 +3980,6 @@ class ClientSelectorGenerator { } } - private findMatchingAbsoluteXPath( - targetElement: HTMLElement, - childSelectors: string[], - listSelector: string, - iframeDocument: Document - ): string | null { - try { - // Use XPath evaluation directly instead of CSS conversion - const parentElements = this.evaluateXPath(listSelector, iframeDocument); - - const containingParent = parentElements.find((parent) => - parent.contains(targetElement) - ); - - if (!containingParent) { - console.warn("Could not find containing parent for target element"); - return null; - } - - // Get the structural path from parent to target - const structuralPath = this.getStructuralPath( - targetElement, - containingParent - ); - - if (!structuralPath) { - console.warn("Could not determine structural path"); - return null; - } - - // Construct the absolute XPath - const absoluteXPath = listSelector + structuralPath; - - // Check if this XPath exists in our child selectors - const matchingSelector = childSelectors.find( - (selector) => - selector === absoluteXPath || - this.isEquivalentXPath(selector, absoluteXPath) - ); - - if (matchingSelector) { - return matchingSelector; - } - - // If no exact match, find the closest matching selector - const closestMatch = this.findClosestXPathMatch( - absoluteXPath, - childSelectors - ); - - if (closestMatch) { - return closestMatch; - } - - return absoluteXPath; - } catch (error) { - console.error("Error finding matching absolute XPath:", error); - return null; - } - } - - private isEquivalentXPath(xpath1: string, xpath2: string): boolean { - // Normalize both XPaths for comparison - const normalize = (xpath: string) => { - return xpath - .replace(/\s+/g, " ") // Normalize whitespace - .replace( - /\[\s*contains\s*\(\s*@class\s*,\s*'([^']+)'\s*\)\s*\]/g, - "[contains(@class,'$1')]" - ) // Normalize class predicates - .trim(); - }; - - return normalize(xpath1) === normalize(xpath2); - } - - private findClosestXPathMatch( - targetXPath: string, - candidateSelectors: string[] - ): string | null { - // Extract the path components for comparison - const getPathComponents = (xpath: string) => { - // Remove the list selector prefix and get just the relative path - const pathMatch = xpath.match(/\/([^\/].*)$/); - return pathMatch ? pathMatch[1].split("/") : []; - }; - - const targetComponents = getPathComponents(targetXPath); - - let bestMatch = null; - let bestScore = 0; - - for (const selector of candidateSelectors) { - const selectorComponents = getPathComponents(selector); - - // Calculate similarity score - const commonLength = Math.min( - targetComponents.length, - selectorComponents.length - ); - let score = 0; - - for (let i = 0; i < commonLength; i++) { - if (targetComponents[i] === selectorComponents[i]) { - score++; - } else { - // Check if they're the same tag with different positions - const targetTag = targetComponents[i].replace(/\[\d+\]/, ""); - const selectorTag = selectorComponents[i].replace(/\[\d+\]/, ""); - if (targetTag === selectorTag) { - score += 0.5; // Partial match for same tag - } - break; // Stop at first mismatch - } - } - - if (score > bestScore) { - bestScore = score; - bestMatch = selector; - } - } - - // Only return a match if we have reasonable confidence - return bestScore >= targetComponents.length * 0.7 ? bestMatch : null; - } - /** * Generate XPath that matches ALL group elements and ONLY group elements */ @@ -3461,7 +4041,7 @@ class ClientSelectorGenerator { for (let i = 0; i < matched.snapshotLength; i++) { matchedSet.add(matched.snapshotItem(i) as HTMLElement); } - + return xpath; } @@ -3499,27 +4079,67 @@ class ClientSelectorGenerator { return attrMap; } - /** - * Get deepest element from a list of elements - */ private getDeepestElementFromPoint( - elements: HTMLElement[] + elements: HTMLElement[], + x: number, + y: number ): HTMLElement | null { if (!elements.length) return null; - if (elements.length === 1) return elements[0]; - let deepestElement = elements[0]; - let maxDepth = 0; + const visited = new Set(); + return this.findDeepestElementRecursive(elements, x, y, visited); + } + + private findDeepestElementRecursive( + elements: HTMLElement[], + x: number, + y: number, + visited: Set + ): HTMLElement | null { + if (!elements.length) return null; for (const element of elements) { - const depth = this.getElementDepth(element); - if (depth > maxDepth) { - maxDepth = depth; - deepestElement = element; + if (visited.has(element)) continue; + visited.add(element); + + if (element.shadowRoot) { + let shadowElements = element.shadowRoot.elementsFromPoint( + x, + y + ) as HTMLElement[]; + + if (shadowElements.length > 0) { + let deepestShadowElement = shadowElements[0]; + + if (deepestShadowElement.shadowRoot) { + const evenDeeperElement = this.findDeepestElementRecursive( + [deepestShadowElement], + x, + y, + visited + ); + if (evenDeeperElement) { + return evenDeeperElement; + } + } + + return deepestShadowElement; + } } } - return deepestElement; + return elements[0]; + } + + private getElementDepth(element: HTMLElement): number { + let depth = 0; + let current = element; + while (current && current !== this.lastAnalyzedDocument?.body) { + depth++; + current = current.parentElement as HTMLElement; + if (depth > 50) break; + } + return depth; } /** @@ -3529,6 +4149,10 @@ class ClientSelectorGenerator { this.elementGroups.clear(); this.groupedElements.clear(); this.lastAnalyzedDocument = null; + this.selectorElementCache.clear(); + this.elementSelectorCache = new WeakMap(); + this.spatialIndex.clear(); + this.lastCachedDocument = null; } // Update generateSelector to use instance variables @@ -3540,11 +4164,14 @@ class ClientSelectorGenerator { const elementInfo = this.getElementInformation( iframeDocument, coordinates, - '', + "", false ); - const selectorBasedOnCustomAction = this.getSelectors(iframeDocument, coordinates); + const selectorBasedOnCustomAction = this.getSelectors( + iframeDocument, + coordinates + ); if (this.paginationMode && selectorBasedOnCustomAction) { // Chain selectors in specific priority order