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
6 changes: 2 additions & 4 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,8 @@ export class Locator implements api.Locator {
this._frame = frame;
this._selector = selector;

if (options?.hasText) {
const textSelector = 'internal:text=' + escapeForTextSelector(options.hasText, false);
this._selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
}
if (options?.hasText)
this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;

if (options?.has) {
const locator = options.has;
Expand Down
6 changes: 2 additions & 4 deletions packages/playwright-core/src/server/injected/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?

constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
this.selector = selector;
if (options?.hasText) {
const textSelector = 'internal:text=' + escapeForTextSelector(options.hasText, false);
this.selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
}
if (options?.hasText)
this.selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
if (options?.has)
this.selector += ` >> internal:has=` + JSON.stringify(options.has.selector);
const parsed = injectedScript.parseSelector(this.selector);
Expand Down
25 changes: 20 additions & 5 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class InjectedScript {
this._engines.set('internal:has', this._createHasEngine());
this._engines.set('internal:label', this._createInternalLabelEngine());
this._engines.set('internal:text', this._createTextEngine(true, true));
this._engines.set('internal:has-text', this._createInternalHasTextEngine());
this._engines.set('internal:attr', this._createNamedAttributeEngine());
this._engines.set('internal:role', RoleEngine);

Expand Down Expand Up @@ -250,7 +251,7 @@ export class InjectedScript {

private _createTextEngine(shadow: boolean, internal: boolean): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string): Element[] => {
const { matcher, kind } = createTextMatcher(selector, false, internal);
const { matcher, kind } = createTextMatcher(selector, internal);
const result: Element[] = [];
let lastDidNotMatchSelf: Element | null = null;

Expand All @@ -261,7 +262,7 @@ export class InjectedScript {
const matches = elementMatchesText(this._evaluator._cacheText, element, matcher);
if (matches === 'none')
lastDidNotMatchSelf = element;
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict'))
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict' && !internal))
result.push(element);
};

Expand All @@ -280,11 +281,25 @@ export class InjectedScript {
};
}

private _createInternalHasTextEngine(): SelectorEngine {
const evaluator = this._evaluator;
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
const element = root as Element;
const text = elementText(evaluator._cacheText, element);
const { matcher } = createTextMatcher(selector, true);
return matcher(text) ? [element] : [];
}
};
}

private _createInternalLabelEngine(): SelectorEngine {
const evaluator = this._evaluator;
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
const { matcher } = createTextMatcher(selector, true, true);
const { matcher } = createTextMatcher(selector, true);
const result: Element[] = [];
const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[];
for (const label of labels) {
Expand Down Expand Up @@ -1302,7 +1317,7 @@ function cssUnquote(s: string): string {
return r.join('');
}

function createTextMatcher(selector: string, strictMatchesFullText: boolean, internal: boolean): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
function createTextMatcher(selector: string, internal: boolean): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
const lastSlash = selector.lastIndexOf('/');
const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
Expand All @@ -1324,7 +1339,7 @@ function createTextMatcher(selector: string, strictMatchesFullText: boolean, int
strict = true;
}
if (strict)
return { matcher: strictMatchesFullText ? createStrictFullTextMatcher(selector) : createStrictTextMatcher(selector), kind: 'strict' };
return { matcher: internal ? createStrictFullTextMatcher(selector) : createStrictTextMatcher(selector), kind: 'strict' };
return { matcher: createLaxTextMatcher(selector), kind: 'lax' };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
} else {
candidate.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: 10 });
}
candidate.push({ engine: 'internal:has', selector: JSON.stringify('internal:text=' + escaped), score: 0 });
candidate.push({ engine: 'internal:has-text', selector: escaped, score: 0 });
candidates.push(candidate);
return candidates;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils';
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
import type { NestedSelectorBody } from '../isomorphic/selectorParser';
import type { ParsedSelector } from '../isomorphic/selectorParser';

