chore: internal selectors (#17827)

- Rename internal selectors `has`, `control` and `attr` to
`internal:has`, `internal:control` and `internal:attr`.
- Fix `getByLabel()` to respect strictness, by introducing
`internal:label` selector.
- Move tests essential for ports to `selectors-by.spec`.
This commit is contained in:
Dmitry Gozman 2022-10-05 08:45:10 -07:00 committed by GitHub
parent f64dbe9ece
commit 2bcd9ce9ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 170 additions and 131 deletions

View File

@ -57,12 +57,12 @@ export class Locator implements api.Locator {
private static getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string {
if (!isString(text))
return `attr=[${attrName}=${text}]`;
return `attr=[${attrName}=${escapeForAttributeSelector(text)}${options?.exact ? 's' : 'i'}]`;
return `internal:attr=[${attrName}=${text}]`;
return `internal:attr=[${attrName}=${escapeForAttributeSelector(text)}${options?.exact ? 's' : 'i'}]`;
}
static getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string {
return Locator.getByTextSelector(text, options) + ' >> control=resolve-label';
return 'internal:label=' + escapeForTextSelector(text, !!options?.exact);
}
static getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string {
@ -108,14 +108,14 @@ export class Locator implements api.Locator {
if (options?.hasText) {
const textSelector = 'text=' + escapeForTextSelector(options.hasText, false);
this._selector += ` >> has=${JSON.stringify(textSelector)}`;
this._selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
}
if (options?.has) {
const locator = options.has;
if (locator._frame !== frame)
throw new Error(`Inner "has" locator must belong to the same frame.`);
this._selector += ` >> has=` + JSON.stringify(locator._selector);
this._selector += ` >> internal:has=` + JSON.stringify(locator._selector);
}
}
@ -395,7 +395,7 @@ export class FrameLocator implements api.FrameLocator {
}
locator(selector: string, options?: { hasText?: string | RegExp }): Locator {
return new Locator(this._frame, this._frameSelector + ' >> control=enter-frame >> ' + selector, options);
return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selector, options);
}
getByTestId(testId: string): Locator {
@ -427,7 +427,7 @@ export class FrameLocator implements api.FrameLocator {
}
frameLocator(selector: string): FrameLocator {
return new FrameLocator(this._frame, this._frameSelector + ' >> control=enter-frame >> ' + selector);
return new FrameLocator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selector);
}
first(): FrameLocator {

View File

@ -1501,7 +1501,7 @@ export class Frame extends SdkObject {
return this.retryWithProgress(progress, selector, options, async selectorInFrame => {
// Be careful, |this| can be different from |frame|.
progress.log(`waiting for selector "${selector}"`);
const { frame, info } = selectorInFrame || { frame: this, info: { parsed: { parts: [{ name: 'control', body: 'return-empty', source: 'control=return-empty' }] }, world: 'utility', strict: !!options.strict } };
const { frame, info } = selectorInFrame || { frame: this, info: { parsed: { parts: [{ name: 'internal:control', body: 'return-empty', source: 'internal:control=return-empty' }] }, world: 'utility', strict: !!options.strict } };
return await frame._scheduleRerunnableTaskInFrame(progress, info, callbackText, taskData, options);
});
}

View File

@ -28,10 +28,10 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?
this.selector = selector;
if (options?.hasText) {
const textSelector = 'text=' + escapeForTextSelector(options.hasText, false);
this.selector += ` >> has=${JSON.stringify(textSelector)}`;
this.selector += ` >> internal:has=${JSON.stringify(textSelector)}`;
}
if (options?.has)
this.selector += ` >> has=` + JSON.stringify(options.has.selector);
this.selector += ` >> internal:has=` + JSON.stringify(options.has.selector);
const parsed = injectedScript.parseSelector(this.selector);
this.element = injectedScript.querySelector(parsed, document, false);
this.elements = injectedScript.querySelectorAll(parsed, document);

View File

@ -22,7 +22,7 @@ import { RoleEngine } from './roleSelectorEngine';
import { parseAttributeSelector } from '../isomorphic/selectorParser';
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser';
import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher, elementText } from './selectorUtils';
import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher, elementText, createStrictFullTextMatcher } from './selectorUtils';
import { SelectorEvaluatorImpl } from './selectorEvaluator';
import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils';
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
@ -103,9 +103,10 @@ export class InjectedScript {
this._engines.set('css', this._createCSSEngine());
this._engines.set('nth', { queryAll: () => [] });
this._engines.set('visible', this._createVisibleEngine());
this._engines.set('control', this._createControlEngine());
this._engines.set('has', this._createHasEngine());
this._engines.set('attr', this._createNamedAttributeEngine());
this._engines.set('internal:control', this._createControlEngine());
this._engines.set('internal:has', this._createHasEngine());
this._engines.set('internal:label', this._createLabelEngine());
this._engines.set('internal:attr', this._createNamedAttributeEngine());
for (const { name, engine } of customEngines)
this._engines.set(name, engine);
@ -173,7 +174,7 @@ export class InjectedScript {
const withHas: ParsedSelector = { parts: selector.parts.slice(0, selector.capture + 1) };
if (selector.capture < selector.parts.length - 1) {
const parsed: ParsedSelector = { parts: selector.parts.slice(selector.capture + 1) };
const has: ParsedSelectorPart = { name: 'has', body: { parsed }, source: stringifySelector(parsed) };
const has: ParsedSelectorPart = { name: 'internal:has', body: { parsed }, source: stringifySelector(parsed) };
withHas.parts.push(has);
}
return this.querySelectorAll(withHas, root);
@ -243,7 +244,7 @@ export class InjectedScript {
private _createTextEngine(shadow: boolean): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string): Element[] => {
const { matcher, kind } = createTextMatcher(selector);
const { matcher, kind } = createTextMatcher(selector, false);
const result: Element[] = [];
let lastDidNotMatchSelf: Element | null = null;
@ -273,6 +274,23 @@ export class InjectedScript {
};
}
private _createLabelEngine(): SelectorEngine {
const evaluator = this._evaluator;
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
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) {
const control = label.control;
if (control && matcher(elementText(evaluator._cacheText, label)))
result.push(control);
}
return result;
}
};
}
private _createNamedAttributeEngine(): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string): Element[] => {
const parsed = parseAttributeSelector(selector, true);
@ -305,10 +323,6 @@ export class InjectedScript {
return [];
if (body === 'return-empty')
return [];
if (body === 'resolve-label') {
const control = (root as HTMLLabelElement).control;
return control ? [control] : [];
}
if (body === 'component') {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
@ -316,7 +330,7 @@ export class InjectedScript {
// However, when mounting fragments, return the root instead.
return [root.childElementCount === 1 ? root.firstElementChild! : root as Element];
}
throw new Error(`Internal error, unknown control selector ${body}`);
throw new Error(`Internal error, unknown internal:control selector ${body}`);
}
};
}
@ -1280,7 +1294,7 @@ function unescape(s: string): string {
return r.join('');
}
function createTextMatcher(selector: string): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
function createTextMatcher(selector: string, strictMatchesFullText: 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));
@ -1295,8 +1309,9 @@ function createTextMatcher(selector: string): { matcher: TextMatcher, kind: 'reg
selector = unescape(selector.substring(1, selector.length - 1));
strict = true;
}
const matcher = strict ? createStrictTextMatcher(selector) : createLaxTextMatcher(selector);
return { matcher, kind: strict ? 'strict' : 'lax' };
if (strict)
return { matcher: strictMatchesFullText ? createStrictFullTextMatcher(selector) : createStrictTextMatcher(selector), kind: 'strict' };
return { matcher: createLaxTextMatcher(selector), kind: 'lax' };
}
class ExpectedTextMatcher {

View File

@ -148,7 +148,7 @@ function buildCandidates(element: Element, accessibleNameCache: Map<Element, boo
const candidates: SelectorToken[] = [];
if (element.getAttribute('data-testid'))
candidates.push({ engine: 'attr', selector: `[data-testid=${escapeForAttributeSelector(element.getAttribute('data-testid')!)}]`, score: 1 });
candidates.push({ engine: 'internal:attr', selector: `[data-testid=${escapeForAttributeSelector(element.getAttribute('data-testid')!)}]`, score: 1 });
for (const attr of ['data-test-id', 'data-test']) {
if (element.getAttribute(attr))
@ -158,7 +158,7 @@ function buildCandidates(element: Element, accessibleNameCache: Map<Element, boo
if (element.nodeName === 'INPUT') {
const input = element as HTMLInputElement;
if (input.placeholder)
candidates.push({ engine: 'attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder)}]`, score: 3 });
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder)}]`, score: 3 });
}
const ariaRole = getAriaRole(element);
@ -171,7 +171,7 @@ function buildCandidates(element: Element, accessibleNameCache: Map<Element, boo
}
if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName))
candidates.push({ engine: 'attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!)}]`, score: 10 });
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!)}]`, score: 10 });
if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: 50 });

View File

@ -68,6 +68,13 @@ export function createStrictTextMatcher(text: string): TextMatcher {
};
}
export function createStrictFullTextMatcher(text: string): TextMatcher {
text = text.trim().replace(/\s+/g, ' ');
return (elementText: ElementText) => {
return elementText.full.trim().replace(/\s+/g, ' ') === text;
};
}
export function createRegexTextMatcher(source: string, flags?: string): TextMatcher {
const re = new RegExp(source, flags);
return (elementText: ElementText) => {

View File

@ -19,7 +19,7 @@ import { InvalidSelectorError, parseCSS } from './cssParser';
export { InvalidSelectorError, isInvalidSelectorError } from './cssParser';
export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number };
const kNestedSelectorNames = new Set(['has', 'left-of', 'right-of', 'above', 'below', 'near']);
const kNestedSelectorNames = new Set(['internal:has', 'left-of', 'right-of', 'above', 'below', 'near']);
const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']);
export type ParsedSelectorPart = {
@ -70,7 +70,7 @@ export function parseSelector(selector: string): ParsedSelector {
throw new Error(`Malformed selector: ${part.name}=` + part.body);
}
const result = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } };
if (result.body.parsed.parts.some(part => part.name === 'control' && part.body === 'enter-frame'))
if (result.body.parsed.parts.some(part => part.name === 'internal:control' && part.body === 'enter-frame'))
throw new Error(`Frames are not allowed inside "${part.name}" selectors`);
return result;
}
@ -93,7 +93,7 @@ export function splitSelectorByFrame(selectorText: string): ParsedSelector[] {
let chunkStartIndex = 0;
for (let i = 0; i < selector.parts.length; ++i) {
const part = selector.parts[i];
if (part.name === 'control' && part.body === 'enter-frame') {
if (part.name === 'internal:control' && part.body === 'enter-frame') {
if (!chunk.parts.length)
throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first');
result.push(chunk);

View File

@ -121,7 +121,7 @@ export function asLocator(generator: LanguageGenerator, selector: string, isFram
}
}
if (part.name === 'attr') {
if (part.name === 'internal:attr') {
const attrSelector = parseAttributeSelector(part.body as string, true);
const { name, value } = attrSelector.attributes[0];
if (name === 'data-testid') {

View File

@ -45,8 +45,8 @@ export class Selectors {
'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light',
'nth', 'visible', 'control', 'has',
'role', 'attr'
'nth', 'visible', 'internal:control', 'internal:has',
'role', 'internal:attr', 'internal:label'
]);
this._builtinEnginesInMainWorld = new Set([
'_react', '_vue',

View File

@ -64,7 +64,7 @@ export function escapeForTextSelector(text: string | RegExp, exact: boolean, cas
if (exact)
return '"' + text.replace(/["]/g, '\\"') + '"';
if (text.includes('"') || text.includes('>>') || text[0] === '/')
return `/.*${escapeForRegex(text).replace(/\s+/, '\\s+')}.*/` + (caseSensitive ? '' : 'i');
return `/${escapeForRegex(text).replace(/\s+/, '\\s+')}/` + (caseSensitive ? '' : 'i');
return text;
}

View File

@ -124,7 +124,7 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
await window.playwrightMount(component, rootElement, hooksConfig);
return '#root >> control=component';
return '#root >> internal:control=component';
}, { component, hooksConfig: options.hooksConfig });
return selector;
}

View File

@ -235,7 +235,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<div data-testid=testid onclick="console.log('click')">Submit</div>`);
const selector = await recorder.hoverOverElement('div');
expect(selector).toBe('attr=[data-testid="testid"]');
expect(selector).toBe('internal:attr=[data-testid="testid"]');
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -267,7 +267,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input placeholder="Country"></input>`);
const selector = await recorder.hoverOverElement('input');
expect(selector).toBe('attr=[placeholder="Country"]');
expect(selector).toBe('internal:attr=[placeholder="Country"]');
const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'click'),
@ -296,7 +296,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input alt="Country"></input>`);
const selector = await recorder.hoverOverElement('input');
expect(selector).toBe('attr=[alt="Country"]');
expect(selector).toBe('internal:attr=[alt="Country"]');
const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'click'),

