Web自动化测试神器:告别手动定位,智能生成高效稳定 XPath/CSS

自动化测试CSSJavaScript

前言

在Web自动化测试领域,定位元素的准确性与稳定性是测试成功的关键。但是,手动编写或检查页面上成百上千个交互元素的 XPath 和 CSS 选择器,无疑是一项耗时且枯燥的工作。你是否也曾为寻找一个稳定且唯一的元素定位器而头疼不已?又或者,面对复杂的动态页面,定位器总是失效?

这个 Chrome DevTools 控制台脚本,它能帮助我们告别繁琐的手动定位,实现自动化扫描页面上的可交互元素,并智能生成高效、稳定且经过唯一性验证的 XPath 和 CSS 定位器。不仅如此,它还会将结果以直观的表格形式展示在控制台,并支持一键导出为 CSV 文件,极大地提升了测试开发的效率和准确性!

picture.image

一、为什么要开发这个脚本?它能解决哪些痛点?

  1. 效率提升:无需逐个检查元素,脚本会自动识别并处理页面上的所有可见交互元素。
  2. 定位器质量
    • 优先生成简洁且唯一的定位器(如ID、Name、data-*属性、Class)。
    • 对生成的每个 XPath 和 CSS 选择器进行唯一性验证,确保其精准指向目标元素。
    • 如果无法生成唯一或高质量的定位器,会给出明确的“验证失败或不唯一”提示。
  3. 直观展示:在页面上高亮显示已识别的交互元素,并用数字徽标进行标记,与控制台输出的表格数据一一对应。
  4. 数据导出:支持将所有元素信息(包括 XPath、CSS、标签、文本等)导出为 CSV 文件,方便团队协作、文档归档或导入到测试框架中。

二、如何使用这个脚本?

使用这个脚本非常简单,无需安装任何插件或工具,只需几步即可:

  1. 打开目标网页:在 Chrome 浏览器中,导航到你想要扫描的网页,这里以https://adminlte.io/themes/v3/为例。

  2. 打开开发者工具:按下 F12 键,或右键点击页面选择“检查 (Inspect)”,然后切换到“Console (控制台)”标签页。

picture.image

  1. 粘贴并运行脚本:将JavaScript 代码复制。在控制台的输入区域粘贴代码,然后按下 Enter 键执行。

picture.image

JavaScript 代码如下:

