fix(locators): allow identical frameLocators inside and/or/has (#23740)

So, the following will work:

```
page.frameLocator('iframe').locator('span').or(page.frameLoactor('iframe').locator('div'))
```

The following will not work, because frame locators are not exactly the
same:

```
page.frameLocator('#iframe1').locator('span').or(page.frameLoactor('#iframe2').locator('div'))
```

Also improve the error message to be more readable and include the
locator.

Fixes #23697.
This commit is contained in:
Dmitry Gozman 2023-06-19 15:22:26 -07:00 committed by GitHub
parent fbb5d48283
commit fe5c9dad4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 57 additions and 27 deletions

View File

@ -16,10 +16,11 @@
import type { Frame } from './frames'; import type { Frame } from './frames';
import type * as types from './types'; import type * as types from './types';
import { stringifySelector, type ParsedSelector, splitSelectorByFrame } from '../utils/isomorphic/selectorParser'; import { stringifySelector, type ParsedSelector, splitSelectorByFrame, InvalidSelectorError, visitAllSelectorParts } from '../utils/isomorphic/selectorParser';
import type { FrameExecutionContext, ElementHandle } from './dom'; import type { FrameExecutionContext, ElementHandle } from './dom';
import type { JSHandle } from './javascript'; import type { JSHandle } from './javascript';
import type { InjectedScript } from './injected/injectedScript'; import type { InjectedScript } from './injected/injectedScript';
import { asLocator } from '../utils/isomorphic/locatorGenerators';
export type SelectorInfo = { export type SelectorInfo = {
parsed: ParsedSelector, parsed: ParsedSelector,
@ -111,6 +112,15 @@ export class FrameSelectors {
let frame: Frame = this.frame; let frame: Frame = this.frame;
const frameChunks = splitSelectorByFrame(selector); const frameChunks = splitSelectorByFrame(selector);
for (const chunk of frameChunks) {
visitAllSelectorParts(chunk, (part, nested) => {
if (nested && part.name === 'internal:control' && part.body === 'enter-frame') {
const locator = asLocator(this.frame._page.attribution.playwright.options.sdkLanguage, selector);
throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`);
}
});
}
for (let i = 0; i < frameChunks.length - 1; ++i) { for (let i = 0; i < frameChunks.length - 1; ++i) {
const info = this._parseSelector(frameChunks[i], options); const info = this._parseSelector(frameChunks[i], options);
const context = await frame._context(info.world); const context = await frame._context(info.world);

View File

@ -21,7 +21,7 @@ import { VueEngine } from './vueSelectorEngine';
import { createRoleEngine } from './roleSelectorEngine'; import { createRoleEngine } from './roleSelectorEngine';
import { parseAttributeSelector } from '../../utils/isomorphic/selectorParser'; import { parseAttributeSelector } from '../../utils/isomorphic/selectorParser';
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../../utils/isomorphic/selectorParser'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../../utils/isomorphic/selectorParser';
import { allEngineNames, parseSelector, stringifySelector } from '../../utils/isomorphic/selectorParser'; import { visitAllSelectorParts, parseSelector, stringifySelector } from '../../utils/isomorphic/selectorParser';
import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils'; import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils';
import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator'; import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator';
import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils'; import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils';
@ -146,10 +146,10 @@ export class InjectedScript {
parseSelector(selector: string): ParsedSelector { parseSelector(selector: string): ParsedSelector {
const result = parseSelector(selector); const result = parseSelector(selector);
for (const name of allEngineNames(result)) { visitAllSelectorParts(result, part => {
if (!this._engines.has(name)) if (!this._engines.has(part.name))
throw this.createStacklessError(`Unknown engine "${name}" while parsing selector ${selector}`); throw this.createStacklessError(`Unknown engine "${part.name}" while parsing selector ${selector}`);
} });
return result; return result;
} }

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { allEngineNames, InvalidSelectorError, type ParsedSelector, parseSelector, stringifySelector } from '../utils/isomorphic/selectorParser'; import { visitAllSelectorParts, InvalidSelectorError, type ParsedSelector, parseSelector, stringifySelector } from '../utils/isomorphic/selectorParser';
import { createGuid } from '../utils'; import { createGuid } from '../utils';
export class Selectors { export class Selectors {
@ -73,7 +73,8 @@ export class Selectors {
parseSelector(selector: string | ParsedSelector, strict: boolean) { parseSelector(selector: string | ParsedSelector, strict: boolean) {
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector; const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
let needsMainWorld = false; let needsMainWorld = false;
for (const name of allEngineNames(parsed)) { visitAllSelectorParts(parsed, part => {
const name = part.name;
const custom = this._engines.get(name); const custom = this._engines.get(name);
if (!custom && !this._builtinEngines.has(name)) if (!custom && !this._builtinEngines.has(name))
throw new InvalidSelectorError(`Unknown engine "${name}" while parsing selector ${stringifySelector(parsed)}`); throw new InvalidSelectorError(`Unknown engine "${name}" while parsing selector ${stringifySelector(parsed)}`);
@ -81,7 +82,7 @@ export class Selectors {
needsMainWorld = true; needsMainWorld = true;
if (this._builtinEnginesInMainWorld.has(name)) if (this._builtinEnginesInMainWorld.has(name))
needsMainWorld = true; needsMainWorld = true;
} });
return { return {
parsed, parsed,
world: needsMainWorld ? 'main' as const : 'utility' as const, world: needsMainWorld ? 'main' as const : 'utility' as const,

View File

@ -41,17 +41,19 @@ type ParsedSelectorStrings = {
export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'has-text', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']); export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'has-text', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']);
export function parseSelector(selector: string): ParsedSelector { export function parseSelector(selector: string): ParsedSelector {
const result = parseSelectorString(selector); const parsedStrings = parseSelectorString(selector);
const parts: ParsedSelectorPart[] = result.parts.map(part => { const parts: ParsedSelectorPart[] = [];
for (const part of parsedStrings.parts) {
if (part.name === 'css' || part.name === 'css:light') { if (part.name === 'css' || part.name === 'css:light') {
if (part.name === 'css:light') if (part.name === 'css:light')
part.body = ':light(' + part.body + ')'; part.body = ':light(' + part.body + ')';
const parsedCSS = parseCSS(part.body, customCSSNames); const parsedCSS = parseCSS(part.body, customCSSNames);
return { parts.push({
name: 'css', name: 'css',
body: parsedCSS.selector, body: parsedCSS.selector,
source: part.body source: part.body
}; });
continue;
} }
if (kNestedSelectorNames.has(part.name)) { if (kNestedSelectorNames.has(part.name)) {
let innerSelector: string; let innerSelector: string;
@ -69,17 +71,21 @@ export function parseSelector(selector: string): ParsedSelector {
} catch (e) { } catch (e) {
throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
} }
const result = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } }; const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } };
if (result.body.parsed.parts.some(part => part.name === 'internal:control' && part.body === 'enter-frame')) const lastFrame = [...nested.body.parsed.parts].reverse().find(part => part.name === 'internal:control' && part.body === 'enter-frame');
throw new InvalidSelectorError(`Frames are not allowed inside "${part.name}" selectors`); const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1;
return result; // Allow nested selectors to start with the same frame selector.
if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1)))
nested.body.parsed.parts.splice(0, lastFrameIndex + 1);
parts.push(nested);
continue;
} }
return { ...part, source: part.body }; parts.push({ ...part, source: part.body });
}); }
if (kNestedSelectorNames.has(parts[0].name)) if (kNestedSelectorNames.has(parts[0].name))
throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`); throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`);
return { return {
capture: result.capture, capture: parsedStrings.capture,
parts parts
}; };
} }
@ -113,6 +119,10 @@ export function splitSelectorByFrame(selectorText: string): ParsedSelector[] {
return result; return result;
} }
function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) {
return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 });
}
export function stringifySelector(selector: string | ParsedSelector): string { export function stringifySelector(selector: string | ParsedSelector): string {
if (typeof selector === 'string') if (typeof selector === 'string')
return selector; return selector;
@ -122,17 +132,15 @@ export function stringifySelector(selector: string | ParsedSelector): string {
}).join(' >> '); }).join(' >> ');
} }
export function allEngineNames(selector: ParsedSelector): Set<string> { export function visitAllSelectorParts(selector: ParsedSelector, visitor: (part: ParsedSelectorPart, nested: boolean) => void) {
const result = new Set<string>(); const visit = (selector: ParsedSelector, nested: boolean) => {
const visit = (selector: ParsedSelector) => {
for (const part of selector.parts) { for (const part of selector.parts) {
result.add(part.name); visitor(part, nested);
if (kNestedSelectorNames.has(part.name)) if (kNestedSelectorNames.has(part.name))
visit((part.body as NestedSelectorBody).parsed); visit((part.body as NestedSelectorBody).parsed, true);
} }
}; };
visit(selector); visit(selector, false);
return result;
} }
function parseSelectorString(selector: string): ParsedSelectorStrings { function parseSelectorString(selector: string): ParsedSelectorStrings {

View File

@ -189,6 +189,17 @@ it('should support locator.or', async ({ page }) => {
await expect(page.locator('span').or(page.locator('article'))).toHaveText('world'); await expect(page.locator('span').or(page.locator('article'))).toHaveText('world');
}); });
it('should allow some, but not all nested frameLocators', async ({ page }) => {
await page.setContent(`<iframe srcdoc="<span id=target>world</span>"></iframe><span>hello</span>`);
await expect(page.frameLocator('iframe').locator('span').or(page.frameLocator('iframe').locator('article'))).toHaveText('world');
await expect(page.frameLocator('iframe').locator('article').or(page.frameLocator('iframe').locator('span'))).toHaveText('world');
await expect(page.frameLocator('iframe').locator('span').and(page.frameLocator('iframe').locator('#target'))).toHaveText('world');
const error1 = await expect(page.frameLocator('iframe').locator('div').or(page.frameLocator('#iframe').locator('span'))).toHaveText('world').catch(e => e);
expect(error1.message).toContain(`Frame locators are not allowed inside composite locators, while querying "frameLocator('iframe').locator('div').or(frameLocator('#iframe').locator('span'))`);
const error2 = await expect(page.frameLocator('iframe').locator('div').and(page.frameLocator('#iframe').locator('span'))).toHaveText('world').catch(e => e);
expect(error2.message).toContain(`Frame locators are not allowed inside composite locators, while querying "frameLocator('iframe').locator('div').and(frameLocator('#iframe').locator('span'))`);
});
it('should enforce same frame for has/leftOf/rightOf/above/below/near', async ({ page, server }) => { it('should enforce same frame for has/leftOf/rightOf/above/below/near', async ({ page, server }) => {
await page.goto(server.PREFIX + '/frames/two-frames.html'); await page.goto(server.PREFIX + '/frames/two-frames.html');
const child = page.frames()[1]; const child = page.frames()[1];