Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion packages/js-core/src/lib/common/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,7 @@ describe("utils.ts", () => {
targetElement.className = "other";

targetElement.matches = vi.fn(() => false);
targetElement.closest = vi.fn(() => null); // no ancestor matches either

const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
Expand Down Expand Up @@ -993,13 +994,93 @@ describe("utils.ts", () => {
expect(result).toBe(true);
});

// --- Regression tests for nested child click target (issue #7314) ---
// In this test environment document.createElement() returns a plain mock object,
// so we set .matches and .closest as vi.fn() — the same pattern used by existing tests.
// This exercises the exact code path of the fix: matches() fails → closest() succeeds.

test("returns true when clicking a child element inside a button matched by cssSelector", () => {
const button = document.createElement("button");
const icon = document.createElement("span");

// Simulate: icon does NOT directly match ".my-btn", but its closest ancestor does
(icon as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => false);
(icon as unknown as { closest: ReturnType<typeof vi.fn> }).closest = vi.fn(() => button);

const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector: ".my-btn" },
},
};

// Before fix: matches() → false → returns false (bug)
// After fix: matches() → false → closest() → button → returns true (correct)
const result = evaluateNoCodeConfigClick(icon as unknown as HTMLElement, action);
expect(result).toBe(true);
});

test("returns false when clicking a child element with no matching ancestor", () => {
const other = document.createElement("div");

// Simulate: element doesn't match, and no ancestor matches either
(other as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => false);
(other as unknown as { closest: ReturnType<typeof vi.fn> }).closest = vi.fn(() => null);

const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector: ".my-btn" },
},
};

const result = evaluateNoCodeConfigClick(other as unknown as HTMLElement, action);
expect(result).toBe(false);
});

test("uses direct target (not closest) when target directly matches cssSelector", () => {
const button = document.createElement("button");

// Simulate: click on the button itself — matches() succeeds, closest() should NOT be called
(button as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => true);
const closestSpy = vi.fn();
(button as unknown as { closest: ReturnType<typeof vi.fn> }).closest = closestSpy;

const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector: ".my-btn" },
},
};

const result = evaluateNoCodeConfigClick(button as unknown as HTMLElement, action);
expect(result).toBe(true);
expect(closestSpy).not.toHaveBeenCalled(); // closest() is only a fallback
});

test("handles multiple cssSelectors correctly", () => {
const targetElement = document.createElement("div");
targetElement.className = "test other";

targetElement.matches = vi.fn((selector) => {
return selector === ".test" || selector === ".other";
return selector === ".test" || selector === ".other" || selector === ".test .other";
});
targetElement.closest = vi.fn(() => null); // not needed but consistent with mock environment

const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
Expand Down
26 changes: 17 additions & 9 deletions packages/js-core/src/lib/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,20 +304,28 @@ export const evaluateNoCodeConfigClick = (

if (!innerHtml && !cssSelector) return false;

if (innerHtml && targetElement.innerHTML !== innerHtml) return false;
// Resolve the element to test: prefer the direct click target, but walk up to
// the nearest ancestor that matches the CSS selector (event delegation for nested markup,
// e.g. <svg> or <span> inside a <button class="my-btn">).
let matchedElement: HTMLElement = targetElement;

if (cssSelector) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelector
.split(/(?=[.#])/) // split before each . or #
.map((sel) => sel.trim()); // remove leftover whitespace
for (const selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return false;
}
let matchesDirectly = false;
try {
matchesDirectly = targetElement.matches(cssSelector);
} catch {
matchesDirectly = false;
}
if (!matchesDirectly) {
const ancestor = targetElement.closest(cssSelector);
if (!ancestor) return false;
matchedElement = ancestor as HTMLElement;
}
}

// Check innerHtml against the resolved element, not the raw click target
if (innerHtml && matchedElement.innerHTML !== innerHtml) return false;

const connector = action.noCodeConfig.urlFiltersConnector ?? "or";
const isValidUrl = handleUrlFilters(urlFilters, connector);

Expand Down
Loading