From e7acd0cf8903da240abaf172cef01feb88f16ded Mon Sep 17 00:00:00 2001 From: Zack Voase Date: Tue, 30 Jul 2024 03:08:41 -0400 Subject: [PATCH] fix(browser): Improve unique CSS selector generation (#6243) Co-authored-by: Zack Voase --- packages/browser/src/client/tester/context.ts | 40 ++++++++++++++++--- test/browser/test/userEvent.test.ts | 21 ++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 368f1e61a009..54bf7847044b 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -32,6 +32,36 @@ function convertElementToCssSelector(element: Element) { return getUniqueCssSelector(element) } +function escapeIdForCSSSelector(id: string) { + return id + .split('') + .map((char) => { + const code = char.charCodeAt(0) + + if (char === ' ' || char === '#' || char === '.' || char === ':' || char === '[' || char === ']' || char === '>' || char === '+' || char === '~' || char === '\\') { + // Escape common special characters with backslashes + return `\\${char}` + } + else if (code >= 0x10000) { + // Unicode escape for characters outside the BMP + return `\\${code.toString(16).toUpperCase().padStart(6, '0')} ` + } + else if (code < 0x20 || code === 0x7F) { + // Non-printable ASCII characters (0x00-0x1F and 0x7F) are escaped + return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` + } + else if (code >= 0x80) { + // Non-ASCII characters (0x80 and above) are escaped + return `\\${code.toString(16).toUpperCase().padStart(2, '0')} ` + } + else { + // Allowable characters are used directly + return char + } + }) + .join('') +} + function getUniqueCssSelector(el: Element) { const path = [] let parent: null | ParentNode @@ -44,10 +74,10 @@ function getUniqueCssSelector(el: Element) { const tag = el.tagName if (el.id) { - path.push(`#${el.id}`) + path.push(`#${escapeIdForCSSSelector(el.id)}`) } else if (!el.nextElementSibling && !el.previousElementSibling) { - path.push(tag) + path.push(tag.toLowerCase()) } else { let index = 0 @@ -65,15 +95,15 @@ function getUniqueCssSelector(el: Element) { } if (sameTagSiblings > 1) { - path.push(`${tag}:nth-child(${elementIndex})`) + path.push(`${tag.toLowerCase()}:nth-child(${elementIndex})`) } else { - path.push(tag) + path.push(tag.toLowerCase()) } } el = parent as Element }; - return `${provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}`.toLowerCase() + return `${provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}` } function getParent(el: Element) { diff --git a/test/browser/test/userEvent.test.ts b/test/browser/test/userEvent.test.ts index 27ad21be30f1..c69c890812e6 100644 --- a/test/browser/test/userEvent.test.ts +++ b/test/browser/test/userEvent.test.ts @@ -70,6 +70,27 @@ describe('userEvent.click', () => { expect(onClick).toHaveBeenCalled() }) + + test('clicks a button with complex HTML ID', async () => { + const container = document.createElement('div') + // This is similar to unique IDs generated by React's useId() + container.id = ':r3:' + const button = document.createElement('button') + // Use uppercase and special characters + button.id = 'A:Button' + button.textContent = 'Click me' + container.appendChild(button) + document.body.appendChild(container) + + const onClick = vi.fn() + const dblClick = vi.fn() + button.addEventListener('click', onClick) + + await userEvent.click(button) + + expect(onClick).toHaveBeenCalled() + expect(dblClick).not.toHaveBeenCalled() + }) }) describe('userEvent.dblClick', () => {