View File

@ -45,7 +45,7 @@ it.describe('selector generator', () => {
it('should not escape spaces inside named attr selectors', async ({ page }) => {
await page.setContent(`<input placeholder="Foo b ar"/>`);
expect(await generate(page, 'input')).toBe('attr=[placeholder=\"Foo b ar\"]');
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Foo b ar\"]');
});
it('should generate text for <input type=button>', async ({ page }) => {
@ -60,17 +60,17 @@ it.describe('selector generator', () => {
it('should escape text with >>', async ({ page }) => {
await page.setContent(`<div>text&gt;&gt;text</div>`);
expect(await generate(page, 'div')).toBe('text=/.*text\\>\\>text.*/');
expect(await generate(page, 'div')).toBe('text=/text\\>\\>text/');
});
it('should escape text with quote', async ({ page }) => {
await page.setContent(`<div>text"text</div>`);
expect(await generate(page, 'div')).toBe('text=/.*text"text.*/');
expect(await generate(page, 'div')).toBe('text=/text"text/');
});
it('should escape text with slash', async ({ page }) => {
await page.setContent(`<div>/text</div>`);
expect(await generate(page, 'div')).toBe('text=/.*\/text.*/');
expect(await generate(page, 'div')).toBe('text=/\/text/');
});
it('should not use text for select', async ({ page }) => {
@ -88,7 +88,7 @@ it.describe('selector generator', () => {
it('should prefer data-testid', async ({ page }) => {
await page.setContent(`<div>Text</div><div>Text</div><div data-testid=a>Text</div><div>Text</div>`);
expect(await generate(page, '[data-testid="a"]')).toBe('attr=[data-testid=\"a\"]');
expect(await generate(page, '[data-testid="a"]')).toBe('internal:attr=[data-testid=\"a\"]');
});
it('should handle first non-unique data-testid', async ({ page }) => {
@ -99,7 +99,7 @@ it.describe('selector generator', () => {
<div data-testid=a>
Text
</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe('attr=[data-testid=\"a\"] >> nth=0');
expect(await generate(page, 'div[mark="1"]')).toBe('internal:attr=[data-testid=\"a\"] >> nth=0');
});
it('should handle second non-unique data-testid', async ({ page }) => {
@ -110,7 +110,7 @@ it.describe('selector generator', () => {
<div data-testid=a mark=1>
Text
</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe(`attr=[data-testid=\"a\"] >> nth=1`);
expect(await generate(page, 'div[mark="1"]')).toBe(`internal:attr=[data-testid=\"a\"] >> nth=1`);
});
it('should use readable id', async ({ page }) => {
@ -232,7 +232,7 @@ it.describe('selector generator', () => {
});
it('placeholder', async ({ page }) => {
await page.setContent(`<input placeholder="foobar" type="text"/>`);
expect(await generate(page, 'input')).toBe('attr=[placeholder=\"foobar\"]');
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"]');
});
it('type', async ({ page }) => {
await page.setContent(`<input type="text"/>`);

View File

@ -180,31 +180,3 @@ it('alias methods coverage', async ({ page }) => {
await expect(page.locator('div').getByRole('button')).toHaveCount(1);
await expect(page.mainFrame().locator('button')).toHaveCount(1);
});
it('getBy escaping', async ({ page }) => {
await page.setContent(`<label id=label for=control>Hello
wo"rld</label><input id=control />`);
await page.$eval('input', input => {
input.setAttribute('placeholder', 'hello\nwo"rld');
input.setAttribute('title', 'hello\nwo"rld');
input.setAttribute('alt', 'hello\nwo"rld');
});
await expect(page.getByText('hello\nwo"rld')).toHaveAttribute('id', 'label');
await expect(page.getByLabel('hello\nwo"rld')).toHaveAttribute('id', 'control');
await expect(page.getByPlaceholder('hello\nwo"rld')).toHaveAttribute('id', 'control');
await expect(page.getByAltText('hello\nwo"rld')).toHaveAttribute('id', 'control');
await expect(page.getByTitle('hello\nwo"rld')).toHaveAttribute('id', 'control');
await page.setContent(`<label id=label for=control>Hello
world</label><input id=control />`);
await page.$eval('input', input => {
input.setAttribute('placeholder', 'hello\nworld');
input.setAttribute('title', 'hello\nworld');
input.setAttribute('alt', 'hello\nworld');
});
await expect(page.getByText('hello\nworld')).toHaveAttribute('id', 'label');
await expect(page.getByLabel('hello\nworld')).toHaveAttribute('id', 'control');
await expect(page.getByPlaceholder('hello\nworld')).toHaveAttribute('id', 'control');
await expect(page.getByAltText('hello\nworld')).toHaveAttribute('id', 'control');
await expect(page.getByTitle('hello\nworld')).toHaveAttribute('id', 'control');
});

View File

@ -49,7 +49,7 @@ async function routeIframe(page: Page) {
it('should work for iframe @smoke', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> control=enter-frame >> button');
const button = page.locator('iframe >> internal:control=enter-frame >> button');
await button.waitFor();
expect(await button.innerText()).toBe('Hello iframe');
await expect(button).toHaveText('Hello iframe');
@ -60,7 +60,7 @@ it('should work for iframe (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const button = await body.waitForSelector('iframe >> control=enter-frame >> button');
const button = await body.waitForSelector('iframe >> internal:control=enter-frame >> button');
expect(await button.innerText()).toBe('Hello iframe');
expect(await button.textContent()).toBe('Hello iframe');
await button.click();
@ -69,7 +69,7 @@ it('should work for iframe (handle)', async ({ page, server }) => {
it('should work for nested iframe', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> control=enter-frame >> iframe >> control=enter-frame >> button');
const button = page.locator('iframe >> internal:control=enter-frame >> iframe >> internal:control=enter-frame >> button');
await button.waitFor();
expect(await button.innerText()).toBe('Hello nested iframe');
await expect(button).toHaveText('Hello nested iframe');
@ -80,7 +80,7 @@ it('should work for nested iframe (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const button = await body.waitForSelector('iframe >> control=enter-frame >> iframe >> control=enter-frame >> button');
const button = await body.waitForSelector('iframe >> internal:control=enter-frame >> iframe >> internal:control=enter-frame >> button');
expect(await button.innerText()).toBe('Hello nested iframe');
expect(await button.textContent()).toBe('Hello nested iframe');
await button.click();
@ -89,35 +89,35 @@ it('should work for nested iframe (handle)', async ({ page, server }) => {
it('should work for $ and $$', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const element = await page.$('iframe >> control=enter-frame >> button');
const element = await page.$('iframe >> internal:control=enter-frame >> button');
expect(await element.textContent()).toBe('Hello iframe');
const elements = await page.$$('iframe >> control=enter-frame >> span');
const elements = await page.$$('iframe >> internal:control=enter-frame >> span');
expect(elements).toHaveLength(2);
});
it('$ should not wait for frame', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
expect(await page.$('iframe >> control=enter-frame >> canvas')).toBeFalsy();
expect(await page.$('iframe >> internal:control=enter-frame >> canvas')).toBeFalsy();
const body = await page.$('body');
expect(await body.$('iframe >> control=enter-frame >> canvas')).toBeFalsy();
expect(await body.$('iframe >> internal:control=enter-frame >> canvas')).toBeFalsy();
});
it('$$ should not wait for frame', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
expect(await page.$$('iframe >> control=enter-frame >> canvas')).toHaveLength(0);
expect(await page.$$('iframe >> internal:control=enter-frame >> canvas')).toHaveLength(0);
const body = await page.$('body');
expect(await body.$$('iframe >> control=enter-frame >> canvas')).toHaveLength(0);
expect(await body.$$('iframe >> internal:control=enter-frame >> canvas')).toHaveLength(0);
});
it('$eval should throw for missing frame', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
{
const error = await page.$eval('iframe >> control=enter-frame >> canvas', e => 1).catch(e => e);
const error = await page.$eval('iframe >> internal:control=enter-frame >> canvas', e => 1).catch(e => e);
expect(error.message).toContain('Error: failed to find element matching selector');
}
{
const body = await page.$('body');
const error = await body.$eval('iframe >> control=enter-frame >> canvas', e => 1).catch(e => e);
const error = await body.$eval('iframe >> internal:control=enter-frame >> canvas', e => 1).catch(e => e);
expect(error.message).toContain('Error: failed to find element matching selector');
}
});
@ -125,12 +125,12 @@ it('$eval should throw for missing frame', async ({ page, server }) => {
it('$$eval should throw for missing frame', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
{
const error = await page.$$eval('iframe >> control=enter-frame >> canvas', e => 1).catch(e => e);
const error = await page.$$eval('iframe >> internal:control=enter-frame >> canvas', e => 1).catch(e => e);
expect(error.message).toContain('Error: failed to find frame for selector');
}
{
const body = await page.$('body');
const error = await body.$$eval('iframe >> control=enter-frame >> canvas', e => 1).catch(e => e);
const error = await body.$$eval('iframe >> internal:control=enter-frame >> canvas', e => 1).catch(e => e);
expect(error.message).toContain('Error: failed to find frame for selector');
}
});
@ -139,16 +139,16 @@ it('should work for $ and $$ (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const element = await body.$('iframe >> control=enter-frame >> button');
const element = await body.$('iframe >> internal:control=enter-frame >> button');
expect(await element.textContent()).toBe('Hello iframe');
const elements = await body.$$('iframe >> control=enter-frame >> span');
const elements = await body.$$('iframe >> internal:control=enter-frame >> span');
expect(elements).toHaveLength(2);
});
it('should work for $eval', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const value = await page.$eval('iframe >> control=enter-frame >> button', b => b.nodeName);
const value = await page.$eval('iframe >> internal:control=enter-frame >> button', b => b.nodeName);
expect(value).toBe('BUTTON');
});
@ -156,14 +156,14 @@ it('should work for $eval (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const value = await body.$eval('iframe >> control=enter-frame >> button', b => b.nodeName);
const value = await body.$eval('iframe >> internal:control=enter-frame >> button', b => b.nodeName);
expect(value).toBe('BUTTON');
});
it('should work for $$eval', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const value = await page.$$eval('iframe >> control=enter-frame >> span', ss => ss.map(s => s.textContent));
const value = await page.$$eval('iframe >> internal:control=enter-frame >> span', ss => ss.map(s => s.textContent));
expect(value).toEqual(['1', '2']);
});
@ -171,30 +171,30 @@ it('should work for $$eval (handle)', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const value = await body.$$eval('iframe >> control=enter-frame >> span', ss => ss.map(s => s.textContent));
const value = await body.$$eval('iframe >> internal:control=enter-frame >> span', ss => ss.map(s => s.textContent));
expect(value).toEqual(['1', '2']);
});
it('should not allow dangling enter-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> control=enter-frame');
const button = page.locator('iframe >> internal:control=enter-frame');
const error = await button.click().catch(e => e);
expect(error.message).toContain('Selector cannot end with');
expect(error.message).toContain('iframe >> control=enter-frame');
expect(error.message).toContain('iframe >> internal:control=enter-frame');
});
it('should not allow leading enter-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const error = await page.waitForSelector('control=enter-frame >> button').catch(e => e);
const error = await page.waitForSelector('internal:control=enter-frame >> button').catch(e => e);
expect(error.message).toContain('Selector cannot start with');
});
it('should not allow capturing before enter-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('*css=iframe >> control=enter-frame >> div');
const button = page.locator('*css=iframe >> internal:control=enter-frame >> div');
const error = await await button.click().catch(e => e);
expect(error.message).toContain('Can not capture the selector before diving into the frame');
});
@ -202,7 +202,7 @@ it('should not allow capturing before enter-frame', async ({ page, server }) =>
it('should capture after the enter-frame', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const div = page.locator('iframe >> control=enter-frame >> *css=div >> button');
const div = page.locator('iframe >> internal:control=enter-frame >> *css=div >> button');
expect(await div.innerHTML()).toContain('<button>');
});
@ -230,7 +230,7 @@ it('should click in lazy iframe', async ({ page, server }) => {
}, 500);
// Click in iframe
const button = page.locator('iframe >> control=enter-frame >> button');
const button = page.locator('iframe >> internal:control=enter-frame >> button');
const [, text] = await Promise.all([
button.click(),
button.innerText(),
@ -242,7 +242,7 @@ it('should click in lazy iframe', async ({ page, server }) => {
it('waitFor should survive frame reattach', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> control=enter-frame >> button:has-text("Hello nested iframe")');
const button = page.locator('iframe >> internal:control=enter-frame >> button:has-text("Hello nested iframe")');
const promise = button.waitFor();
await page.locator('iframe').evaluate(e => e.remove());
await page.evaluate(() => {
@ -257,7 +257,7 @@ it('waitForSelector should survive frame reattach (handle)', async ({ page, serv
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const promise = body.waitForSelector('iframe >> control=enter-frame >> button:has-text("Hello nested iframe")');
const promise = body.waitForSelector('iframe >> internal:control=enter-frame >> button:has-text("Hello nested iframe")');
await page.locator('iframe').evaluate(e => e.remove());
await page.evaluate(() => {
const iframe = document.createElement('iframe');
@ -271,7 +271,7 @@ it('waitForSelector should survive iframe navigation (handle)', async ({ page, s
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const body = await page.$('body');
const promise = body.waitForSelector('iframe >> control=enter-frame >> button:has-text("Hello nested iframe")');
const promise = body.waitForSelector('iframe >> internal:control=enter-frame >> button:has-text("Hello nested iframe")');
page.locator('iframe').evaluate(e => (e as HTMLIFrameElement).src = 'iframe-2.html');
await promise;
});
@ -279,7 +279,7 @@ it('waitForSelector should survive iframe navigation (handle)', async ({ page, s
it('click should survive frame reattach', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> control=enter-frame >> button:has-text("Hello nested iframe")');
const button = page.locator('iframe >> internal:control=enter-frame >> button:has-text("Hello nested iframe")');
const promise = button.click();
await page.locator('iframe').evaluate(e => e.remove());
await page.evaluate(() => {
@ -293,7 +293,7 @@ it('click should survive frame reattach', async ({ page, server }) => {
it('click should survive iframe navigation', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const button = page.locator('iframe >> control=enter-frame >> button:has-text("Hello nested iframe")');
const button = page.locator('iframe >> internal:control=enter-frame >> button:has-text("Hello nested iframe")');
const promise = button.click();
page.locator('iframe').evaluate(e => (e as HTMLIFrameElement).src = 'iframe-2.html');
await promise;
@ -322,7 +322,7 @@ it('should fail if element removed while waiting on element handle', async ({ pa
it('should non work for non-frame', async ({ page, server }) => {
await routeIframe(page);
await page.setContent('<div></div>');
const button = page.locator('div >> control=enter-frame >> button');
const button = page.locator('div >> internal:control=enter-frame >> button');
const error = await button.waitFor().catch(e => e);
expect(error.message).toContain('<div></div>');
expect(error.message).toContain('<iframe> was expected');

View File

@ -32,6 +32,7 @@ it('getByText should work', async ({ page }) => {
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);
expect(await page.getByText('ye').evaluate(e => e.outerHTML)).toContain('>\nye </div>');
expect(await page.getByText(/ye/).evaluate(e => e.outerHTML)).toContain('>\nye </div>');
expect(await page.getByText(/e/).evaluate(e => e.outerHTML)).toContain('>\nye </div>');
await page.setContent(`<div> ye </div><div>ye</div>`);
expect(await page.getByText('ye', { exact: true }).first().evaluate(e => e.outerHTML)).toContain('> ye </div>');
@ -48,6 +49,22 @@ it('getByLabel should work', async ({ page }) => {
expect(await page.locator('div').getByLabel('Name').evaluate(e => e.nodeName)).toBe('INPUT');
});
it('getByLabel should work with nested elements', async ({ page }) => {
await page.setContent(`<label for=target>Last <span>Name</span></label><input id=target type=text>`);
await expect(page.getByLabel('last name')).toHaveAttribute('id', 'target');
await expect(page.getByLabel('st na')).toHaveAttribute('id', 'target');
await expect(page.getByLabel('Name')).toHaveAttribute('id', 'target');
await expect(page.getByLabel('Last Name', { exact: true })).toHaveAttribute('id', 'target');
await expect(page.getByLabel(/Last\s+name/i)).toHaveAttribute('id', 'target');
expect(await page.getByLabel('Last', { exact: true }).elementHandles()).toEqual([]);
expect(await page.getByLabel('last name', { exact: true }).elementHandles()).toEqual([]);
expect(await page.getByLabel('Name', { exact: true }).elementHandles()).toEqual([]);
expect(await page.getByLabel('what?').elementHandles()).toEqual([]);
expect(await page.getByLabel(/last name/).elementHandles()).toEqual([]);
});
it('getByPlaceholder should work', async ({ page }) => {
await page.setContent(`<div>
<input placeholder='Hello'>
@ -89,3 +106,31 @@ it('getByTitle should work', async ({ page }) => {
await expect(page.mainFrame().getByTitle('hello')).toHaveCount(2);
await expect(page.locator('div').getByTitle('hello')).toHaveCount(2);
});
it('getBy escaping', async ({ page }) => {
await page.setContent(`<label id=label for=control>Hello
wo"rld</label><input id=control />`);
await page.$eval('input', input => {
input.setAttribute('placeholder', 'hello\nwo"rld');
input.setAttribute('title', 'hello\nwo"rld');
input.setAttribute('alt', 'hello\nwo"rld');
});
await expect(page.getByText('hello\nwo"rld')).toHaveAttribute('id', 'label');
await expect(page.getByLabel('hello\nwo"rld')).toHaveAttribute('id', 'control');
await expect(page.getByPlaceholder('hello\nwo"rld')).toHaveAttribute('id', 'control');
await expect(page.getByAltText('hello\nwo"rld')).toHaveAttribute('id', 'control');
await expect(page.getByTitle('hello\nwo"rld')).toHaveAttribute('id', 'control');
await page.setContent(`<label id=label for=control>Hello
world</label><input id=control />`);
await page.$eval('input', input => {
input.setAttribute('placeholder', 'hello\nworld');
input.setAttribute('title', 'hello\nworld');
input.setAttribute('alt', 'hello\nworld');
});
await expect(page.getByText('hello\nworld')).toHaveAttribute('id', 'label');
await expect(page.getByLabel('hello\nworld')).toHaveAttribute('id', 'control');
await expect(page.getByPlaceholder('hello\nworld')).toHaveAttribute('id', 'control');
await expect(page.getByAltText('hello\nworld')).toHaveAttribute('id', 'control');
await expect(page.getByTitle('hello\nworld')).toHaveAttribute('id', 'control');
});

View File

@ -368,37 +368,37 @@ it('should properly determine visibility of display:contents elements', async ({
await page.waitForSelector('article', { state: 'hidden' });
});
it('should work with has=', async ({ page, server }) => {
it('should work with internal:has=', async ({ page, server }) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
expect(await page.$$eval(`div >> has="#target"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> has="[data-testid=foo]"`, els => els.length)).toBe(3);
expect(await page.$$eval(`div >> has="[attr*=value]"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> internal:has="#target"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> internal:has="[data-testid=foo]"`, els => els.length)).toBe(3);
expect(await page.$$eval(`div >> internal:has="[attr*=value]"`, els => els.length)).toBe(2);
await page.setContent(`<section><span></span><div></div></section><section><br></section>`);
expect(await page.$$eval(`section >> has="span, div"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> has="span, div"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> has="br"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> has="span, br"`, els => els.length)).toBe(2);
expect(await page.$$eval(`section >> has="span, br, div"`, els => els.length)).toBe(2);
expect(await page.$$eval(`section >> internal:has="span, div"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> internal:has="span, div"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> internal:has="br"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> internal:has="span, br"`, els => els.length)).toBe(2);
expect(await page.$$eval(`section >> internal:has="span, br, div"`, els => els.length)).toBe(2);
await page.setContent(`<div><span>hello</span></div><div><span>world</span></div>`);
expect(await page.$$eval(`div >> has="text=world"`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> has="text=world"`, e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
expect(await page.$$eval(`div >> has="text=\\"hello\\""`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> has="text=\\"hello\\""`, e => e.outerHTML)).toBe(`<div><span>hello</span></div>`);
expect(await page.$$eval(`div >> has="xpath=./span"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> has="span"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> has="span >> text=wor"`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> has="span >> text=wor"`, e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
expect(await page.$eval(`div >> has="span >> text=wor" >> span`, e => e.outerHTML)).toBe(`<span>world</span>`);
expect(await page.$$eval(`div >> internal:has="text=world"`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> internal:has="text=world"`, e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
expect(await page.$$eval(`div >> internal:has="text=\\"hello\\""`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> internal:has="text=\\"hello\\""`, e => e.outerHTML)).toBe(`<div><span>hello</span></div>`);
expect(await page.$$eval(`div >> internal:has="xpath=./span"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> internal:has="span"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> internal:has="span >> text=wor"`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> internal:has="span >> text=wor"`, e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
expect(await page.$eval(`div >> internal:has="span >> text=wor" >> span`, e => e.outerHTML)).toBe(`<span>world</span>`);
const error1 = await page.$(`div >> has=abc`).catch(e => e);
expect(error1.message).toContain('Malformed selector: has=abc');
const error2 = await page.$(`has="div"`).catch(e => e);
expect(error2.message).toContain('"has" selector cannot be first');
const error3 = await page.$(`div >> has=33`).catch(e => e);
expect(error3.message).toContain('Malformed selector: has=33');
const error4 = await page.$(`div >> has="span!"`).catch(e => e);
const error1 = await page.$(`div >> internal:has=abc`).catch(e => e);
expect(error1.message).toContain('Malformed selector: internal:has=abc');
const error2 = await page.$(`internal:has="div"`).catch(e => e);
expect(error2.message).toContain('"internal:has" selector cannot be first');
const error3 = await page.$(`div >> internal:has=33`).catch(e => e);
expect(error3.message).toContain('Malformed selector: internal:has=33');
const error4 = await page.$(`div >> internal:has="span!"`).catch(e => e);
expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"');
});