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