diff --git a/packages/extension/src/lib/robula-plus/index.test.ts b/packages/extension/src/lib/robula-plus/index.test.ts new file mode 100644 index 00000000..51dfcabf --- /dev/null +++ b/packages/extension/src/lib/robula-plus/index.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { RobulaPlus } from "./index" + +describe("RobulaPlus", () => { + let container: HTMLElement + + beforeEach(() => { + // Create a fresh container for each test + container = document.createElement("div") + document.body.appendChild(container) + }) + + afterEach(() => { + // Clean up after each test + document.body.removeChild(container) + }) + + describe("Attribute prioritization", () => { + it("should prioritize data-testid over other attributes", () => { + container.innerHTML = ` +
+ + +
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(buttons[0], document) + + // Should use data-testid in the XPath because there are multiple buttons + expect(xpath).toContain("data-testid") + expect(xpath).toContain("submit-btn") + }) + + it("should prioritize data-test-id over other attributes", () => { + container.innerHTML = ` +
+ + +
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(buttons[0], document) + + // Should use data-test-id in the XPath + expect(xpath).toContain("data-test-id") + expect(xpath).toContain("login-btn") + }) + + it("should prioritize data-test over other attributes", () => { + container.innerHTML = ` +
+ + +
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(buttons[0], document) + + // Should use data-test in the XPath + expect(xpath).toContain("data-test") + expect(xpath).toContain("cancel-btn") + }) + + it("should use data-testid even when name and class are present", () => { + container.innerHTML = ` +
+ + +
+ ` + const inputs = container.querySelectorAll("input") + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(inputs[0], document) + + // Should prefer data-testid over name and class (and type which both have) + expect(xpath).toContain("data-testid") + expect(xpath).toContain("username-input") + }) + }) + + describe("aria-label exclusion", () => { + it("should not use aria-label in XPath selectors", () => { + container.innerHTML = ` +
+ +
+ ` + const button = container.querySelector("button")! + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(button, document) + + // Should NOT use aria-label in the XPath + expect(xpath).not.toContain("aria-label") + expect(xpath).not.toContain("Submit form") + }) + + it("should not use aria-label even when it's the only distinctive attribute", () => { + container.innerHTML = ` +
+
+ +
+
+ +
+
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + + const xpath1 = robula.getRobustXPath(buttons[0], document) + const xpath2 = robula.getRobustXPath(buttons[1], document) + + // Should NOT use aria-label anywhere in the XPath + expect(xpath1).not.toContain("aria-label") + expect(xpath2).not.toContain("aria-label") + + // The XPaths should still uniquely identify each button (using position or other attributes) + expect(robula.uniquelyLocate(xpath1, buttons[0], document)).toBe(true) + expect(robula.uniquelyLocate(xpath2, buttons[1], document)).toBe(true) + }) + + it("should prefer other attributes over aria-label", () => { + container.innerHTML = ` +
+ + +
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(buttons[0], document) + + // Should use name instead of aria-label + expect(xpath).not.toContain("aria-label") + expect(xpath).toContain("name") + expect(xpath).toContain("submit-btn") + }) + }) + + describe("Combined scenarios", () => { + it("should prioritize data-testid over name, class, and aria-label", () => { + container.innerHTML = ` +
+ + +
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + const xpath = robula.getRobustXPath(buttons[0], document) + + // Should use data-testid and not aria-label + expect(xpath).toContain("data-testid") + expect(xpath).toContain("primary-action") + expect(xpath).not.toContain("aria-label") + }) + + it("should work with multiple elements with data-testid", () => { + container.innerHTML = ` +
+ + + +
+ ` + const buttons = container.querySelectorAll("button") + const robula = new RobulaPlus() + + const xpath1 = robula.getRobustXPath(buttons[0], document) + const xpath2 = robula.getRobustXPath(buttons[1], document) + const xpath3 = robula.getRobustXPath(buttons[2], document) + + // Each should use its unique data-testid + expect(xpath1).toContain("btn-1") + expect(xpath2).toContain("btn-2") + expect(xpath3).toContain("btn-3") + + // Each should uniquely identify its element + expect(robula.uniquelyLocate(xpath1, buttons[0], document)).toBe(true) + expect(robula.uniquelyLocate(xpath2, buttons[1], document)).toBe(true) + expect(robula.uniquelyLocate(xpath3, buttons[2], document)).toBe(true) + }) + }) +}) diff --git a/packages/extension/src/lib/robula-plus/index.ts b/packages/extension/src/lib/robula-plus/index.ts index 3f4c1eab..77078872 100644 --- a/packages/extension/src/lib/robula-plus/index.ts +++ b/packages/extension/src/lib/robula-plus/index.ts @@ -11,7 +11,15 @@ * @param options - (optional) algorithm options. */ export class RobulaPlus { + // Data-testid type attributes in priority order + private static readonly DATA_TESTID_ATTRIBUTES = [ + "data-testid", + "data-test-id", + "data-test", + ] as const + private attributePriorizationList: string[] = [ + ...RobulaPlus.DATA_TESTID_ATTRIBUTES, "name", "class", "title", @@ -29,6 +37,7 @@ export class RobulaPlus { "size", "maxlength", "value", + "aria-label", ] // Flag to determine whether to detect random number patterns @@ -93,9 +102,10 @@ export class RobulaPlus { const xPath: XPath = xPathList.shift()! let temp: XPath[] = [] temp = temp.concat(this.transfConvertStar(xPath, element)) + temp = temp.concat(this.transfAddDataTestId(xPath, element)) temp = temp.concat(this.transfAddId(xPath, element)) - temp = temp.concat(this.transfAddText(xPath, element)) temp = temp.concat(this.transfAddAttribute(xPath, element)) + temp = temp.concat(this.transfAddText(xPath, element)) temp = temp.concat(this.transfAddAttributeSet(xPath, element)) temp = temp.concat(this.transfAddPosition(xPath, element)) temp = temp.concat(this.transfAddLevel(xPath, element)) @@ -159,6 +169,18 @@ export class RobulaPlus { } } + /** + * Escapes single quotes in attribute values for safe XPath generation + * @param value - The attribute value to escape + * @returns The escaped value safe for use in XPath predicates + */ + private escapeXPathValue(value: string | null | undefined): string { + if (value === null || value === undefined) { + return "" + } + return value.replace(/'/g, "'") + } + public transfConvertStar(xPath: XPath, element: Element): XPath[] { const output: XPath[] = [] const ancestor: Element = this.getAncestor(element, xPath.getLength() - 1) @@ -177,13 +199,35 @@ export class RobulaPlus { // Only add ID if it doesn't contain React useId patterns if (this.isAttributeValueUsable(ancestor.id)) { const newXPath: XPath = new XPath(xPath.getValue()) - newXPath.addPredicateToHead(`[@id='${ancestor.id}']`) + newXPath.addPredicateToHead(`[@id='${this.escapeXPathValue(ancestor.id)}']`) output.push(newXPath) } } return output } + public transfAddDataTestId(xPath: XPath, element: Element): XPath[] { + const output: XPath[] = [] + const ancestor: Element = this.getAncestor(element, xPath.getLength() - 1) + + if (!xPath.headHasAnyPredicates()) { + // Check for data-testid type attributes in priority order + for (const attrName of RobulaPlus.DATA_TESTID_ATTRIBUTES) { + const attrValue = ancestor.getAttribute(attrName) + // For data-testid attributes, we don't check for random patterns + // because these are explicitly set by developers for testing purposes + if (attrValue) { + const newXPath: XPath = new XPath(xPath.getValue()) + newXPath.addPredicateToHead(`[@${attrName}='${this.escapeXPathValue(attrValue)}']`) + output.push(newXPath) + // Return immediately after finding the first data-test* attribute + break + } + } + } + return output + } + public transfAddText(xPath: XPath, element: Element): XPath[] { const output: XPath[] = [] const ancestor: Element = this.getAncestor(element, xPath.getLength() - 1) @@ -195,7 +239,7 @@ export class RobulaPlus { ) { const newXPath: XPath = new XPath(xPath.getValue()) newXPath.addPredicateToHead( - `[contains(text(),'${ancestor.textContent}')]`, + `[contains(text(),'${this.escapeXPathValue(ancestor.textContent)}')]`, ) output.push(newXPath) } @@ -215,7 +259,7 @@ export class RobulaPlus { ) { const newXPath: XPath = new XPath(xPath.getValue()) newXPath.addPredicateToHead( - `[@${attribute.name}='${attribute.value}']`, + `[@${attribute.name}='${this.escapeXPathValue(attribute.value)}']`, ) output.push(newXPath) break @@ -230,7 +274,7 @@ export class RobulaPlus { ) { const newXPath: XPath = new XPath(xPath.getValue()) newXPath.addPredicateToHead( - `[@${attribute.name}='${attribute.value}']`, + `[@${attribute.name}='${this.escapeXPathValue(attribute.value)}']`, ) output.push(newXPath) } @@ -286,9 +330,9 @@ export class RobulaPlus { // convert to predicate for (const attributeSet of attributePowerSet) { - let predicate: string = `[@${attributeSet[0].name}='${attributeSet[0].value}'` + let predicate: string = `[@${attributeSet[0].name}='${this.escapeXPathValue(attributeSet[0].value)}'` for (let i: number = 1; i < attributeSet.length; i++) { - predicate += ` and @${attributeSet[i].name}='${attributeSet[i].value}'` + predicate += ` and @${attributeSet[i].name}='${this.escapeXPathValue(attributeSet[i].value)}'` } predicate += "]" const newXPath: XPath = new XPath(xPath.getValue()) @@ -575,6 +619,7 @@ export class RobulaPlusOptions { */ public attributePriorizationList: string[] = [ + ...RobulaPlus.DATA_TESTID_ATTRIBUTES, "name", "class", "title", @@ -592,6 +637,7 @@ export class RobulaPlusOptions { "size", "maxlength", "value", + "aria-label", ] public avoidRandomPatterns?: boolean public randomPatterns?: RegExp[]