diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 6e0fc548de..966ea15d7b 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -43,6 +43,7 @@ const kRoleWithNameScore = 140; const kAltTextScore = 160; const kTextScore = 180; const kTitleScore = 200; +const kTextScoreRegex = 250; const kPlaceholderScoreExact = kPlaceholderScore + kExactPenalty; const kLabelScoreExact = kLabelScore + kExactPenalty; const kRoleWithNameScoreExact = kRoleWithNameScore + kExactPenalty; @@ -268,11 +269,10 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i const candidates: SelectorToken[][] = []; const escaped = escapeForTextSelector(text, false); - const exactEscaped = escapeForTextSelector(text, true); if (isTargetNode) { candidates.push([{ engine: 'internal:text', selector: escaped, score: kTextScore }]); - candidates.push([{ engine: 'internal:text', selector: exactEscaped, score: kTextScoreExact }]); + candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(text, true), score: kTextScoreExact }]); } const ariaRole = getAriaRole(element); @@ -289,7 +289,8 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i candidate.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: kCSSTagNameScore }); } candidates.push([...candidate, { engine: 'internal:has-text', selector: escaped, score: kTextScore }]); - candidates.push([...candidate, { engine: 'internal:has-text', selector: exactEscaped, score: kTextScoreExact }]); + if (text.length <= 80) + candidates.push([...candidate, { engine: 'internal:has-text', selector: '/^' + escapeRegExp(text) + '$/', score: kTextScoreRegex }]); penalizeScoreForLength(candidates); return candidates; } @@ -467,3 +468,8 @@ function isGuidLike(id: string): boolean { } return transitionCount >= id.length / 4; } + +function escapeRegExp(s: string) { + // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index f1324b40f7..05e8047981 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -75,8 +75,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame } if (part.name === 'internal:has-text') { const { exact, text } = detectExact(part.body as string); - tokens.push(factory.generateLocator(base, 'has-text', text, { exact })); - continue; + // There is no locator equivalent for strict has-text, leave it as is. + if (!exact) { + tokens.push(factory.generateLocator(base, 'has-text', text, { exact })); + continue; + } } if (part.name === 'internal:has') { const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index e6e8047ff2..3d9f844c53 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -347,6 +347,8 @@ it.describe(() => { javascript: `locator('div').filter({ hasText: 'Goodbye world' }).locator('span')`, python: 'locator("div").filter(has_text="Goodbye world").locator("span")', }); + + expect.soft(asLocator('javascript', 'div >> internal:has-text="foo"s', false)).toBe(`locator('div').locator('internal:has-text="foo"s')`); }); }); diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 27a69285e3..4044d69899 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -162,6 +162,15 @@ it.describe('selector generator', () => { expect(await generate(page, 'a:has-text("Hello")')).toBe(`a >> internal:has-text="Hello world"i`); }); + it('should use internal:has-text with regexp', async ({ page }) => { + await page.setContent(` + Hello world +
Hello world
extra
+ Goodbye world + `); + expect(await generate(page, 'div div')).toBe(`div >> internal:has-text=/^Hello world$/`); + }); + it('should chain text after parent', async ({ page }) => { await page.setContent(`
Hello world