diff --git a/packages/extension/src/action/pageAction.ts b/packages/extension/src/action/pageAction.ts
index 63339347..e8f83795 100644
--- a/packages/extension/src/action/pageAction.ts
+++ b/packages/extension/src/action/pageAction.ts
@@ -1,10 +1,11 @@
import { Ipc, BgCommand } from "@/services/ipc"
import { getScreenSize, getWindowPosition } from "@/services/screen"
import { isValidString, isPageActionCommand } from "@/lib/utils"
-import { PAGE_ACTION_OPEN_MODE } from "@/const"
+import { PAGE_ACTION_OPEN_MODE, PAGE_ACTION_EVENT } from "@/const"
import { PopupOption } from "@/services/option/defaultSettings"
import type { ExecuteCommandParams, UrlParam } from "@/types"
import type { OpenAndRunProps } from "@/services/pageAction/background"
+import { INSERT, InsertSymbol } from "@/services/pageAction"
type PageActionParams = {
userVariables?: Array<{ name: string; value: string }>
@@ -33,10 +34,18 @@ export const PageAction = {
return
}
+ // Checks if any step requires clipboard data
+ const needClipboard = command.pageActionOption.steps.some((step) => {
+ return (
+ step.param.type === PAGE_ACTION_EVENT.input &&
+ step.param.value.includes(InsertSymbol[INSERT.CLIPBOARD])
+ )
+ })
+
const url: UrlParam = {
searchUrl: command.pageActionOption.startUrl,
selectionText,
- useClipboard: useClipboard ?? false,
+ useClipboard: needClipboard || (useClipboard ?? false),
}
const openMode = useSecondary
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..ddb8f219
--- /dev/null
+++ b/packages/extension/src/lib/robula-plus/index.test.ts
@@ -0,0 +1,307 @@
+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 not use aria-label even when it's the only attribute distinguishing sibling elements", () => {
+ 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 even though it's the only distinguishing attribute
+ expect(xpath1).not.toContain("aria-label")
+ expect(xpath2).not.toContain("aria-label")
+
+ // The XPaths should still uniquely identify each button (using position)
+ 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)
+ })
+ })
+
+ 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 3f4c1eab..59a8f0df 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
+ public static readonly DATA_TESTID_ATTRIBUTES = [
+ "data-testid",
+ "data-test-id",
+ "data-test",
+ ] as const
+
private attributePriorizationList: string[] = [
+ // DATA_TESTID_ATTRIBUTES are excluded here; handled exclusively by transfAddDataTestId
"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,56 @@ export class RobulaPlus {
}
}
+ /**
+ * 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 "''"
+ }
+
+ 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[] {
const output: XPath[] = []
const ancestor: Element = this.getAncestor(element, xPath.getLength() - 1)
@@ -177,13 +237,39 @@ 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 +281,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,22 +301,26 @@ 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
}
}
}
- // append all other non-blacklist and non-random attributes to output
+ // append all other non-blacklist, non-priority, non-data-testid attributes to output
+ // (DATA_TESTID_ATTRIBUTES are handled exclusively by transfAddDataTestId)
for (const attribute of ancestor.attributes) {
if (
!this.attributePriorizationList.includes(attribute.name) &&
+ !RobulaPlus.DATA_TESTID_ATTRIBUTES.includes(
+ attribute.name as (typeof RobulaPlus.DATA_TESTID_ATTRIBUTES)[number],
+ ) &&
this.isAttributeUsable(attribute)
) {
const newXPath: XPath = new XPath(xPath.getValue())
newXPath.addPredicateToHead(
- `[@${attribute.name}='${attribute.value}']`,
+ `[@${attribute.name}=${this.escapeXPathValue(attribute.value)}]`,
)
output.push(newXPath)
}
@@ -286,9 +376,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 +665,7 @@ export class RobulaPlusOptions {
*/
public attributePriorizationList: string[] = [
+ // DATA_TESTID_ATTRIBUTES are excluded here; handled exclusively by transfAddDataTestId
"name",
"class",
"title",
@@ -592,6 +683,7 @@ export class RobulaPlusOptions {
"size",
"maxlength",
"value",
+ "aria-label",
]
public avoidRandomPatterns?: boolean
public randomPatterns?: RegExp[]
diff --git a/packages/extension/src/services/dom.ts b/packages/extension/src/services/dom.ts
index 5687b164..d84c39ea 100644
--- a/packages/extension/src/services/dom.ts
+++ b/packages/extension/src/services/dom.ts
@@ -7,7 +7,7 @@ export function toDataURL(src: string, outputFormat?: string): Promise {
const img = new Image()
img.crossOrigin = "Anonymous"
const id = setTimeout(() => reject(`toDataURL timeout: ${src}`), 1000)
- img.onload = function () {
+ img.onload = function() {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
canvas.height = img.naturalHeight
diff --git a/packages/extension/src/services/pageAction/background-crud.test.ts b/packages/extension/src/services/pageAction/background-crud.test.ts
index 16520838..a1bf6158 100644
--- a/packages/extension/src/services/pageAction/background-crud.test.ts
+++ b/packages/extension/src/services/pageAction/background-crud.test.ts
@@ -692,6 +692,156 @@ describe("background.ts - CRUD Operations", () => {
const savedData = setCall![1]
expect(savedData.steps[2].param.value).toBe("bbb")
})
+
+ it("BGD-15-f: Integration: Previous click is removed when click → input on same element", async () => {
+ const existingStep = {
+ id: "click-1",
+ param: { type: "click", selector: ".test-input", label: "Click" },
+ }
+ const newStep = {
+ id: "input-1",
+ param: {
+ type: "input",
+ selector: ".test-input",
+ label: "Input",
+ value: "hello",
+ },
+ }
+
+ const mockRecordingData = { steps: [existingStep] }
+ mockStorage.get.mockImplementation((key: string) => {
+ if (key === "pa_recording") return Promise.resolve(mockRecordingData)
+ return Promise.resolve({ urlChanged: false })
+ })
+
+ add(newStep as any, mockSender as any, mockResponse)
+ await vi.runAllTimersAsync()
+
+ const setCall = mockStorage.set.mock.calls.find(
+ (call: any[]) => call[0] === "pa_recording",
+ )
+ const savedData = setCall![1]
+ // click is removed; only input + End remain
+ expect(savedData.steps).toHaveLength(2)
+ expect(savedData.steps[0]).toBe(newStep)
+ expect(savedData.steps[1].param.type).toBe("end")
+ })
+
+ it("BGD-15-g: Integration: Previous doubleClick is removed when doubleClick → input on same element", async () => {
+ const existingStep = {
+ id: "double-1",
+ param: {
+ type: "doubleClick",
+ selector: ".test-input",
+ label: "Double Click",
+ },
+ }
+ const newStep = {
+ id: "input-1",
+ param: {
+ type: "input",
+ selector: ".test-input",
+ label: "Input",
+ value: "hello",
+ },
+ }
+
+ const mockRecordingData = { steps: [existingStep] }
+ mockStorage.get.mockImplementation((key: string) => {
+ if (key === "pa_recording") return Promise.resolve(mockRecordingData)
+ return Promise.resolve({ urlChanged: false })
+ })
+
+ add(newStep as any, mockSender as any, mockResponse)
+ await vi.runAllTimersAsync()
+
+ const setCall = mockStorage.set.mock.calls.find(
+ (call: any[]) => call[0] === "pa_recording",
+ )
+ const savedData = setCall![1]
+ // doubleClick is removed; only input + End remain
+ expect(savedData.steps).toHaveLength(2)
+ expect(savedData.steps[0]).toBe(newStep)
+ expect(savedData.steps[1].param.type).toBe("end")
+ })
+
+ it("BGD-15-h: Integration: Previous tripleClick is not removed", async () => {
+ const existingStep = {
+ id: "triple-1",
+ param: {
+ type: "tripleClick",
+ selector: ".test-input",
+ label: "Triple Click",
+ },
+ }
+ const newStep = {
+ id: "input-1",
+ param: {
+ type: "input",
+ selector: ".test-input",
+ label: "Input",
+ value: "hello",
+ },
+ }
+
+ const mockRecordingData = { steps: [existingStep] }
+ mockStorage.get.mockImplementation((key: string) => {
+ if (key === "pa_recording") return Promise.resolve(mockRecordingData)
+ return Promise.resolve({ urlChanged: false })
+ })
+
+ add(newStep as any, mockSender as any, mockResponse)
+ await vi.runAllTimersAsync()
+
+ const setCall = mockStorage.set.mock.calls.find(
+ (call: any[]) => call[0] === "pa_recording",
+ )
+ const savedData = setCall![1]
+ // tripleClick is not removed;
+ expect(savedData.steps).toHaveLength(3)
+ expect(savedData.steps[0]).toBe(existingStep)
+ expect(savedData.steps[1]).toBe(newStep)
+ expect(savedData.steps[2].param.type).toBe("end")
+ })
+
+ it("BGD-15-i: Boundary: Previous click is NOT removed when click → input on different element", async () => {
+ const existingStep = {
+ id: "click-1",
+ param: {
+ type: "click",
+ selector: ".other-element",
+ label: "Click",
+ },
+ }
+ const newStep = {
+ id: "input-1",
+ param: {
+ type: "input",
+ selector: ".test-input",
+ label: "Input",
+ value: "hello",
+ },
+ }
+
+ const mockRecordingData = { steps: [existingStep] }
+ mockStorage.get.mockImplementation((key: string) => {
+ if (key === "pa_recording") return Promise.resolve(mockRecordingData)
+ return Promise.resolve({ urlChanged: false })
+ })
+
+ add(newStep as any, mockSender as any, mockResponse)
+ await vi.runAllTimersAsync()
+
+ const setCall = mockStorage.set.mock.calls.find(
+ (call: any[]) => call[0] === "pa_recording",
+ )
+ const savedData = setCall![1]
+ // click is preserved because selectors differ; click + input + End
+ expect(savedData.steps).toHaveLength(3)
+ expect(savedData.steps[0]).toBe(existingStep)
+ expect(savedData.steps[1]).toBe(newStep)
+ expect(savedData.steps[2].param.type).toBe("end")
+ })
})
describe("Error handling", () => {
diff --git a/packages/extension/src/services/pageAction/background.ts b/packages/extension/src/services/pageAction/background.ts
index 920abe54..45e09f05 100644
--- a/packages/extension/src/services/pageAction/background.ts
+++ b/packages/extension/src/services/pageAction/background.ts
@@ -133,6 +133,16 @@ export const add = (
step.delayMs = DELAY_AFTER_URL_CHANGED
}
} else if (type === "input") {
+ // Remove preceding click on the same element when an input step follows;
+ // the input step is sufficient for replay (the dispatcher applies focus if needed).
+ // * Don't remove if the click is a tripleClick, as they may indicate special interactions (e.g. select all text).
+ if (prevType === "click" || prevType === "doubleClick") {
+ 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..2fbe3913 100644
--- a/packages/extension/src/services/pageAction/dispatcher.ts
+++ b/packages/extension/src/services/pageAction/dispatcher.ts
@@ -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..a47dfdb7 100644
--- a/packages/extension/src/services/pageAction/listener.ts
+++ b/packages/extension/src/services/pageAction/listener.ts
@@ -73,6 +73,12 @@ const getLabel = (e: Element): string => {
e.parentElement?.dataset.placeholder ||
e.ariaLabel ||
e.innerText
+ } else if (e instanceof HTMLElement && isEditable(e)) {
+ label =
+ e.dataset.placeholder ||
+ e.parentElement?.dataset.placeholder ||
+ e.ariaLabel ||
+ e.innerText
} else if (e instanceof HTMLElement) {
label = !isEmpty(e.ariaLabel) ? e.ariaLabel || "" : e.innerText
} else {
@@ -84,8 +90,27 @@ 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
+/**
+ * XPath locked at the time of the first `input` event on a contenteditable
+ * element (e.g. Quill editor). Some WYSIWYG editors mutate the element's
+ * class names when editing begins — for example, Quill adds `ql-blank` to the
+ * editor div while it is empty and removes it on the first keystroke. Because
+ * RobulaPlus includes class names in generated XPaths, consecutive `input`
+ * events on the very same DOM element can produce different XPath strings,
+ * which prevents background.ts from recognising them as the same element and
+ * combining them into a single step.
+ *
+ * To solve this we compute the XPath once (on the first `input` event, after
+ * the DOM has already been updated) and reuse it for every subsequent event on
+ * the same focused element. The lock is cleared whenever focus moves to a
+ * different element so that unrelated editable elements get their own fresh
+ * XPath.
+ */
+let lockedInputSelector: string | null = null
+
/**
* Get the robust XPath of an element.
* @param {Element} e - The element to get the XPath of.
@@ -153,13 +178,24 @@ interface EventsFunctions {
export const PageActionListener = (() => {
const onFocusIn = (event: FocusEvent) => {
- focusElm = event.target as HTMLElement
+ const newFocusElm = event.target as HTMLElement
+ // Clear the locked selector whenever focus moves to a different element so
+ // that the next editable element gets its own fresh XPath.
+ if (focusElm !== newFocusElm) {
+ lockedInputSelector = null
+ }
+ focusElm = newFocusElm
if (isPopup(focusElm)) return
focusXpath = getXPathM(focusElm)
}
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 +211,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