(function() {
   console.log('✨ 开始扫描页面上的可交互元素并生成 XPath 和 CSS 选择器... ✨');
   
   function escapeXPathString(value) {
       if (value.includes("'")) {
           const parts = value.split("'");
           const concatParts = parts.map(p => `'${p}'`);
           return `concat(${concatParts.join(', "\'", ')})`;
       }
       return `'${value}'`;
   }

   /**
    * 将元素信息数组转换为CSV格式字符串
    * @param {Array} data 元素信息数组
    * @returns {string} CSV格式字符串
    */
   function convertToCSV(data) {
       if (!data || !data.length) return '';

       const headers = ['XPath', 'CSS', 'TagName', 'Type', 'Role', 'ID', 'Class', 'Text', 'Note'];
       let csvContent = headers.join(',') + '\n';

       data.forEach(item => {
           const row = headers.map(header => {
               const value = item[header] || '';
               if (typeof value === 'string') {
                   if (value.includes(',') || value.includes('"') || value.includes('\n')) {
                       return `"${value.replace(/"/g, '""')}"`;
                   }
                   return value;
               }
               return '';
           });
           csvContent += row.join(',') + '\n';
       });

       return csvContent;
   }

   /**
    * 下载CSV文件
    * @param {string} csvContent CSV内容
    * @param {string} filename 文件名
    */
   function downloadCSV(csvContent, filename) {
       const BOM = '\uFEFF';
       const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
       const link = document.createElement('a');
       const url = URL.createObjectURL(blob);

       link.setAttribute('href', url);
       link.setAttribute('download', filename);
       link.style.display = 'none';

       document.body.appendChild(link);
       link.click();
       document.body.removeChild(link);
       URL.revokeObjectURL(url);
   }

   function cssEscape(value) {
       if (typeof CSS !== 'undefined' && CSS.escape) {
           return CSS.escape(value);
       }
       return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
   }

   function escapeAttributeValue(value) {
       if (!value) return '';
       return value.replace(/"/g, '\\"'); // Escape double quotes
   }

   function isUniqueId(id) {
       if (!id) return false;
       try {
           const elements = document.querySelectorAll(`#${cssEscape(id)}`);
           return elements.length === 1;
       } catch (e) {
            console.warn(`Error evaluating uniqueness of ID "#${id}".`, e);
            return false;
       }
   }

    function isUniqueXPath(xpath, contextNode, expectedElement = null) {
        if (!xpath || xpath.includes('(验证失败或不唯一)') || xpath.includes('(生成或验证出错)')) {
            return false;
        }
        try {
            const result = document.evaluate(xpath, contextNode, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
            if (expectedElement) {
                 return result.snapshotLength === 1 && result.snapshotItem(0) === expectedElement;
            }
            return result.snapshotLength === 1;
        } catch (e) {
            return false;
        }
    }

   function isUniqueCssSelector(selector, contextNode, expectedElement = null) {
       if (!selector || selector.includes('(验证失败或不唯一)') || selector.includes('(生成或验证出错)')) {
           return false;
       }
       try {
           const elements = contextNode.querySelectorAll(selector);
           if (expectedElement) {
               return elements.length === 1 && elements[0] === expectedElement;
           }
           return elements.length === 1;
       } catch (e) {
           return false;
       }
   }


   /**
    * 判断元素是否可见(考虑到 display: none 和 visibility: hidden 以及尺寸)
    * @param {Element} el
    * @returns {boolean}
    */
   function isElementVisible(el) {
       if (!el.offsetParent || el.offsetWidth === 0 || el.offsetHeight === 0) {
           return false;
       }
       const style = window.getComputedStyle(el);
       if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
            return false;
       }
       let current = el.parentElement;
       while (current && current !== document.documentElement) {
           const parentStyle = window.getComputedStyle(current);
           if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden') {
               return false;
           }
           current = current.parentElement;
       }
       return true;
   }

   /**
    * 判断元素是否是可交互的(基于标签、属性、角色等)
    * @param {Element} el
    * @returns {boolean}
    */
   function isElementInteractive(el) {
       const tag = el.tagName.toLowerCase();
       const role = el.getAttribute('role');
       const tabindex = el.getAttribute('tabindex');
       const hasClickAttribute = el.hasAttribute('onclick');
       const isDisabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';

       if (isDisabled) return false;

       if (['a', 'button', 'select', 'textarea'].includes(tag)) {
            if (tag === 'a' && !el.hasAttribute('href') && !role && !hasClickAttribute) {
                const computedCursor = window.getComputedStyle(el).cursor;
                if ((tabindex === null || parseInt(tabindex, 10) < 0 || isNaN(parseInt(tabindex, 10))) && computedCursor !== 'pointer') {
                    return false;
                }
            }
            let parent = el.parentElement;
            while(parent && parent !== document.documentElement) {
                if (parent.tagName.toLowerCase() === 'fieldset' && parent.hasAttribute('disabled')) {
                    return false;
                }
                parent = parent.parentElement;
            }
           return true;
       }

       if (tag === 'input') {
           const type = el.type.toLowerCase();
           if (type === 'hidden') return false;
           return true;
       }

       if (tabindex !== null && parseInt(tabindex, 10) >= 0 && !isNaN(parseInt(tabindex, 10))) {
            return true;
       }

       if (role) {
           const interactiveRoles = [
                'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox',
                'menuitem', 'option', 'switch', 'slider', 'tab', 'gridcell',
                'columnheader', 'rowheader', 'img',
           ];
           if (interactiveRoles.includes(role.toLowerCase())) {
                return true;
           }
       }

        if (hasClickAttribute) {
            return true;
        }
       return false;
   }

    function generateTargetPredicate(el) {
         const nameAttr = el.getAttribute('name');
         const roleAttr = el.getAttribute('role');
         const ariaLabelAttr = el.getAttribute('aria-label');
         const placeholderAttr = el.getAttribute('placeholder');
         const valueAttr = el.tagName.toLowerCase() === 'input' ? el.value : null;
         const textContent = el.textContent ? el.textContent.trim() : '';
         const classAttr = el.className;

         if (nameAttr) return `[@name=${escapeXPathString(nameAttr)}]`;
         if (roleAttr) return `[@role=${escapeXPathString(roleAttr)}]`;
         if (ariaLabelAttr) return `[@aria-label=${escapeXPathString(ariaLabelAttr)}]`;
         if (placeholderAttr) return `[@placeholder=${escapeXPathString(placeholderAttr)}]`;
         if (valueAttr && valueAttr.length > 0 && valueAttr.length < 50) return `[@value=${escapeXPathString(valueAttr)}]`;
         if (textContent && textContent.length > 0 && textContent.length < 50 && !textContent.includes('\n')) return `[contains(string(.), ${escapeXPathString(textContent)})]`;
         if (classAttr) {
              const classes = classAttr.trim().split(/\s+/).filter(cls => cls.length > 0);
              if (classes.length > 0) {
                  const classPredicates = classes.map(cls => `contains(@class, ${escapeXPathString(cls)})`).join(' and ');
                  if (classPredicates) return `[${classPredicates}]`;
              }
         }
         return '';
    }

    function generateIndexPredicate(el, parent) {
         if (!parent) return '';
         let index = 0;
         let sameTagCount = 0;
         for (let i = 0; i < parent.children.length; i++) {
             const sibling = parent.children[i];
             if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === el.tagName) {
                 sameTagCount++;
                 if (sibling === el) {
                     index = sameTagCount;
                     break;
                 }
             }
         }
         if (sameTagCount > 1 && index > 0) return `[${index}]`;
         return '';
    }

   function generateXPath(el) {
       if (!el || !el.tagName || el.nodeType !== Node.ELEMENT_NODE) return null;

       const targetElement = el;
       const targetTagName = el.tagName.toLowerCase();

       if (el.id && isUniqueId(el.id)) {
            return `//*[@id=${escapeXPathString(el.id)}]`;
       }

       const targetPredicate = generateTargetPredicate(el);

       if (targetPredicate) {
            const targetOnlyPath = `//${targetTagName}${targetPredicate}`;
            if (isUniqueXPath(targetOnlyPath, document, targetElement)) {
                return targetOnlyPath;
            }
       }

       const pathSegments = [];
       let current = el;
       let relativeToAncestor = null;

       while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
           if (current.id && current !== targetElement && isUniqueId(current.id)) {
                relativeToAncestor = current;
                break;
           }

           let segment = current.tagName.toLowerCase();
           let parent = current.parentNode;
           let predicate = '';

           if (current === targetElement) {
               predicate = targetPredicate;
               if (!predicate && parent) {
                    predicate = generateIndexPredicate(current, parent);
               }
           } else {
                if (parent) {
                     predicate = generateIndexPredicate(current, parent);
                }
           }
           segment += predicate;
           pathSegments.unshift(segment);
           current = parent;
       }

       let fullPath = '';
       if (relativeToAncestor) {
           fullPath = `//*[@id=${escapeXPathString(relativeToAncestor.id)}]//${targetTagName}${targetPredicate || ''}`; // Ensure targetPredicate is appended
       } else if (pathSegments.length > 0) {
           fullPath = `/${pathSegments.join('/')}`; // Start from root or html if available
            if (document.documentElement && pathSegments[0] !== document.documentElement.tagName.toLowerCase()) {
                fullPath = `/${document.documentElement.tagName.toLowerCase()}${fullPath}`;
            }
            fullPath = `/${fullPath}`; 
       } else {
           return null;
       }

       if (isUniqueXPath(fullPath, document, targetElement)) {
            return fullPath;
       } else {
            console.warn(`Generated XPath "${fullPath}" for element`, el, `does not uniquely point to the element.`);
            return fullPath + ' (验证失败或不唯一)';
       }
   }

   function generateCssSelector(el) {
       if (!el || !el.tagName) return null;
       const tagName = el.tagName.toLowerCase();
       let selector;

       if (el.id && isUniqueId(el.id)) { // isUniqueId already uses cssEscape
           selector = `#${cssEscape(el.id)}`;
           if (isUniqueCssSelector(selector, document, el)) return selector;
       }

       const nameAttr = el.getAttribute('name');
       if (nameAttr) {
           selector = `${tagName}[name="${escapeAttributeValue(nameAttr)}"]`;
           if (isUniqueCssSelector(selector, document, el)) return selector;
           selector = `[name="${escapeAttributeValue(nameAttr)}"]`; // Without tag
           if (isUniqueCssSelector(selector, document, el)) return selector;
       }

       const testIdAttr = el.getAttribute('data-testid');
       if (testIdAttr) {
           selector = `[data-testid="${escapeAttributeValue(testIdAttr)}"]`;
           if (isUniqueCssSelector(selector, document, el)) return selector;
           selector = `${tagName}[data-testid="${escapeAttributeValue(testIdAttr)}"]`;
           if (isUniqueCssSelector(selector, document, el)) return selector;
       }        
       if (el.classList.length > 0) {
           for (const cls of el.classList) {
               const safeClass = cssEscape(cls);
               selector = `${tagName}.${safeClass}`;
               if (isUniqueCssSelector(selector, document, el)) return selector;
               selector = `.${safeClass}`;
               if (isUniqueCssSelector(selector, document, el)) return selector;
           }
           const allClasses = Array.from(el.classList).map(c => `.${cssEscape(c)}`).join('');
           if (allClasses) {
               selector = `${tagName}${allClasses}`;
               if (isUniqueCssSelector(selector, document, el)) return selector;
               selector = `${allClasses}`; // Without tag
                if (isUniqueCssSelector(selector, document, el)) return selector;
           }
       }

       const commonAttrs = ['role', 'aria-label', 'placeholder', 'type'];
       for (const attr of commonAttrs) {
           const attrValue = el.getAttribute(attr);
           if (attrValue) {
               selector = `${tagName}[${attr}="${escapeAttributeValue(attrValue)}"]`;
               if (isUniqueCssSelector(selector, document, el)) return selector;
           }
       }

       function getCssPathSegments(targetEl, stopAtUniqueAncestor = true) {
           const segments = [];
           let current = targetEl;
           let uniqueAncestorSelector = null;

           while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
               const currentTagName = current.tagName.toLowerCase();
               let segment = currentTagName;

               if (stopAtUniqueAncestor && current !== targetEl) { // Check ancestors for unique identifiers
                   if (current.id && isUniqueId(current.id)) {
                       uniqueAncestorSelector = `#${cssEscape(current.id)}`;
                       break;
                   }
                   const currentTestId = current.getAttribute('data-testid');
                   if (currentTestId) {
                       const tempTestIdSelector = `[data-testid="${escapeAttributeValue(currentTestId)}"]`;
                       if (isUniqueCssSelector(tempTestIdSelector, document, current)) {
                            uniqueAncestorSelector = tempTestIdSelector;
                            break;
                       }
                   }
               }

               const parent = current.parentElement;
               if (parent) {
                   const sameTagSiblings = Array.from(parent.children).filter(child => child.tagName === current.tagName);
                   if (sameTagSiblings.length > 1) {
                       const index = sameTagSiblings.indexOf(current) + 1;
                       segment += `:nth-of-type(${index})`;
                   }
               }
               segments.unshift(segment);
               if (current === document.body && !uniqueAncestorSelector) break; 
               current = parent;
           }
            if (uniqueAncestorSelector) {
                return { ancestor: uniqueAncestorSelector, path: segments.join(' > ') };
            }
            if (current === document.documentElement && segments[0] !== 'html') {
                segments.unshift('html');
            }
            return { path: segments.join(' > ') };
       }

       const combinedPathInfo = getCssPathSegments(el, true);
       if (combinedPathInfo.ancestor && combinedPathInfo.path) {
           selector = `${combinedPathInfo.ancestor} > ${combinedPathInfo.path}`;
           if (isUniqueCssSelector(selector, document, el)) return selector;
       }

       const fullPathInfo = getCssPathSegments(el, false);
       selector = fullPathInfo.path;

       if (isUniqueCssSelector(selector, document, el)) {
           return selector;
       } else {
           return selector ? selector + ' (验证失败或不唯一)' : 'N/A (CSS生成失败)';
       }
   }

   const potentialInteractiveSelectors = [
       'a', 
       'button',
       'input:not([type="hidden"]):not([type="file"])',
       'select',
       'textarea',
       '[tabindex]:not([tabindex="-1"])',
       '[role="button"]',
       '[role="link"]',
       '[role="checkbox"]',
       '[role="radio"]',     
       '[role="textbox"]',  
       '[role="combobox"]',
       '[role="menuitem"]',
       '[role="option"]',
       '[role="switch"]',
       '[role="slider"]',    
       '[role="tab"]',
       '[role="gridcell"]',
       '[role="columnheader"]',
       '[role="rowheader"]',
       '[onclick]',
       '[aria-haspopup="true"]',
       '[data-toggle]',
       '[data-action]',
       '[data-qa]',
       '[data-cy]',
       '[data-test]',
       'div[role="button"]',
       'span[role="button"]',
       'li[role="menuitem"]',
       'li',
       'img[role="button"]',
       'div[role="link"]',
       'span[role="link"]'
   ].join(',');

   const potentialElements = document.querySelectorAll(potentialInteractiveSelectors);
   const interactiveElementsInfo = [];
   const processedElements = new Set();

   // 添加样式定义
   const style = document.createElement('style');
   style.textContent = `
       .locator-highlight {
           border: 2px solid #1E90FF !important;
           position: relative !important;
       }
       .locator-badge {
           position: absolute !important;
           top: 2px !important;
           right: 2px !important;
           background: #1E90FF !important;
           color: white !important;
           border-radius: 50% !important;
           width: 18px !important;
           height: 18px !important;
           display: flex !important;
           align-items: center !important;
           justify-content: center !important;
           font-size: 12px !important;
           font-family: Arial, sans-serif !important;
           z-index: 10000 !important;
       }
   `;
   document.head.appendChild(style);

   console.log(`初步找到 ${potentialElements.length} 个潜在的可交互元素。`);

   document.querySelectorAll('.locator-highlight').forEach(el => {
       el.classList.remove('locator-highlight');
   });
   document.querySelectorAll('.locator-badge').forEach(badge => {
       badge.remove();
   });

   let validElementIndex = 0;

   potentialElements.forEach(element => {
       if (processedElements.has(element)) return;

       if (isElementVisible(element) && isElementInteractive(element)) {
           const xpath = generateXPath(element);
           const css = generateCssSelector(element); 

           if (xpath || css) { 
               const originalText = element.textContent ?
                   element.textContent.trim().replace(/\s+/g, ' ').substring(0, 100) +
                   (element.textContent.trim().length > 100 ? '...' : '') : '';

               const isValid = (!xpath.includes('验证失败或不唯一') && !xpath.includes('生成失败')) ||
                             (!css.includes('验证失败或不唯一') && !css.includes('生成失败'));

               if (isValid) {
                   validElementIndex++;
                   element.classList.add('locator-highlight');
                   const badge = document.createElement('div');
                   badge.className = 'locator-badge';
                   badge.textContent = validElementIndex;
                   element.style.position = element.style.position === 'static' ? 'relative' : element.style.position;
                   element.appendChild(badge);
               }

               interactiveElementsInfo.push({
                   XPath: xpath || 'N/A (XPath生成失败)',
                   CSS: css || 'N/A (CSS生成失败)',
                   TagName: element.tagName.toLowerCase(),
                   Type: element.type ? element.type.toLowerCase() : '',
                   Role: element.getAttribute('role') || '',
                   ID: element.id || '',
                   Class: element.className || '',
                   Text: originalText,
                   Note: isValid ? `元素编号: ${validElementIndex}` : '',
                   Element: element
               });
           } else {
                console.warn('无法为元素生成 XPath 或 CSS 选择器:', element);
                interactiveElementsInfo.push({
                    XPath: 'N/A (生成失败)',
                    CSS: 'N/A (生成失败)',
                    TagName: element.tagName.toLowerCase(),
                    Type: element.type ? element.type.toLowerCase() : '',
                    Role: element.getAttribute('role') || '',
                    ID: element.id || '',
                    Class: element.className || '',
                    Text: element.textContent ? element.textContent.trim().replace(/\s+/g, ' ').substring(0, 100) + (element.textContent.trim().length > 100 ? '...' : '') : '',
                    Note: 'XPath 和 CSS 均无法生成',
                    Element: element
                });
           }
           processedElements.add(element);
       }
   });

   console.log(`✅ 找到 ${interactiveElementsInfo.length} 个实际的可交互元素。`);

   // --- Output Results ---
   if (interactiveElementsInfo.length > 0) {
       interactiveElementsInfo.sort((a, b) => {
            const lenA = (a.XPath || '').length + (a.CSS || '').length;
            const lenB = (b.XPath || '').length + (b.CSS || '').length;
            if (lenA !== lenB) return lenA - lenB;
            return a.TagName.localeCompare(b.TagName) || (a.XPath || '').localeCompare(b.XPath || '') || (a.CSS || '').localeCompare(b.CSS || '');
        });

       console.table(interactiveElementsInfo, ['XPath', 'CSS', 'TagName', 'Type', 'Role', 'ID', 'Class', 'Text', 'Note']);
       console.log('⬆️ 上表中列出了找到的可交互元素及其生成的 XPath、CSS选择器、文本内容等信息。');
       console.log('点击 "Element" 列中的值(如果表格中显示了),可以在 "Elements" 面板中检查对应的DOM元素。');

       // 添加下载按钮
       const downloadButton = document.createElement('button');
       downloadButton.textContent = '下载元素表格数据 (CSV)';
       downloadButton.style.position = 'fixed';
       downloadButton.style.top = '10px';
       downloadButton.style.right = '10px';
       downloadButton.style.zIndex = '10000';
       downloadButton.style.padding = '8px 12px';
       downloadButton.style.backgroundColor = '#4CAF50';
       downloadButton.style.color = 'white';
       downloadButton.style.border = 'none';
       downloadButton.style.borderRadius = '4px';
       downloadButton.style.cursor = 'pointer';
       downloadButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';

       downloadButton.addEventListener('click', () => {
           const csvContent = convertToCSV(interactiveElementsInfo);
           const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
           downloadCSV(csvContent, `页面元素定位器_${timestamp}.csv`);
       });

       document.body.appendChild(downloadButton);
       console.log('💾 已添加"下载元素表格数据"按钮,点击可将表格数据保存为CSV文件。');

       console.log('💡 **XPath 生成策略 (优先简洁唯一):**');
       console.log('  1. 唯一 ID (`//*[@id="element-id"]`)');
       console.log('  2. 目标元素标签+谓语 (`//tagName[predicate]`) (全局唯一)');
       console.log('  3. 最近唯一ID父级 + `//` + 目标 (`//*[@id="parent-id"]//tagName[predicate]`)');
       console.log('  4. 回退: 完整索引路径 (`/html/body/div[1]/...`)');
       console.log('💡 **CSS 选择器生成策略 (优先简洁唯一):**');
       console.log('  1. 唯一 ID (`#element-id`)');
       console.log('  2. 唯一 `name` (`tagName[name="value"]` 或 `[name="value"]`)');
       console.log('  3. 唯一 `data-testid` (`[data-testid="value"]` 或 `tagName[data-testid="value"]`)');
       console.log('  4. 唯一 Class (`.class`, `tagName.class`, `tagName.c1.c2`)');
       console.log('  5. 其它属性 (`tagName[role="value"]`, etc.)');
       console.log('  6. 组合: 最近唯一祖先(ID/data-testid) + 后代路径 (`#ancestorId > ... > tagName:nth-of-type(i)`)');
       console.log('  7. 回退: 完整 CSS 路径 (`html > body > div:nth-of-type(1) > ...`)');
       console.log('⚠️ 带 "(验证失败或不唯一)" 标记的定位器可能指向多个元素或存在问题,需要手动检查和调整。');

   } else {
       console.log('🚫 未找到符合条件的可交互元素。');
   }

   console.log('✨ 扫描完成。 ✨');

})();

