Skip to content
Open
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
29 changes: 23 additions & 6 deletions packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export class InjectedScript {
const result = this.querySelectorAll(selector, root);
if (strict && result.length > 1)
throw this.strictModeViolationError(selector, result);
this.checkDeprecatedSelectorUsage(selector, result);
return result[0];
}

Expand Down Expand Up @@ -1228,28 +1229,44 @@ export class InjectedScript {
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${trimStringWithEllipsis(text, 50)}</${element.nodeName.toLowerCase()}>`);
}

strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error {
private _generateSelectors(elements: Element[]) {
this._evaluator.begin();
beginAriaCaches();
beginDOMCaches();
try {
// Firefox is slow to access DOM bindings in the utility world, making it very expensive to generate a lot of selectors.
const maxElements = this._isUtilityWorld && this._browserName === 'firefox' ? 2 : 10;
const infos = matches.slice(0, maxElements).map(m => ({
const infos = elements.slice(0, maxElements).map(m => ({
preview: this.previewNode(m),
selector: this.generateSelectorSimple(m),
}));
const lines = infos.map((info, i) => `\n ${i + 1}) ${info.preview} aka ${asLocator(this._sdkLanguage, info.selector)}`);
if (infos.length < matches.length)
lines.push('\n ...');
return this.createStacklessError(`strict mode violation: ${asLocator(this._sdkLanguage, stringifySelector(selector))} resolved to ${matches.length} elements:${lines.join('')}\n`);
return infos.map((info, i) => `${i + 1}) ${info.preview} aka ${asLocator(this._sdkLanguage, info.selector)}`);
} finally {
endDOMCaches();
endAriaCaches();
this._evaluator.end();
}
}

strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error {
const lines = this._generateSelectors(matches).map(line => `\n ` + line);
if (lines.length < matches.length)
lines.push('\n ...');
return this.createStacklessError(`strict mode violation: ${asLocator(this._sdkLanguage, stringifySelector(selector))} resolved to ${matches.length} elements:${lines.join('')}\n`);
}

checkDeprecatedSelectorUsage(selector: ParsedSelector, matches: Element[]) {
if (!matches.length)
return;
const deperecated = selector.parts.find(part => part.name === '_react' || part.name === '_vue');
if (!deperecated)
return;
const lines = this._generateSelectors(matches).map(line => `\n ` + line);
if (lines.length < matches.length)
lines.push('\n ...');
throw this.createStacklessError(`"${deperecated.name}" selector is not supported: ${asLocator(this._sdkLanguage, stringifySelector(selector))} resolved to ${matches.length} element${matches.length === 1 ? '' : 's'}:${lines.join('')}\n`);
}

createStacklessError(message: string): Error {
if (this._browserName === 'firefox') {
const error = new Error('Error: ' + message);
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ export class Frame extends SdkObject {
} else if (element) {
log = ` locator resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`;
}
injected.checkDeprecatedSelectorUsage(info.parsed, elements);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add the same to query, queryCount etc methods too?

return { log, element, visible, attached: !!element };
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined }));
const { log, visible, attached } = await progress.race(result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached })));
Expand Down Expand Up @@ -1115,6 +1116,7 @@ export class Frame extends SdkObject {
} else if (element) {
log = ` locator resolved to ${injected.previewNode(element)}`;
}
injected.checkDeprecatedSelectorUsage(info.parsed, elements);
return { log, success: !!element, element };
}, { info: resolved.info, callId: progress.metadata.id }));
const { log, success } = await progress.race(result.evaluate(r => ({ log: r.log, success: r.success })));
Expand Down Expand Up @@ -1444,6 +1446,8 @@ export class Frame extends SdkObject {
throw injected.strictModeViolationError(info!.parsed, elements);
else if (elements.length)
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
if (info)
injected.checkDeprecatedSelectorUsage(info.parsed, elements);
return { log, ...await injected.expect(elements[0], options, elements) };
}, { info, options, callId: progress.metadata.id }));

Expand Down
147 changes: 85 additions & 62 deletions tests/page/selectors-react.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,31 @@
* limitations under the License.
*/

import { test as it, expect } from './pageTest';
import { test as it, expect as baseExpect } from './pageTest';
import type { Locator } from 'playwright-core';

const expect = baseExpect.extend({
async toHaveCountError(locator: Locator, expected: number) {
try {
await expect(locator).toHaveCount(expected);
if (!expected)
return { pass: true, message: () => 'Locator has expected count of 0' };
return {
pass: false,
message: () => `Querying locator ${locator.toString()} should throw, but it did not.`,
};
} catch (e) {
const message = (e as Error).message;
try {
expect(message).toContain(`"_react" selector is not supported`);
expect(message).toContain(`resolved to ${expected} element`);
} catch (error) {
return { pass: false, message: () => (error as Error).message };
}
return { pass: true, message: () => 'Error message is as expected' };
}
}
});

const reacts = {
'react15': '/reading-list/react15.html',
Expand All @@ -31,139 +55,138 @@ for (const [name, url] of Object.entries(reacts)) {
});

it('should work with single-root elements @smoke', async ({ page }) => {
await expect(page.locator(`_react=BookList`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookList >> _react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookItem >> _react=BookList`)).toHaveCount(0);
await expect(page.locator(`_react=BookList`)).toHaveCountError(1);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(3);
await expect(page.locator(`_react=BookList >> _react=BookItem`)).toHaveCountError(3);
await expect(page.locator(`_react=BookItem >> _react=BookList`)).toHaveCountError(0);
});