export type Language = 'javascript' | 'python' | 'java' | 'csharp';
Expand Down Expand Up @@ -50,6 +49,11 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
tokens.push(factory.generateLocator(base, 'text', text, { exact }));
continue;
}
if (part.name === 'internal:has-text') {
const { exact, text } = detectExact(part.body as string);
tokens.push(factory.generateLocator(base, 'has-text', text, { exact }));
continue;
}
if (part.name === 'internal:label') {
const { exact, text } = detectExact(part.body as string);
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
Expand All @@ -63,15 +67,6 @@ function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocato
tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs }));
continue;
}
if (part.name === 'internal:has') {
const nested = (part.body as NestedSelectorBody).parsed;
if (nested?.parts?.[0]?.name === 'internal:text') {
const result = detectExact(nested.parts[0].body as string);
tokens.push(factory.generateLocator(base, 'has-text', result.text, { exact: result.exact }));
continue;
}
}

if (part.name === 'internal:attr') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value, caseSensitive } = attrSelector.attributes[0];
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class Selectors {
'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light',
'nth', 'visible', 'internal:control', 'internal:has',
'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text',
'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role',
]);
this._builtinEnginesInMainWorld = new Set([
Expand Down
2 changes: 1 addition & 1 deletion tests/library/locator-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ it.describe('selector generator', () => {
await (context as any)._enableRecorder({ language: 'javascript' });
});

it('reverse engineer internal:has locators', async ({ page }) => {
it('reverse engineer internal:has-text locators', async ({ page }) => {
await page.setContent(`
<div>Hello world</div>
<a>Hello <span>world</span></a>
Expand Down
8 changes: 4 additions & 4 deletions tests/library/selector-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,29 +129,29 @@ it.describe('selector generator', () => {
expect(await generate(page, 'div[mark="1"]')).toBe(`div >> nth=1`);
});

it('should use internal:has', async ({ page }) => {
it('should use internal:has-text', async ({ page }) => {
await page.setContent(`
<div>Hello world</div>
<a>Hello <span>world</span></a>
<a>Goodbye <span>world</span></a>
`);
expect(await generate(page, 'a:has-text("Hello")')).toBe(`a >> internal:has=\"internal:text=\\\"Hello world\\\"i\"`);
expect(await generate(page, 'a:has-text("Hello")')).toBe(`a >> internal:has-text="Hello world"i`);
});

it('should chain text after parent', async ({ page }) => {
await page.setContent(`
<div>Hello <span>world</span></div>
<b>Hello <span mark=1>world</span></b>
`);
expect(await generate(page, '[mark="1"]')).toBe(`b >> internal:has=\"internal:text=\\\"Hello world\\\"i\" >> span`);
expect(await generate(page, '[mark="1"]')).toBe(`b >> internal:has-text="Hello world"i >> span`);
});

it('should use parent text', async ({ page }) => {
await page.setContent(`
<div>Hello <span>world</span></div>
<div>Goodbye <span mark=1>world</span></div>
`);
expect(await generate(page, '[mark="1"]')).toBe(`div >> internal:has=\"internal:text=\\\"Goodbye world\\\"i\" >> span`);
expect(await generate(page, '[mark="1"]')).toBe(`div >> internal:has-text="Goodbye world"i >> span`);
});

it('should separate selectors by >>', async ({ page }) => {
Expand Down
20 changes: 20 additions & 0 deletions tests/page/selectors-text.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,3 +460,23 @@ it('should work with paired quotes in the middle of selector', async ({ page })
// Should double escape inside quoted text.
await expect(page.locator(`div >> text='pattern "^-?\\\\d+$"'`)).toBeVisible();
});

it('hasText and internal:text should match full node text in strict mode', async ({ page }) => {
await page.setContent(`
<div id=div1>hello<span>world</span></div>
<div id=div2>hello</div>
`);
await expect(page.getByText('helloworld', { exact: true })).toHaveId('div1');
await expect(page.getByText('hello', { exact: true })).toHaveId('div2');
await expect(page.locator('div', { hasText: /^helloworld$/ })).toHaveId('div1');
await expect(page.locator('div', { hasText: /^hello$/ })).toHaveId('div2');

await page.setContent(`
<div id=div1><span id=span1>hello</span>world</div>
<div id=div2><span id=span2>hello</span></div>
`);
await expect(page.getByText('helloworld', { exact: true })).toHaveId('div1');
expect(await page.getByText('hello', { exact: true }).evaluateAll(els => els.map(e => e.id))).toEqual(['span1', 'span2']);
await expect(page.locator('div', { hasText: /^helloworld$/ })).toHaveId('div1');
await expect(page.locator('div', { hasText: /^hello$/ })).toHaveId('div2');
});