三、脚本如何智能识别和生成定位器?

在运行脚本后,它会经历以下核心步骤:

3.1. 识别可交互与可见元素

脚本首先通过 document.querySelectorAll() 方法,使用一系列预定义的 CSS 选择器来初步筛选页面上常见的潜在交互元素,例如: a[href] (带链接的a标签), button, input (非hidden/file), select, textarea, [tabindex]:not([tabindex="-1"]) (可聚焦元素), [role="button"], [onclick] 以及包含 data-testdata-qa 等测试专用属性的元素。

然后,它会对这些初步筛选出的元素进行双重验证

  • 是否可见 (isElementVisible):检查元素的 displayvisibilityopacity 样式,以及 offsetWidthoffsetHeight 是否为零,并递归检查其父元素是否被隐藏。
  • 是否可交互 (isElementInteractive)
    • 基础标签a, button, select, textarea, input (非 hidden 类型)。
    • tabindex 属性tabindex 大于等于0的元素(可聚焦)。
    • role 属性:拥有 button, link, checkbox, radio, textbox 等 ARIA 交互角色的元素。
    • onclick 属性:直接绑定了点击事件的元素。
    • 同时,脚本会排除被 disabledaria-disabled="true" 的元素,以及在禁用状态的 <fieldset> 内部的元素。

