buildDomTree.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. window.buildDomTree = (args = { doHighlightElements: true, focusHighlightIndex: -1, viewportExpansion: 0 }) => {
  2. const { doHighlightElements, focusHighlightIndex, viewportExpansion } = args;
  3. let highlightIndex = 0; // Reset highlight index
  4. // Quick check to confirm the script receives focusHighlightIndex
  5. console.log('focusHighlightIndex:', focusHighlightIndex);
  6. function highlightElement(element, index, parentIframe = null) {
  7. // Create or get highlight container
  8. let container = document.getElementById('playwright-highlight-container');
  9. if (!container) {
  10. container = document.createElement('div');
  11. container.id = 'playwright-highlight-container';
  12. container.style.position = 'absolute';
  13. container.style.pointerEvents = 'none';
  14. container.style.top = '0';
  15. container.style.left = '0';
  16. container.style.width = '100%';
  17. container.style.height = '100%';
  18. container.style.zIndex = '2147483647'; // Maximum z-index value
  19. document.body.appendChild(container);
  20. }
  21. // Generate a color based on the index
  22. const colors = [
  23. '#FF0000',
  24. '#00FF00',
  25. '#0000FF',
  26. '#FFA500',
  27. '#800080',
  28. '#008080',
  29. '#FF69B4',
  30. '#4B0082',
  31. '#FF4500',
  32. '#2E8B57',
  33. '#DC143C',
  34. '#4682B4',
  35. ];
  36. const colorIndex = index % colors.length;
  37. const baseColor = colors[colorIndex];
  38. const backgroundColor = `${baseColor}1A`; // 10% opacity version of the color
  39. // Create highlight overlay
  40. const overlay = document.createElement('div');
  41. overlay.style.position = 'absolute';
  42. overlay.style.border = `2px solid ${baseColor}`;
  43. overlay.style.backgroundColor = backgroundColor;
  44. overlay.style.pointerEvents = 'none';
  45. overlay.style.boxSizing = 'border-box';
  46. // Position overlay based on element, including scroll position
  47. const rect = element.getBoundingClientRect();
  48. let top = rect.top + window.scrollY;
  49. let left = rect.left + window.scrollX;
  50. // Adjust position if element is inside an iframe
  51. if (parentIframe) {
  52. const iframeRect = parentIframe.getBoundingClientRect();
  53. top += iframeRect.top;
  54. left += iframeRect.left;
  55. }
  56. overlay.style.top = `${top}px`;
  57. overlay.style.left = `${left}px`;
  58. overlay.style.width = `${rect.width}px`;
  59. overlay.style.height = `${rect.height}px`;
  60. // Create label
  61. const label = document.createElement('div');
  62. label.className = 'playwright-highlight-label';
  63. label.style.position = 'absolute';
  64. label.style.background = baseColor;
  65. label.style.color = 'white';
  66. label.style.padding = '1px 4px';
  67. label.style.borderRadius = '4px';
  68. label.style.fontSize = `${Math.min(12, Math.max(8, rect.height / 2))}px`; // Responsive font size
  69. label.textContent = index;
  70. // Calculate label position
  71. const labelWidth = 20; // Approximate width
  72. const labelHeight = 16; // Approximate height
  73. // Default position (top-right corner inside the box)
  74. let labelTop = top + 2;
  75. let labelLeft = left + rect.width - labelWidth - 2;
  76. // Adjust if box is too small
  77. if (rect.width < labelWidth + 4 || rect.height < labelHeight + 4) {
  78. // Position outside the box if it's too small
  79. labelTop = top - labelHeight - 2;
  80. labelLeft = left + rect.width - labelWidth;
  81. }
  82. label.style.top = `${labelTop}px`;
  83. label.style.left = `${labelLeft}px`;
  84. // Add to container
  85. container.appendChild(overlay);
  86. container.appendChild(label);
  87. // Store reference for cleanup
  88. element.setAttribute('browser-user-highlight-id', `playwright-highlight-${index}`);
  89. return index + 1;
  90. }
  91. // Helper function to generate XPath as a tree
  92. function getXPathTree(element, stopAtBoundary = true) {
  93. const segments = [];
  94. let currentElement = element;
  95. while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE) {
  96. // Stop if we hit a shadow root or iframe
  97. if (
  98. stopAtBoundary &&
  99. (currentElement.parentNode instanceof ShadowRoot || currentElement.parentNode instanceof HTMLIFrameElement)
  100. ) {
  101. break;
  102. }
  103. let index = 0;
  104. let sibling = currentElement.previousSibling;
  105. while (sibling) {
  106. if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === currentElement.nodeName) {
  107. index++;
  108. }
  109. sibling = sibling.previousSibling;
  110. }
  111. const tagName = currentElement.nodeName.toLowerCase();
  112. const xpathIndex = index > 0 ? `[${index + 1}]` : '';
  113. segments.unshift(`${tagName}${xpathIndex}`);
  114. currentElement = currentElement.parentNode;
  115. }
  116. return segments.join('/');
  117. }
  118. // Helper function to check if element is accepted
  119. function isElementAccepted(element) {
  120. const leafElementDenyList = new Set(['svg', 'script', 'style', 'link', 'meta']);
  121. return !leafElementDenyList.has(element.tagName.toLowerCase());
  122. }
  123. // Helper function to check if element is interactive
  124. function isInteractiveElement(element) {
  125. // Immediately return false for body tag
  126. if (element.tagName.toLowerCase() === 'body') {
  127. return false;
  128. }
  129. // Base interactive elements and roles
  130. const interactiveElements = new Set([
  131. 'a',
  132. 'button',
  133. 'details',
  134. 'embed',
  135. 'input',
  136. 'label',
  137. 'menu',
  138. 'menuitem',
  139. 'object',
  140. 'select',
  141. 'textarea',
  142. 'summary',
  143. ]);
  144. const interactiveRoles = new Set([
  145. 'button',
  146. 'menu',
  147. 'menuitem',
  148. 'link',
  149. 'checkbox',
  150. 'radio',
  151. 'slider',
  152. 'tab',
  153. 'tabpanel',
  154. 'textbox',
  155. 'combobox',
  156. 'grid',
  157. 'listbox',
  158. 'option',
  159. 'progressbar',
  160. 'scrollbar',
  161. 'searchbox',
  162. 'switch',
  163. 'tree',
  164. 'treeitem',
  165. 'spinbutton',
  166. 'tooltip',
  167. 'a-button-inner',
  168. 'a-dropdown-button',
  169. 'click',
  170. 'menuitemcheckbox',
  171. 'menuitemradio',
  172. 'a-button-text',
  173. 'button-text',
  174. 'button-icon',
  175. 'button-icon-only',
  176. 'button-text-icon-only',
  177. 'dropdown',
  178. 'combobox',
  179. ]);
  180. const tagName = element.tagName.toLowerCase();
  181. const role = element.getAttribute('role');
  182. const ariaRole = element.getAttribute('aria-role');
  183. const tabIndex = element.getAttribute('tabindex');
  184. // Add check for specific class
  185. const hasAddressInputClass = element.classList.contains('address-input__container__input');
  186. // Basic role/attribute checks
  187. const hasInteractiveRole =
  188. hasAddressInputClass ||
  189. interactiveElements.has(tagName) ||
  190. interactiveRoles.has(role) ||
  191. interactiveRoles.has(ariaRole) ||
  192. (tabIndex !== null && tabIndex !== '-1' && element.parentElement?.tagName.toLowerCase() !== 'body') ||
  193. element.getAttribute('data-action') === 'a-dropdown-select' ||
  194. element.getAttribute('data-action') === 'a-dropdown-button';
  195. if (hasInteractiveRole) return true;
  196. // Get computed style
  197. const style = window.getComputedStyle(element);
  198. // Check if element has click-like styling
  199. // const hasClickStyling = style.cursor === 'pointer' ||
  200. // element.style.cursor === 'pointer' ||
  201. // style.pointerEvents !== 'none';
  202. // Check for event listeners
  203. const hasClickHandler =
  204. element.onclick !== null ||
  205. element.getAttribute('onclick') !== null ||
  206. element.hasAttribute('ng-click') ||
  207. element.hasAttribute('@click') ||
  208. element.hasAttribute('v-on:click');
  209. // Helper function to safely get event listeners
  210. function getEventListeners(el) {
  211. try {
  212. // Try to get listeners using Chrome DevTools API
  213. return window.getEventListeners?.(el) || {};
  214. } catch (e) {
  215. // Fallback: check for common event properties
  216. const listeners = {};
  217. // List of common event types to check
  218. const eventTypes = [
  219. 'click',
  220. 'mousedown',
  221. 'mouseup',
  222. 'touchstart',
  223. 'touchend',
  224. 'keydown',
  225. 'keyup',
  226. 'focus',
  227. 'blur',
  228. ];
  229. for (const type of eventTypes) {
  230. const handler = el[`on${type}`];
  231. if (handler) {
  232. listeners[type] = [
  233. {
  234. listener: handler,
  235. useCapture: false,
  236. },
  237. ];
  238. }
  239. }
  240. return listeners;
  241. }
  242. }
  243. // Check for click-related events on the element itself
  244. const listeners = getEventListeners(element);
  245. const hasClickListeners =
  246. listeners &&
  247. (listeners.click?.length > 0 ||
  248. listeners.mousedown?.length > 0 ||
  249. listeners.mouseup?.length > 0 ||
  250. listeners.touchstart?.length > 0 ||
  251. listeners.touchend?.length > 0);
  252. // Check for ARIA properties that suggest interactivity
  253. const hasAriaProps =
  254. element.hasAttribute('aria-expanded') ||
  255. element.hasAttribute('aria-pressed') ||
  256. element.hasAttribute('aria-selected') ||
  257. element.hasAttribute('aria-checked');
  258. // Check for form-related functionality
  259. const isFormRelated =
  260. element.form !== undefined || element.hasAttribute('contenteditable') || style.userSelect !== 'none';
  261. // Check if element is draggable
  262. const isDraggable = element.draggable || element.getAttribute('draggable') === 'true';
  263. // Additional check to prevent body from being marked as interactive
  264. if (element.tagName.toLowerCase() === 'body' || element.parentElement?.tagName.toLowerCase() === 'body') {
  265. return false;
  266. }
  267. return (
  268. hasAriaProps ||
  269. // hasClickStyling ||
  270. hasClickHandler ||
  271. hasClickListeners ||
  272. // isFormRelated ||
  273. isDraggable
  274. );
  275. }
  276. // Helper function to check if element is visible
  277. function isElementVisible(element) {
  278. const style = window.getComputedStyle(element);
  279. return (
  280. element.offsetWidth > 0 && element.offsetHeight > 0 && style.visibility !== 'hidden' && style.display !== 'none'
  281. );
  282. }
  283. // Helper function to check if element is the top element at its position
  284. function isTopElement(element) {
  285. // Find the correct document context and root element
  286. let doc = element.ownerDocument;
  287. // If we're in an iframe, elements are considered top by default
  288. if (doc !== window.document) {
  289. return true;
  290. }
  291. // For shadow DOM, we need to check within its own root context
  292. const shadowRoot = element.getRootNode();
  293. if (shadowRoot instanceof ShadowRoot) {
  294. const rect = element.getBoundingClientRect();
  295. const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
  296. try {
  297. // Use shadow root's elementFromPoint to check within shadow DOM context
  298. const topEl = shadowRoot.elementFromPoint(point.x, point.y);
  299. if (!topEl) return false;
  300. // Check if the element or any of its parents match our target element
  301. let current = topEl;
  302. while (current && current !== shadowRoot) {
  303. if (current === element) return true;
  304. current = current.parentElement;
  305. }
  306. return false;
  307. } catch (e) {
  308. return true; // If we can't determine, consider it visible
  309. }
  310. }
  311. // Regular DOM elements
  312. const rect = element.getBoundingClientRect();
  313. // If viewportExpansion is -1, check if element is the top one at its position
  314. if (viewportExpansion === -1) {
  315. return true; // Consider all elements as top elements when expansion is -1
  316. }
  317. // Calculate expanded viewport boundaries including scroll position
  318. const scrollX = window.scrollX;
  319. const scrollY = window.scrollY;
  320. const viewportTop = -viewportExpansion + scrollY;
  321. const viewportLeft = -viewportExpansion + scrollX;
  322. const viewportBottom = window.innerHeight + viewportExpansion + scrollY;
  323. const viewportRight = window.innerWidth + viewportExpansion + scrollX;
  324. // Get absolute element position
  325. const absTop = rect.top + scrollY;
  326. const absLeft = rect.left + scrollX;
  327. const absBottom = rect.bottom + scrollY;
  328. const absRight = rect.right + scrollX;
  329. // Skip if element is completely outside expanded viewport
  330. if (absBottom < viewportTop || absTop > viewportBottom || absRight < viewportLeft || absLeft > viewportRight) {
  331. return false;
  332. }
  333. // For elements within expanded viewport, check if they're the top element
  334. try {
  335. const centerX = rect.left + rect.width / 2;
  336. const centerY = rect.top + rect.height / 2;
  337. // Only clamp the point if it's outside the actual document
  338. const point = {
  339. x: centerX,
  340. y: centerY,
  341. };
  342. if (point.x < 0 || point.x >= window.innerWidth || point.y < 0 || point.y >= window.innerHeight) {
  343. return true; // Consider elements with center outside viewport as visible
  344. }
  345. const topEl = document.elementFromPoint(point.x, point.y);
  346. if (!topEl) return false;
  347. let current = topEl;
  348. while (current && current !== document.documentElement) {
  349. if (current === element) return true;
  350. current = current.parentElement;
  351. }
  352. return false;
  353. } catch (e) {
  354. return true;
  355. }
  356. }
  357. // Helper function to check if text node is visible
  358. function isTextNodeVisible(textNode) {
  359. const range = document.createRange();
  360. range.selectNodeContents(textNode);
  361. const rect = range.getBoundingClientRect();
  362. return (
  363. rect.width !== 0 &&
  364. rect.height !== 0 &&
  365. rect.top >= 0 &&
  366. rect.top <= window.innerHeight &&
  367. textNode.parentElement?.checkVisibility({
  368. checkOpacity: true,
  369. checkVisibilityCSS: true,
  370. })
  371. );
  372. }
  373. // Function to traverse the DOM and create nested JSON
  374. function buildDomTree(node, parentIframe = null) {
  375. if (!node) return null;
  376. // Special case for text nodes
  377. if (node.nodeType === Node.TEXT_NODE) {
  378. const textContent = node.textContent.trim();
  379. if (textContent && isTextNodeVisible(node)) {
  380. return {
  381. type: 'TEXT_NODE',
  382. text: textContent,
  383. isVisible: true,
  384. };
  385. }
  386. return null;
  387. }
  388. // Check if element is accepted
  389. if (node.nodeType === Node.ELEMENT_NODE && !isElementAccepted(node)) {
  390. return null;
  391. }
  392. const nodeData = {
  393. tagName: node.tagName ? node.tagName.toLowerCase() : null,
  394. attributes: {},
  395. xpath: node.nodeType === Node.ELEMENT_NODE ? getXPathTree(node, true) : null,
  396. children: [],
  397. };
  398. // Add coordinates for element nodes
  399. if (node.nodeType === Node.ELEMENT_NODE) {
  400. const rect = node.getBoundingClientRect();
  401. const scrollX = window.scrollX;
  402. const scrollY = window.scrollY;
  403. // Viewport-relative coordinates (can be negative when scrolled)
  404. nodeData.viewportCoordinates = {
  405. topLeft: {
  406. x: Math.round(rect.left),
  407. y: Math.round(rect.top),
  408. },
  409. topRight: {
  410. x: Math.round(rect.right),
  411. y: Math.round(rect.top),
  412. },
  413. bottomLeft: {
  414. x: Math.round(rect.left),
  415. y: Math.round(rect.bottom),
  416. },
  417. bottomRight: {
  418. x: Math.round(rect.right),
  419. y: Math.round(rect.bottom),
  420. },
  421. center: {
  422. x: Math.round(rect.left + rect.width / 2),
  423. y: Math.round(rect.top + rect.height / 2),
  424. },
  425. width: Math.round(rect.width),
  426. height: Math.round(rect.height),
  427. };
  428. // Page-relative coordinates (always positive, relative to page origin)
  429. nodeData.pageCoordinates = {
  430. topLeft: {
  431. x: Math.round(rect.left + scrollX),
  432. y: Math.round(rect.top + scrollY),
  433. },
  434. topRight: {
  435. x: Math.round(rect.right + scrollX),
  436. y: Math.round(rect.top + scrollY),
  437. },
  438. bottomLeft: {
  439. x: Math.round(rect.left + scrollX),
  440. y: Math.round(rect.bottom + scrollY),
  441. },
  442. bottomRight: {
  443. x: Math.round(rect.right + scrollX),
  444. y: Math.round(rect.bottom + scrollY),
  445. },
  446. center: {
  447. x: Math.round(rect.left + rect.width / 2 + scrollX),
  448. y: Math.round(rect.top + rect.height / 2 + scrollY),
  449. },
  450. width: Math.round(rect.width),
  451. height: Math.round(rect.height),
  452. };
  453. // Add viewport and scroll information
  454. nodeData.viewport = {
  455. scrollX: Math.round(scrollX),
  456. scrollY: Math.round(scrollY),
  457. width: window.innerWidth,
  458. height: window.innerHeight,
  459. };
  460. }
  461. // Copy all attributes if the node is an element
  462. if (node.nodeType === Node.ELEMENT_NODE && node.attributes) {
  463. // Use getAttributeNames() instead of directly iterating attributes
  464. const attributeNames = node.getAttributeNames?.() || [];
  465. for (const name of attributeNames) {
  466. nodeData.attributes[name] = node.getAttribute(name);
  467. }
  468. }
  469. if (node.nodeType === Node.ELEMENT_NODE) {
  470. const isInteractive = isInteractiveElement(node);
  471. const isVisible = isElementVisible(node);
  472. const isTop = isTopElement(node);
  473. nodeData.isInteractive = isInteractive;
  474. nodeData.isVisible = isVisible;
  475. nodeData.isTopElement = isTop;
  476. // Highlight if element meets all criteria and highlighting is enabled
  477. if (isInteractive && isVisible && isTop) {
  478. nodeData.highlightIndex = highlightIndex++;
  479. if (doHighlightElements) {
  480. if (focusHighlightIndex >= 0) {
  481. if (focusHighlightIndex === nodeData.highlightIndex) {
  482. highlightElement(node, nodeData.highlightIndex, parentIframe);
  483. }
  484. } else {
  485. highlightElement(node, nodeData.highlightIndex, parentIframe);
  486. }
  487. }
  488. }
  489. }
  490. // Only add iframeContext if we're inside an iframe
  491. // if (parentIframe) {
  492. // nodeData.iframeContext = `iframe[src="${parentIframe.src || ''}"]`;
  493. // }
  494. // Only add shadowRoot field if it exists
  495. if (node.shadowRoot) {
  496. nodeData.shadowRoot = true;
  497. }
  498. // Handle shadow DOM
  499. if (node.shadowRoot) {
  500. const shadowChildren = Array.from(node.shadowRoot.childNodes).map(child => buildDomTree(child, parentIframe));
  501. nodeData.children.push(...shadowChildren);
  502. }
  503. // Handle iframes
  504. if (node.tagName === 'IFRAME') {
  505. try {
  506. const iframeDoc = node.contentDocument || node.contentWindow.document;
  507. if (iframeDoc) {
  508. const iframeChildren = Array.from(iframeDoc.body.childNodes).map(child => buildDomTree(child, node));
  509. nodeData.children.push(...iframeChildren);
  510. }
  511. } catch (e) {
  512. console.warn('Unable to access iframe:', node);
  513. }
  514. } else {
  515. const children = Array.from(node.childNodes).map(child => buildDomTree(child, parentIframe));
  516. nodeData.children.push(...children);
  517. }
  518. return nodeData;
  519. }
  520. return buildDomTree(document.body);
  521. };