it('should work with multi-root elements (fragments)', async ({ page }) => {
it.skip(name === 'react15', 'React 15 does not support fragments');
await expect(page.locator(`_react=App`)).toHaveCount(15);
await expect(page.locator(`_react=AppHeader`)).toHaveCount(2);
await expect(page.locator(`_react=NewBook`)).toHaveCount(2);
await expect(page.locator(`_react=App`)).toHaveCountError(15);
await expect(page.locator(`_react=AppHeader`)).toHaveCountError(2);
await expect(page.locator(`_react=NewBook`)).toHaveCountError(2);
});

it('should not crash when there is no match', async ({ page }) => {
await expect(page.locator(`_react=Apps`)).toHaveCount(0);
await expect(page.locator(`_react=BookLi`)).toHaveCount(0);
await expect(page.locator(`_react=Apps`)).toHaveCountError(0);
await expect(page.locator(`_react=BookLi`)).toHaveCountError(0);
});

it('should compose', async ({ page }) => {
await expect(page.locator(`_react=NewBook >> _react=button`)).toHaveText('new book');
expect(await page.$eval(`_react=NewBook >> _react=input`, el => el.tagName)).toBe('INPUT');
await expect(page.locator(`_react=BookItem >> text=Gatsby`)).toHaveText('The Great Gatsby');
await expect(page.locator(`_react=NewBook >> _react=button`).locator(':scope:has-text("new book")')).toHaveCountError(1);
await expect(page.locator(`_react=BookItem >> text=Gatsby`).locator(':scope:has-text("The Great Gatsby")')).toHaveCountError(1);
});