只有同时满足“可见”和“可交互”条件的元素,才会被纳入后续的定位器生成环节。

3.2. 精心构建与验证定位器

脚本为每个符合条件的元素生成 XPath 和 CSS 选择器,并遵循“优先简洁唯一,其次稳定,最后回退”的策略:

💡 XPath 生成策略 (优先简洁唯一):

  1. 唯一 ID:如果元素拥有一个在整个文档中唯一的 id,则直接使用 //*[@id="your_id"]。这是最高优先级的定位器,最为稳定。
  2. 目标元素标签 + 谓语:尝试使用元素的标签名结合独特的属性(如 namerolearia-labelplaceholdervalueclass 等),生成 //tagName[predicate] 形式的 XPath。如果该 XPath 在文档中是唯一的,则优先采用。
  3. 最近唯一 ID 祖先 + 后代:如果元素本身没有唯一 ID,且无法通过自身属性生成唯一 XPath,脚本会向上查找最近的拥有唯一 ID 的祖先元素。然后构建一个从该祖先开始的相对 XPath,如 //*[@id="parent-id"]//tagName[predicate]
  4. 回退:完整索引路径:如果以上方法都无法生成唯一的 XPath,脚本会构建一个从 html 根元素开始的完整、基于同级索引的绝对路径,例如 /html/body/div[1]/ul[2]/li[3]/a[1]。这种定位器虽然不直观且脆弱,但总能指向目标。

