mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-03 07:51:12 +03:00
fix(selectors): hasText and getByText exact match should consider full text (#18260)
Fixes #18259.
This commit is contained in:
parent
d4053abd29
commit
48c44f2c78
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
@ -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));
|
||||
@ -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' };
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
@ -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 }));
|
||||
@ -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];
|
||||
|
@ -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([
|
||||
|
@ -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>
|
||||
|
@ -129,13 +129,13 @@ 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 }) => {
|
||||
@ -143,7 +143,7 @@ it.describe('selector generator', () => {
|
||||
<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 }) => {
|
||||
@ -151,7 +151,7 @@ it.describe('selector generator', () => {
|
||||
<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 }) => {
|
||||
|
@ -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');
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user