it('should query by props combinations', async ({ page }) => {
await expect(page.locator(`_react=BookItem[name="The Great Gatsby"]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name="the great gatsby" i]`)).toHaveCount(1);
await expect(page.locator(`_react=li[key="The Great Gatsby"]`)).toHaveCount(1);
await expect(page.locator(`_react=ColorButton[nested.index = 0]`)).toHaveCount(1);
await expect(page.locator(`_react=ColorButton[nested.nonexisting.index = 0]`)).toHaveCount(0);
await expect(page.locator(`_react=ColorButton[nested.index.nonexisting = 0]`)).toHaveCount(0);
await expect(page.locator(`_react=ColorButton[nested.index.nonexisting = 1]`)).toHaveCount(0);
await expect(page.locator(`_react=ColorButton[nested.value = 4.1]`)).toHaveCount(1);
await expect(page.locator(`_react=ColorButton[enabled = false]`)).toHaveCount(4);
await expect(page.locator(`_react=ColorButton[enabled = true] `)).toHaveCount(5);
await expect(page.locator(`_react=ColorButton[enabled = true][color = "red"]`)).toHaveCount(2);
await expect(page.locator(`_react=ColorButton[enabled = true][color = "red"i][nested.index = 6]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name="The Great Gatsby"]`)).toHaveCountError(1);
await expect(page.locator(`_react=BookItem[name="the great gatsby" i]`)).toHaveCountError(1);
await expect(page.locator(`_react=li[key="The Great Gatsby"]`)).toHaveCountError(1);
await expect(page.locator(`_react=ColorButton[nested.index = 0]`)).toHaveCountError(1);
await expect(page.locator(`_react=ColorButton[nested.nonexisting.index = 0]`)).toHaveCountError(0);
await expect(page.locator(`_react=ColorButton[nested.index.nonexisting = 0]`)).toHaveCountError(0);
await expect(page.locator(`_react=ColorButton[nested.index.nonexisting = 1]`)).toHaveCountError(0);
await expect(page.locator(`_react=ColorButton[nested.value = 4.1]`)).toHaveCountError(1);
await expect(page.locator(`_react=ColorButton[enabled = false]`)).toHaveCountError(4);
await expect(page.locator(`_react=ColorButton[enabled = true] `)).toHaveCountError(5);
await expect(page.locator(`_react=ColorButton[enabled = true][color = "red"]`)).toHaveCountError(2);
await expect(page.locator(`_react=ColorButton[enabled = true][color = "red"i][nested.index = 6]`)).toHaveCountError(1);
});

it('should exact match by props', async ({ page }) => {
await expect(page.locator(`_react=BookItem[name = "The Great Gatsby"]`)).toHaveText('The Great Gatsby');
await expect(page.locator(`_react=BookItem[name = "The Great Gatsby"]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name = "The Great Gatsby"]`).locator(':scope:has-text("The Great Gatsby")')).toHaveCountError(1);
await expect(page.locator(`_react=BookItem[name = "The Great Gatsby"]`)).toHaveCountError(1);
// case sensitive by default
await expect(page.locator(`_react=BookItem[name = "the great gatsby"]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" s]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" S]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name = "the great gatsby"]`)).toHaveCountError(0);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" s]`)).toHaveCountError(0);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" S]`)).toHaveCountError(0);
// case insensitive with flag
await expect(page.locator(`_react=BookItem[name = "the great gatsby" i]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" I]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name = " The Great Gatsby "]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" i]`)).toHaveCountError(1);
await expect(page.locator(`_react=BookItem[name = "the great gatsby" I]`)).toHaveCountError(1);
await expect(page.locator(`_react=BookItem[name = " The Great Gatsby "]`)).toHaveCountError(0);
});

it('should partially match by props', async ({ page }) => {
// Check partial matching
await expect(page.locator(`_react=BookItem[name *= "Gatsby"]`)).toHaveText('The Great Gatsby');
await expect(page.locator(`_react=BookItem[name *= "Gatsby"]`)).toHaveCount(1);
await expect(page.locator(`_react=[name *= "Gatsby"]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name *= "Gatsby"]`).locator(':scope:has-text("The Great Gatsby")')).toHaveCountError(1);
await expect(page.locator(`_react=BookItem[name *= "Gatsby"]`)).toHaveCountError(1);
await expect(page.locator(`_react=[name *= "Gatsby"]`)).toHaveCountError(1);

await expect(page.locator(`_react=BookItem[name = "Gatsby"]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name = "Gatsby"]`)).toHaveCountError(0);
});

it('should support all string operators', async ({ page }) => {
await expect(page.locator(`_react=ColorButton[color = "red"]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color |= "red"]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color $= "ed"]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color ^= "gr"]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color ~= "e"]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name ~= "gatsby" i]`)).toHaveCount(1);
await expect(page.locator(`_react=BookItem[name *= " gatsby" i]`)).toHaveCount(1);
await expect(page.locator(`_react=ColorButton[color = "red"]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color |= "red"]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color $= "ed"]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color ^= "gr"]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color ~= "e"]`)).toHaveCountError(0);
await expect(page.locator(`_react=BookItem[name ~= "gatsby" i]`)).toHaveCountError(1);
await expect(page.locator(`_react=BookItem[name *= " gatsby" i]`)).toHaveCountError(1);
});

it('should support regex', async ({ page }) => {
await expect(page.locator(`_react=ColorButton[color = /red/]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color = /^red$/]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color = /RED/i]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color = /[pqr]ed/]`)).toHaveCount(3);
await expect(page.locator(`_react=ColorButton[color = /[pq]ed/]`)).toHaveCount(0);
await expect(page.locator(`_react=BookItem[name = /gat.by/i]`)).toHaveCount(1);
await expect(page.locator(`_react=ColorButton[color = /red/]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color = /^red$/]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color = /RED/i]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color = /[pqr]ed/]`)).toHaveCountError(3);
await expect(page.locator(`_react=ColorButton[color = /[pq]ed/]`)).toHaveCountError(0);
await expect(page.locator(`_react=BookItem[name = /gat.by/i]`)).toHaveCountError(1);
});

it('should support truthy querying', async ({ page }) => {
await expect(page.locator(`_react=ColorButton[enabled]`)).toHaveCount(5);
await expect(page.locator(`_react=ColorButton[enabled]`)).toHaveCountError(5);
});

it('should support nested react trees', async ({ page }) => {
await expect(page.locator(`_react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(3);
await page.evaluate(() => {
// @ts-ignore
mountNestedApp();
});
await expect(page.locator(`_react=BookItem`)).toHaveCount(6);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(6);
});