💡 CSS 选择器生成策略 (优先简洁唯一):

  1. 唯一 ID:与 XPath 类似,优先使用 ID#element-id
  2. 唯一 name 属性:尝试 tagName[name="value"] 或更简洁的 [name="value"]
  3. 唯一 data-testid 属性:自动化测试中常用的自定义属性,如 [data-testid="value"]
  4. 唯一 Class 选择器:尝试使用元素的 class,从单个 class 到组合所有 class,如 .class1tagName.class2tagName.class1.class2
  5. 其他属性选择器:利用 role, aria-label, placeholder, type 等属性,如 tagName[role="value"]
  6. 组合路径(最近唯一祖先):如果元素自身无法唯一,则向上查找最近的拥有唯一 ID 或 data-testid 属性的祖先,然后构建一个相对路径,如 #ancestorId > div:nth-of-type(1) > button:nth-of-type(2)
  7. 回退:完整 CSS 路径:如果所有尝试都失败,则回退到基于 :nth-of-type 的完整路径,例如 html > body > div:nth-of-type(1) > ul:nth-of-type(2) > li:nth-of-type(3)

唯一性验证:无论是 XPath 还是 CSS,每当生成一个候选定位器时,脚本都会立即执行 document.evaluate()document.querySelectorAll() 进行验证。只有当该定位器精准指向且仅指向当前处理的元素时,才会被采纳。如果验证失败或指向多个元素,则会在结果中标记为 (验证失败或不唯一),提醒您手动检查。

