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 01/12] 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 02/12] 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 03/12] 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 04/12] 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, "'")
}
From b36a65d2b80464ebfb6598a903f5d860be477f41 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Feb 2026 02:48:08 +0000
Subject: [PATCH 05/12] Fix XPath escaping and make DATA_TESTID_ATTRIBUTES
public
Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com>
---
.../src/lib/robula-plus/index.test.ts | 83 +++++++++++++++++++
.../extension/src/lib/robula-plus/index.ts | 60 +++++++++++---
2 files changed, 132 insertions(+), 11 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..ba9d0f9c 100644
--- a/packages/extension/src/lib/robula-plus/index.test.ts
+++ b/packages/extension/src/lib/robula-plus/index.test.ts
@@ -199,4 +199,87 @@ describe("RobulaPlus", () => {
expect(robula.uniquelyLocate(xpath3, buttons[2], document)).toBe(true)
})
})
+
+ describe("XPath quote escaping", () => {
+ it("should handle single quotes in data-testid values", () => {
+ container.innerHTML = `
+
+
+
+
+ `
+ const buttons = container.querySelectorAll("button")
+ const robula = new RobulaPlus()
+
+ const xpath1 = robula.getRobustXPath(buttons[0], document)
+
+ // Should use data-testid and properly escape the single quote
+ expect(xpath1).toContain("data-testid")
+ // XPath should use double quotes when value contains single quote
+ expect(xpath1).toMatch(/data-testid="user's-button"/)
+
+ // XPath should uniquely identify the element
+ expect(robula.uniquelyLocate(xpath1, buttons[0], document)).toBe(true)
+ })
+
+ it("should handle double quotes in data-testid values", () => {
+ container.innerHTML = `
+
+
+
+
+ `
+ const buttons = container.querySelectorAll("button")
+ const robula = new RobulaPlus()
+
+ const xpath1 = robula.getRobustXPath(buttons[0], document)
+
+ // Should use data-testid and properly escape the double quotes
+ expect(xpath1).toContain("data-testid")
+ // XPath should use single quotes when value contains double quote
+ expect(xpath1).toMatch(/data-testid='say-"hello"'/)
+
+ // XPath should uniquely identify the element
+ expect(robula.uniquelyLocate(xpath1, buttons[0], document)).toBe(true)
+ })
+
+ it("should handle both single and double quotes in data-testid values", () => {
+ container.innerHTML = `
+
+
+
+
+ `
+ const buttons = container.querySelectorAll("button")
+ const robula = new RobulaPlus()
+
+ const xpath1 = robula.getRobustXPath(buttons[0], document)
+
+ // Should use data-testid and use concat() for mixed quotes
+ expect(xpath1).toContain("data-testid")
+ expect(xpath1).toContain("concat(")
+
+ // XPath should uniquely identify the element
+ expect(robula.uniquelyLocate(xpath1, buttons[0], document)).toBe(true)
+ })
+
+ it("should handle single quotes in text content", () => {
+ container.innerHTML = `
+
+ It's a test
+ Another test
+
+ `
+ const spans = container.querySelectorAll("span")
+ const robula = new RobulaPlus()
+
+ const xpath1 = robula.getRobustXPath(spans[0], document)
+
+ // XPath should use contains() with properly escaped text
+ expect(xpath1).toContain("contains(text(),")
+
+ // XPath should uniquely identify the element
+ expect(robula.uniquelyLocate(xpath1, spans[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 77078872..1d1b2bcf 100644
--- a/packages/extension/src/lib/robula-plus/index.ts
+++ b/packages/extension/src/lib/robula-plus/index.ts
@@ -12,7 +12,7 @@
*/
export class RobulaPlus {
// Data-testid type attributes in priority order
- private static readonly DATA_TESTID_ATTRIBUTES = [
+ public static readonly DATA_TESTID_ATTRIBUTES = [
"data-testid",
"data-test-id",
"data-test",
@@ -170,15 +170,53 @@ export class RobulaPlus {
}
/**
- * Escapes single quotes in attribute values for safe XPath generation
+ * Creates a properly escaped XPath string literal
* @param value - The attribute value to escape
* @returns The escaped value safe for use in XPath predicates
+ *
+ * @remarks
+ * XPath 1.0 doesn't support escape sequences, so we need to use different approaches:
+ * - If value contains no quotes: use single quotes 'value'
+ * - If value contains only single quotes: use double quotes "value"
+ * - If value contains both: use concat() to combine parts
*/
private escapeXPathValue(value: string | null | undefined): string {
if (value === null || value === undefined) {
- return ""
+ return "''"
}
- return value.replace(/'/g, "'")
+
+ const hasSingleQuote = value.includes("'")
+ const hasDoubleQuote = value.includes('"')
+
+ // No quotes - use single quotes
+ if (!hasSingleQuote && !hasDoubleQuote) {
+ return `'${value}'`
+ }
+
+ // Only single quotes - use double quotes
+ if (hasSingleQuote && !hasDoubleQuote) {
+ return `"${value}"`
+ }
+
+ // Only double quotes - use single quotes
+ if (!hasSingleQuote && hasDoubleQuote) {
+ return `'${value}'`
+ }
+
+ // Both types of quotes - use concat()
+ // Split by single quote and wrap each part
+ const parts = value.split("'")
+ const concatParts = parts.map((part, index) => {
+ if (index === parts.length - 1) {
+ // Last part - no quote after
+ return `'${part}'`
+ } else {
+ // Add the single quote as a separate part
+ return `'${part}',"'"`
+ }
+ })
+
+ return `concat(${concatParts.join(",")})`
}
public transfConvertStar(xPath: XPath, element: Element): XPath[] {
@@ -199,7 +237,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='${this.escapeXPathValue(ancestor.id)}']`)
+ newXPath.addPredicateToHead(`[@id=${this.escapeXPathValue(ancestor.id)}]`)
output.push(newXPath)
}
}
@@ -218,7 +256,7 @@ export class RobulaPlus {
// 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)}']`)
+ newXPath.addPredicateToHead(`[@${attrName}=${this.escapeXPathValue(attrValue)}]`)
output.push(newXPath)
// Return immediately after finding the first data-test* attribute
break
@@ -239,7 +277,7 @@ export class RobulaPlus {
) {
const newXPath: XPath = new XPath(xPath.getValue())
newXPath.addPredicateToHead(
- `[contains(text(),'${this.escapeXPathValue(ancestor.textContent)}')]`,
+ `[contains(text(),${this.escapeXPathValue(ancestor.textContent)})]`,
)
output.push(newXPath)
}
@@ -259,7 +297,7 @@ export class RobulaPlus {
) {
const newXPath: XPath = new XPath(xPath.getValue())
newXPath.addPredicateToHead(
- `[@${attribute.name}='${this.escapeXPathValue(attribute.value)}']`,
+ `[@${attribute.name}=${this.escapeXPathValue(attribute.value)}]`,
)
output.push(newXPath)
break
@@ -274,7 +312,7 @@ export class RobulaPlus {
) {
const newXPath: XPath = new XPath(xPath.getValue())
newXPath.addPredicateToHead(
- `[@${attribute.name}='${this.escapeXPathValue(attribute.value)}']`,
+ `[@${attribute.name}=${this.escapeXPathValue(attribute.value)}]`,
)
output.push(newXPath)
}
@@ -330,9 +368,9 @@ export class RobulaPlus {
// convert to predicate
for (const attributeSet of attributePowerSet) {
- let predicate: string = `[@${attributeSet[0].name}='${this.escapeXPathValue(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}='${this.escapeXPathValue(attributeSet[i].value)}'`
+ predicate += ` and @${attributeSet[i].name}=${this.escapeXPathValue(attributeSet[i].value)}`
}
predicate += "]"
const newXPath: XPath = new XPath(xPath.getValue())
From 58284249d2915fb3c929940c6dedb61e63143a00 Mon Sep 17 00:00:00 2001
From: ujiro99
Date: Sat, 21 Feb 2026 13:38:56 +0900
Subject: [PATCH 06/12] Update: Refine the logic for recording input elements
in PageAction.
---
.../src/services/pageAction/background.ts | 12 ++
.../src/services/pageAction/dispatcher.ts | 35 +++++-
.../src/services/pageAction/listener.ts | 39 +++++++
packages/hub/src/app/[lang]/test/page.tsx | 106 +++++++++++++++++-
4 files changed, 187 insertions(+), 5 deletions(-)
diff --git a/packages/extension/src/services/pageAction/background.ts b/packages/extension/src/services/pageAction/background.ts
index 920abe54..7b159259 100644
--- a/packages/extension/src/services/pageAction/background.ts
+++ b/packages/extension/src/services/pageAction/background.ts
@@ -133,6 +133,18 @@ export const add = (
step.delayMs = DELAY_AFTER_URL_CHANGED
}
} else if (type === "input") {
+ // Remove preceding click for select elements; the input step is sufficient for replay
+ if (
+ prevType === "click" ||
+ prevType === "doubleClick" ||
+ prevType === "tripleClick"
+ ) {
+ const selector = (step.param as PageAction.Input).selector
+ const prevSelector = (prev.param as PageAction.Click).selector
+ if (selector === prevSelector) {
+ steps.pop()
+ }
+ }
// Combine operations on the same input element.
if (prevType === "input") {
const selector = (step.param as PageAction.Input).selector
diff --git a/packages/extension/src/services/pageAction/dispatcher.ts b/packages/extension/src/services/pageAction/dispatcher.ts
index cce0870b..4dd8ebc5 100644
--- a/packages/extension/src/services/pageAction/dispatcher.ts
+++ b/packages/extension/src/services/pageAction/dispatcher.ts
@@ -30,9 +30,9 @@ export namespace PageAction {
export type Click = {
type:
- | PAGE_ACTION_EVENT.click
- | PAGE_ACTION_EVENT.doubleClick
- | PAGE_ACTION_EVENT.tripleClick
+ | PAGE_ACTION_EVENT.click
+ | PAGE_ACTION_EVENT.doubleClick
+ | PAGE_ACTION_EVENT.tripleClick
label: string
selector: string
selectorType: SelectorType
@@ -225,8 +225,37 @@ export const PageActionDispatcher = {
}
let value = safeInterpolate(param.value, variables)
if (!isEmpty(value)) {
+ // For select elements: set value directly and dispatch change event
+ if (element instanceof HTMLSelectElement) {
+ element.value = value
+ element.dispatchEvent(new Event("change", { bubbles: true }))
+ return [true]
+ }
+
+ // For non-text input types: set value directly and dispatch events
+ if (
+ element instanceof HTMLInputElement &&
+ [
+ "range",
+ "color",
+ "date",
+ "datetime-local",
+ "month",
+ "week",
+ "time",
+ ].includes(element.type)
+ ) {
+ element.value = value
+ element.dispatchEvent(new Event("input", { bubbles: true }))
+ element.dispatchEvent(new Event("change", { bubbles: true }))
+ return [true]
+ }
+
if (!isEditable(element)) {
value = value.replace(/{/g, "{{") // escape
+ // Ensure focus before typing, since preceding click may have been
+ // removed by recording optimization in background.ts.
+ element.focus()
await user.type(element, value, { skipClick: true })
} else {
/*
diff --git a/packages/extension/src/services/pageAction/listener.ts b/packages/extension/src/services/pageAction/listener.ts
index 2cbe267e..cef8876d 100644
--- a/packages/extension/src/services/pageAction/listener.ts
+++ b/packages/extension/src/services/pageAction/listener.ts
@@ -84,6 +84,7 @@ const getLabel = (e: Element): string => {
let focusElm: HTMLElement | null = null
let focusXpath: string | null = null
let lastMouseDownTarget: HTMLElement | null = null
+let rawMouseDownTarget: HTMLElement | null = null
let lastInputTarget: HTMLElement | null = null
/**
@@ -160,6 +161,11 @@ export const PageActionListener = (() => {
const func: EventsFunctions = {
async click(e: MouseEvent) {
+ // Track raw mousedown target for label-triggered click detection
+ if (e.type === "mousedown") {
+ rawMouseDownTarget = e.target as HTMLElement
+ }
+
// Ignore click events that are triggered by mouse down event.
if (lastMouseDownTarget === e.target && e.type === "click") {
lastMouseDownTarget = null
@@ -175,6 +181,28 @@ export const PageActionListener = (() => {
const target = e.target as HTMLElement
if (isInputting(target)) return
+ // Skip click events on checkbox/radio triggered by a parent label element.
+ // When a user clicks inside a