From b149faab5a413f29c13e016e91e93cfbb141a35a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Feb 2026 02:12:55 +0000
Subject: [PATCH 1/4] Initial plan
From 455f37a58e28114dea707cef0916e9e92c918955 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Feb 2026 02:20:57 +0000
Subject: [PATCH 2/4] Implement data-testid prioritization and aria-label
exclusion
Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com>
---
.../src/lib/robula-plus/index.test.ts | 202 ++++++++++++++++++
.../extension/src/lib/robula-plus/index.ts | 35 ++-
2 files changed, 236 insertions(+), 1 deletion(-)
create mode 100644 packages/extension/src/lib/robula-plus/index.test.ts
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..fb6cb810 100644
--- a/packages/extension/src/lib/robula-plus/index.ts
+++ b/packages/extension/src/lib/robula-plus/index.ts
@@ -12,6 +12,9 @@
*/
export class RobulaPlus {
private attributePriorizationList: string[] = [
+ "data-testid",
+ "data-test-id",
+ "data-test",
"name",
"class",
"title",
@@ -29,6 +32,7 @@ export class RobulaPlus {
"size",
"maxlength",
"value",
+ "aria-label",
]
// Flag to determine whether to detect random number patterns
@@ -93,9 +97,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))
@@ -184,6 +189,30 @@ export class RobulaPlus {
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
+ const dataTestIdAttributes = ["data-testid", "data-test-id", "data-test"]
+
+ for (const attrName of dataTestIdAttributes) {
+ 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}='${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)
@@ -575,6 +604,9 @@ export class RobulaPlusOptions {
*/
public attributePriorizationList: string[] = [
+ "data-testid",
+ "data-test-id",
+ "data-test",
"name",
"class",
"title",
@@ -592,6 +624,7 @@ export class RobulaPlusOptions {
"size",
"maxlength",
"value",
+ "aria-label",
]
public avoidRandomPatterns?: boolean
public randomPatterns?: RegExp[]
From 0140adb743f5aa35e2be8198fa490cdd7b38232c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Feb 2026 02:25:10 +0000
Subject: [PATCH 3/4] Address code review feedback: extract constants and add
XPath escaping
Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com>
---
.../src/lib/robula-plus/index.test.ts | 23 +++++++++++
.../extension/src/lib/robula-plus/index.ts | 38 ++++++++++++-------
2 files changed, 47 insertions(+), 14 deletions(-)
diff --git a/packages/extension/src/lib/robula-plus/index.test.ts b/packages/extension/src/lib/robula-plus/index.test.ts
index 51dfcabf..6da45485 100644
--- a/packages/extension/src/lib/robula-plus/index.test.ts
+++ b/packages/extension/src/lib/robula-plus/index.test.ts
@@ -198,5 +198,28 @@ describe("RobulaPlus", () => {
expect(robula.uniquelyLocate(xpath2, buttons[1], document)).toBe(true)
expect(robula.uniquelyLocate(xpath3, buttons[2], document)).toBe(true)
})
+
+ it("should escape single quotes in attribute values", () => {
+ // Test the escaping function directly through a scenario where the attribute is used
+ container.innerHTML = `
+
+
+
+
+ `
+ const inputs = container.querySelectorAll("input")
+ const robula = new RobulaPlus()
+
+ const xpath1 = robula.getRobustXPath(inputs[0], document)
+
+ // If name attribute is used (instead of position), it should be escaped
+ if (xpath1.includes("name")) {
+ expect(xpath1).toContain("'")
+ expect(xpath1).toContain("user'name")
+ }
+
+ // XPath should still uniquely identify the element regardless
+ expect(robula.uniquelyLocate(xpath1, inputs[0], document)).toBe(true)
+ })
})
})
diff --git a/packages/extension/src/lib/robula-plus/index.ts b/packages/extension/src/lib/robula-plus/index.ts
index fb6cb810..26a9358a 100644
--- a/packages/extension/src/lib/robula-plus/index.ts
+++ b/packages/extension/src/lib/robula-plus/index.ts
@@ -11,10 +11,15 @@
* @param options - (optional) algorithm options.
*/
export class RobulaPlus {
- private attributePriorizationList: string[] = [
+ // 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",
@@ -164,6 +169,15 @@ 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): string {
+ return value.replace(/'/g, "'")
+ }
+
public transfConvertStar(xPath: XPath, element: Element): XPath[] {
const output: XPath[] = []
const ancestor: Element = this.getAncestor(element, xPath.getLength() - 1)
@@ -182,7 +196,7 @@ 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)
}
}
@@ -195,15 +209,13 @@ export class RobulaPlus {
if (!xPath.headHasAnyPredicates()) {
// Check for data-testid type attributes in priority order
- const dataTestIdAttributes = ["data-testid", "data-test-id", "data-test"]
-
- for (const attrName of dataTestIdAttributes) {
+ 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}='${attrValue}']`)
+ newXPath.addPredicateToHead(`[@${attrName}='${this.escapeXPathValue(attrValue)}']`)
output.push(newXPath)
// Return immediately after finding the first data-test* attribute
break
@@ -224,7 +236,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)
}
@@ -244,7 +256,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
@@ -259,7 +271,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)
}
@@ -315,9 +327,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())
@@ -604,9 +616,7 @@ export class RobulaPlusOptions {
*/
public attributePriorizationList: string[] = [
- "data-testid",
- "data-test-id",
- "data-test",
+ ...RobulaPlus.DATA_TESTID_ATTRIBUTES,
"name",
"class",
"title",
From c03db680d0ec8ec2cf2e91234db62e8dc1f4f452 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Feb 2026 02:28:33 +0000
Subject: [PATCH 4/4] Handle null values in escapeXPathValue function
Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com>
---
.../src/lib/robula-plus/index.test.ts | 23 -------------------
.../extension/src/lib/robula-plus/index.ts | 5 +++-
2 files changed, 4 insertions(+), 24 deletions(-)
diff --git a/packages/extension/src/lib/robula-plus/index.test.ts b/packages/extension/src/lib/robula-plus/index.test.ts
index 6da45485..51dfcabf 100644
--- a/packages/extension/src/lib/robula-plus/index.test.ts
+++ b/packages/extension/src/lib/robula-plus/index.test.ts
@@ -198,28 +198,5 @@ describe("RobulaPlus", () => {
expect(robula.uniquelyLocate(xpath2, buttons[1], document)).toBe(true)
expect(robula.uniquelyLocate(xpath3, buttons[2], document)).toBe(true)
})
-
- it("should escape single quotes in attribute values", () => {
- // Test the escaping function directly through a scenario where the attribute is used
- container.innerHTML = `
-
-
-
-
- `
- const inputs = container.querySelectorAll("input")
- const robula = new RobulaPlus()
-
- const xpath1 = robula.getRobustXPath(inputs[0], document)
-
- // If name attribute is used (instead of position), it should be escaped
- if (xpath1.includes("name")) {
- expect(xpath1).toContain("'")
- expect(xpath1).toContain("user'name")
- }
-
- // XPath should still uniquely identify the element regardless
- expect(robula.uniquelyLocate(xpath1, inputs[0], document)).toBe(true)
- })
})
})
diff --git a/packages/extension/src/lib/robula-plus/index.ts b/packages/extension/src/lib/robula-plus/index.ts
index 26a9358a..77078872 100644
--- a/packages/extension/src/lib/robula-plus/index.ts
+++ b/packages/extension/src/lib/robula-plus/index.ts
@@ -174,7 +174,10 @@ export class RobulaPlus {
* @param value - The attribute value to escape
* @returns The escaped value safe for use in XPath predicates
*/
- private escapeXPathValue(value: string): string {
+ private escapeXPathValue(value: string | null | undefined): string {
+ if (value === null || value === undefined) {
+ return ""
+ }
return value.replace(/'/g, "'")
}