3.3. 可视化与数据输出

  • 页面高亮与编号:所有成功生成有效定位器的元素,都会在页面上被蓝色边框高亮显示,并附带一个蓝色圆形的数字徽标(如 , , ),这些数字对应控制台表格中的“Note”列,便于快速定位。
  • 控制台表格 (console.table)
    • XPath:生成的 XPath 定位器。
    • CSS:生成的 CSS 选择器。
    • TagName:元素标签名(如 div, input, a)。
    • Typeinput 元素的 type 属性(如 text, submit)。
    • Role:元素的 ARIA role 属性。
    • ID:元素的 id 属性。
    • Class:元素的 class 属性。
    • Text:元素的可见文本内容(前100字符),便于理解元素用途。
    • Note:元素的编号(如果生成成功),或“XPath/CSS 均无法生成”等提示。
    • Element:最强大的地方!点击表格中此列的值,可以直接在 Chrome DevTools 的“Elements”面板中选中并检查对应的 DOM 元素。
  • CSV 数据导出:在页面右上方会出现一个绿色的“下载元素表格数据 (CSV)”按钮。点击它,即可将 console.table 中展示的所有数据导出为一个 CSV 文件。该文件已添加 UTF-8 BOM 头,确保中文内容不会出现乱码,可以直接在 Excel 或其他文本编辑器中打开。