it('should work with react memo', async ({ page }) => {
it.skip(name === 'react15' || name === 'react16', 'Class components dont support memo');
await expect(page.locator(`_react=ButtonGrid`)).toHaveCount(9);
await expect(page.locator(`_react=ButtonGrid`)).toHaveCountError(9);
});

it('should work with multiroot react', async ({ page }) => {
await it.step('mount second root', async () => {
await expect(page.locator(`_react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(3);
await page.evaluate(() => {
const anotherRoot = document.createElement('div');
anotherRoot.id = 'root2';
document.body.append(anotherRoot);
// @ts-ignore
window.mountApp(anotherRoot);
});
await expect(page.locator(`_react=BookItem`)).toHaveCount(6);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(6);
});

await it.step('add a new book to second root', async () => {
await page.locator('#root2 input').fill('newbook');
await page.locator('#root2 >> text=new book').click();
await expect(page.locator('css=#root >> _react=BookItem')).toHaveCount(3);
await expect(page.locator('css=#root2 >> _react=BookItem')).toHaveCount(4);
await expect(page.locator('css=#root >> _react=BookItem')).toHaveCountError(3);
await expect(page.locator('css=#root2 >> _react=BookItem')).toHaveCountError(4);
});
});

it('should work with multiroot react inside shadow DOM', async ({ page }) => {
await expect(page.locator(`_react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(3);
await page.evaluate(() => {
const anotherRoot = document.createElement('div');
document.body.append(anotherRoot);
const shadowRoot = anotherRoot.attachShadow({ mode: 'open' });
// @ts-ignore
window.mountApp(shadowRoot);
});
await expect(page.locator(`_react=BookItem`)).toHaveCount(6);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(6);
});

it('should work with multiroot react after unmount', async ({ page }) => {
await expect(page.locator(`_react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(3);

await page.evaluate(() => {
const anotherRoot = document.createElement('div');
Expand All @@ -172,7 +195,7 @@ for (const [name, url] of Object.entries(reacts)) {
const newRoot = window.mountApp(anotherRoot);
newRoot.unmount();
});
await expect(page.locator(`_react=BookItem`)).toHaveCount(3);
await expect(page.locator(`_react=BookItem`)).toHaveCountError(3);
});
});
}
Loading
Loading