From 1dbbcd1b372ec4006bde2c28bcc319dcab47c807 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 4 Jan 2022 17:24:53 -0800 Subject: [PATCH] fix(codegen): polyfill CSS.escape (#11181) Some site (for example, fb.com) delete CSS.escape, so we should use the polyfill. --- .../src/server/injected/selectorGenerator.ts | 41 +++++++++++++++---- tests/selector-generator.spec.ts | 9 ++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index f7a030b301..39e44a3c34 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -155,26 +155,26 @@ function buildCandidates(injectedScript: InjectedScript, element: Element): Sele if (element.hasAttribute('aria-label')) candidates.push({ engine: 'css', selector: `[aria-label=${quoteString(element.getAttribute('aria-label')!)}]`, score: 10 }); if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) - candidates.push({ engine: 'css', selector: `${CSS.escape(element.nodeName.toLowerCase())}[alt=${quoteString(element.getAttribute('alt')!)}]`, score: 10 }); + candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[alt=${quoteString(element.getAttribute('alt')!)}]`, score: 10 }); if (element.hasAttribute('role')) - candidates.push({ engine: 'css', selector: `${CSS.escape(element.nodeName.toLowerCase())}[role=${quoteString(element.getAttribute('role')!)}]` , score: 50 }); + candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[role=${quoteString(element.getAttribute('role')!)}]` , score: 50 }); if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName)) - candidates.push({ engine: 'css', selector: `${CSS.escape(element.nodeName.toLowerCase())}[name=${quoteString(element.getAttribute('name')!)}]`, score: 50 }); + candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteString(element.getAttribute('name')!)}]`, score: 50 }); if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') { if (element.getAttribute('type')) - candidates.push({ engine: 'css', selector: `${CSS.escape(element.nodeName.toLowerCase())}[type=${quoteString(element.getAttribute('type')!)}]`, score: 50 }); + candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[type=${quoteString(element.getAttribute('type')!)}]`, score: 50 }); } if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName)) - candidates.push({ engine: 'css', selector: CSS.escape(element.nodeName.toLowerCase()), score: 50 }); + candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: 50 }); const idAttr = element.getAttribute('id'); if (idAttr && !isGuidLike(idAttr)) candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: 100 }); - candidates.push({ engine: 'css', selector: CSS.escape(element.nodeName.toLowerCase()), score: 200 }); + candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: 200 }); return candidates; } @@ -211,7 +211,7 @@ function parentElementOrShadowHost(element: Element): Element | null { } function makeSelectorForId(id: string) { - return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${CSS.escape(id)}"]`; + return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${cssEscape(id)}"]`; } function cssFallback(injectedScript: InjectedScript, targetElement: Element): SelectorToken { @@ -263,7 +263,7 @@ function cssFallback(injectedScript: InjectedScript, targetElement: Element): Se if (parent) { const siblings = [...parent.children]; const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName); - const token = sameTagSiblings.indexOf(element) === 0 ? CSS.escape(nodeName) : `${CSS.escape(nodeName)}:nth-child(${1 + siblings.indexOf(element)})`; + const token = sameTagSiblings.indexOf(element) === 0 ? cssEscape(nodeName) : `${cssEscape(nodeName)}:nth-child(${1 + siblings.indexOf(element)})`; const selector = uniqueCSSSelector(token); if (selector) return { engine: 'css', selector, score: kFallbackScore }; @@ -282,7 +282,7 @@ function escapeForRegex(text: string): string { } function quoteString(text: string): string { - return `"${CSS.escape(text)}"`; + return `"${cssEscape(text)}"`; } function joinTokens(tokens: SelectorToken[]): string { @@ -367,3 +367,26 @@ function isGuidLike(id: string): boolean { } return transitionCount >= id.length / 4; } + +function cssEscape(s: string): string { + let result = ''; + for (let i = 0; i < s.length; i++) + result += cssEscapeOne(s, i); + return result; +} + +function cssEscapeOne(s: string, i: number): string { + // https://drafts.csswg.org/cssom/#serialize-an-identifier + const c = s.charCodeAt(i); + if (c === 0x0000) + return '\uFFFD'; + if ((c >= 0x0001 && c <= 0x001f) || + (c >= 0x0030 && c <= 0x0039 && (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d)))) + return '\\' + c.toString(16) + ' '; + if (i === 0 && c === 0x002d && s.length === 1) + return '\\' + s.charAt(i); + if (c >= 0x0080 || c === 0x002d || c === 0x005f || (c >= 0x0030 && c <= 0x0039) || + (c >= 0x0041 && c <= 0x005a) || (c >= 0x0061 && c <= 0x007a)) + return s.charAt(i); + return '\\' + s.charAt(i); +} diff --git a/tests/selector-generator.spec.ts b/tests/selector-generator.spec.ts index 2ca956144f..7c5da574ac 100644 --- a/tests/selector-generator.spec.ts +++ b/tests/selector-generator.spec.ts @@ -292,4 +292,13 @@ it.describe('selector generator', () => { await page.$eval('div', div => div.id = `!#'!?:`); expect(await generate(page, 'div')).toBe("[id=\"\\!\\#\\'\\!\\?\\:\"]"); }); + + it('should work without CSS.escape', async ({ page }) => { + await page.setContent(``); + await page.$eval('button', button => { + delete window.CSS.escape; + button.setAttribute('name', '-tricky\u0001name'); + }); + expect(await generate(page, 'button')).toBe(`button[name="-tricky\\1 name"]`); + }); });