四、使用注意事项

  • 动态内容:对于大量动态加载、异步渲染的页面,你可能需要在页面加载完成后或特定操作后再次运行脚本,以捕捉最新的 DOM 结构。
  • Shadow DOM:此脚本主要针对 Light DOM。对于处于 Shadow DOM 内部的元素,浏览器标准 API 无法直接访问,因此此脚本暂时无法识别。遇到这种情况,你需要使用特定的工具或方法穿透 Shadow DOM。
  • 复杂场景:虽然脚本尽力生成最优定位器,但在极端复杂的页面结构下(如大量无ID、无class、无唯一属性的同级元素),生成的定位器可能仍较长或带索引。此时,结合业务语义和人工审查进行微调仍然是最佳实践。
  • 自定义属性:如果你的项目广泛使用 data-qadata-test 等自定义测试属性,此脚本已将其纳入 CSS 策略的考量。你可以根据需要,在 generateXPathgenerateCssSelector 函数中添加对其他自定义属性的优先级处理。
  • 清理:每次运行脚本前,它会自动清除上次运行生成的高亮和徽标,保持页面整洁。

结语

这个 Chrome DevTools 脚本是每一位自动化测试工程师的强大助力。它将我们从繁琐的元素定位工作中解放出来,让我们能更专注于测试逻辑和业务场景。通过自动化、智能化的方式,它不仅提升了工作效率,更保障了测试定位器的质量和稳定性,是构建健壮自动化测试框架不可或缺的一环。


如果你对脚本有任何改进建议或疑问,欢迎在评论区留言交流!

0
0
0
0
关于作者
关于作者

文章

0

获赞

0

收藏

0

相关资源
融合开放,新一代边缘云网络平台 | 第 11 期边缘云主题Meetup
《融合开放,新一代边缘云网络平台 》李冰|火山引擎边缘云网络产品负责人
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论