diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts
index 0db5dddec8..f7a030b301 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: `${element.nodeName.toLowerCase()}[alt=${quoteString(element.getAttribute('alt')!)}]`, score: 10 });
+ candidates.push({ engine: 'css', selector: `${CSS.escape(element.nodeName.toLowerCase())}[alt=${quoteString(element.getAttribute('alt')!)}]`, score: 10 });
if (element.hasAttribute('role'))
- candidates.push({ engine: 'css', selector: `${element.nodeName.toLocaleLowerCase()}[role=${quoteString(element.getAttribute('role')!)}]` , score: 50 });
+ candidates.push({ engine: 'css', selector: `${CSS.escape(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: `${element.nodeName.toLowerCase()}[name=${quoteString(element.getAttribute('name')!)}]`, score: 50 });
+ candidates.push({ engine: 'css', selector: `${CSS.escape(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: `${element.nodeName.toLowerCase()}[type=${quoteString(element.getAttribute('type')!)}]`, score: 50 });
+ candidates.push({ engine: 'css', selector: `${CSS.escape(element.nodeName.toLowerCase())}[type=${quoteString(element.getAttribute('type')!)}]`, score: 50 });
}
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName))
- candidates.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: 50 });
+ candidates.push({ engine: 'css', selector: CSS.escape(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: element.nodeName.toLocaleLowerCase(), score: 200 });
+ candidates.push({ engine: 'css', selector: CSS.escape(element.nodeName.toLowerCase()), score: 200 });
return candidates;
}
@@ -192,7 +192,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, a
candidates.push({ engine: 'text', selector: escaped, score: 10 });
if (allowHasText && escaped === text) {
- let prefix = element.nodeName.toLocaleLowerCase();
+ let prefix = element.nodeName.toLowerCase();
if (element.hasAttribute('role'))
prefix += `[role=${quoteString(element.getAttribute('role')!)}]`;
candidates.push({ engine: 'css', selector: `${prefix}:has-text("${text}")`, score: 30 });
@@ -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="${id}"]`;
+ return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${CSS.escape(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 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`;
+ const token = sameTagSiblings.indexOf(element) === 0 ? CSS.escape(nodeName) : `${CSS.escape(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 `"${text.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
+ return `"${CSS.escape(text)}"`;
}
function joinTokens(tokens: SelectorToken[]): string {
diff --git a/tests/selector-generator.spec.ts b/tests/selector-generator.spec.ts
index 8f44da4277..2ca956144f 100644
--- a/tests/selector-generator.spec.ts
+++ b/tests/selector-generator.spec.ts
@@ -277,8 +277,19 @@ it.describe('selector generator', () => {
}
});
- it('should work with tricky ids', async ({ page }) => {
+ it('should work with tricky attributes', async ({ page }) => {
await page.setContent(``);
- expect(await generate(page, 'button')).toBe('[id="this:is-my-tricky.id"]');
+ expect(await generate(page, 'button')).toBe('[id="this\\:is-my-tricky\\.id"]');
+
+ await page.setContent(`