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