fix(codegen): update priorites in selector generator (#18688)

- prefer `role=checkbox` over `input[type=checkbox]`
- prefer `#id` over `input[type=checkbox]` and `role=checkbox`
- prefer `text=foo` over `internal:has-text=foo`
- ignore `none` and `presentation` roles
- remove non-strict support
This commit is contained in:
Dmitry Gozman 2022-11-09 17:22:13 -08:00 committed by GitHub
parent 6d491f928d
commit cafa558845
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 159 deletions

View File

@ -114,13 +114,13 @@ class ConsoleAPI {
private _selector(element: Element) {
if (!(element instanceof Element))
throw new Error(`Usage: playwright.selector(element).`);
return generateSelector(this._injectedScript, element, true, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
return generateSelector(this._injectedScript, element, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
}
private _generateLocator(element: Element, language?: Language) {
if (!(element instanceof Element))
throw new Error(`Usage: playwright.locator(element).`);
const selector = generateSelector(this._injectedScript, element, true, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
const selector = generateSelector(this._injectedScript, element, this._injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen()).selector;
return asLocator(language || 'javascript', selector);
}

View File

@ -149,7 +149,7 @@ export class InjectedScript {
}
generateSelector(targetElement: Element, testIdAttributeName: string): string {
return generateSelector(this, targetElement, true, testIdAttributeName).selector;
return generateSelector(this, targetElement, testIdAttributeName).selector;
}
querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined {

View File

@ -240,7 +240,7 @@ class Recorder {
if (this._mode === 'none')
return;
const activeElement = this._deepActiveElement(document);
const result = activeElement ? generateSelector(this._injectedScript, activeElement, true, this._testIdAttributeName) : null;
const result = activeElement ? generateSelector(this._injectedScript, activeElement, this._testIdAttributeName) : null;
this._activeModel = result && result.selector ? result : null;
if (userGesture)
this._hoveredElement = activeElement as HTMLElement | null;
@ -254,7 +254,7 @@ class Recorder {
return;
}
const hoveredElement = this._hoveredElement;
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement, true, this._testIdAttributeName);
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement, this._testIdAttributeName);
if ((this._hoveredModel && this._hoveredModel.selector === selector))
return;
this._hoveredModel = selector ? { selector, elements } : null;

View File

@ -27,7 +27,20 @@ type SelectorToken = {
const cacheAllowText = new Map<Element, SelectorToken[] | null>();
const cacheDisallowText = new Map<Element, SelectorToken[] | null>();
const kTestIdScore = 1; // testIdAttributeName
const kOtherTestIdScore = 2; // other data-test* attributes
const kPlaceholderScore = 3;
const kLabelScore = 3;
const kRoleWithNameScore = 5;
const kAltTextScore = 10;
const kTextScore = 15;
const kCSSIdScore = 100;
const kRoleWithoutNameScore = 140;
const kCSSInputTypeNameScore = 150;
const kCSSTagNameScore = 200;
const kNthScore = 1000;
const kCSSFallbackScore = 10000000;
export function querySelector(injectedScript: InjectedScript, selector: string, ownerDocument: Document): { selector: string, elements: Element[] } {
try {
@ -44,12 +57,12 @@ export function querySelector(injectedScript: InjectedScript, selector: string,
}
}
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, strict: boolean, testIdAttributeName: string): { selector: string, elements: Element[] } {
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string): { selector: string, elements: Element[] } {
injectedScript._evaluator.begin();
try {
targetElement = targetElement.closest('button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]') || targetElement;
const targetTokens = generateSelectorFor(injectedScript, targetElement, strict, testIdAttributeName);
const bestTokens = targetTokens || cssFallback(injectedScript, targetElement, strict);
const targetTokens = generateSelectorFor(injectedScript, targetElement, testIdAttributeName);
const bestTokens = targetTokens || cssFallback(injectedScript, targetElement);
const selector = joinTokens(bestTokens);
const parsedSelector = injectedScript.parseSelector(selector);
return {
@ -68,7 +81,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
return textCandidates.filter(c => c[0].selector[0] !== '/');
}
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, strict: boolean, testIdAttributeName: string): SelectorToken[] | null {
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, testIdAttributeName: string): SelectorToken[] | null {
if (targetElement.ownerDocument.documentElement === targetElement)
return [{ engine: 'css', selector: 'html', score: 1 }];
@ -84,7 +97,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
const noTextCandidates = buildCandidates(injectedScript, element, testIdAttributeName, accessibleNameCache).map(token => [token]);
// First check all text and non-text candidates for the element.
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch, strict);
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch);
// Do not use regex for chained selectors (for performance).
textCandidates = filterRegexTokens(textCandidates);
@ -114,7 +127,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
if (result && combineScores([...parentTokens, ...bestPossibleInParent]) >= combineScores(result))
continue;
// Update the best candidate that finds "element" in the "parent".
bestPossibleInParent = chooseFirstSelector(injectedScript, parent, element, candidates, allowNthMatch, strict);
bestPossibleInParent = chooseFirstSelector(injectedScript, parent, element, candidates, allowNthMatch);
if (!bestPossibleInParent)
return;
const combined = [...parentTokens, ...bestPossibleInParent];
@ -147,52 +160,52 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
function buildCandidates(injectedScript: InjectedScript, element: Element, testIdAttributeName: string, accessibleNameCache: Map<Element, boolean>): SelectorToken[] {
const candidates: SelectorToken[] = [];
if (element.getAttribute(testIdAttributeName))
candidates.push({ engine: 'internal:testid', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true)}]`, score: 1 });
candidates.push({ engine: 'internal:testid', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true)}]`, score: kTestIdScore });
for (const attr of ['data-testid', 'data-test-id', 'data-test']) {
if (attr !== testIdAttributeName && element.getAttribute(attr))
candidates.push({ engine: 'css', selector: `[${attr}=${quoteAttributeValue(element.getAttribute(attr)!)}]`, score: 2 });
candidates.push({ engine: 'css', selector: `[${attr}=${quoteAttributeValue(element.getAttribute(attr)!)}]`, score: kOtherTestIdScore });
}
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
const input = element as HTMLInputElement | HTMLTextAreaElement;
if (input.placeholder)
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, false)}]`, score: 3 });
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, false)}]`, score: kPlaceholderScore });
const label = input.labels?.[0];
if (label) {
const labelText = elementText(injectedScript._evaluator._cacheText, label).full.trim();
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, false), score: 3 });
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, false), score: kLabelScore });
}
}
const ariaRole = getAriaRole(element);
if (ariaRole) {
if (ariaRole && !['none', 'presentation'].includes(ariaRole)) {
const ariaName = getElementAccessibleName(element, false, accessibleNameCache);
if (ariaName)
candidates.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: 3 });
candidates.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScore });
else
candidates.push({ engine: 'internal:role', selector: ariaRole, score: 150 });
candidates.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore });
}
if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName))
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: 10 });
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: kAltTextScore });
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 });
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: kCSSInputTypeNameScore });
if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') {
if (element.getAttribute('type'))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[type=${quoteAttributeValue(element.getAttribute('type')!)}]`, score: 50 });
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[type=${quoteAttributeValue(element.getAttribute('type')!)}]`, score: kCSSInputTypeNameScore });
}
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName))
candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: 50 });
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden')
candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSInputTypeNameScore + 1 });
const idAttr = element.getAttribute('id');
if (idAttr && !isGuidLike(idAttr))
candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: 100 });
candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore });
candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: 200 });
candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore });
return candidates;
}
@ -207,20 +220,20 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
const escaped = escapeForTextSelector(text, false);
if (isTargetNode)
candidates.push([{ engine: 'internal:text', selector: escaped, score: 10 }]);
candidates.push([{ engine: 'internal:text', selector: escaped, score: kTextScore }]);
const ariaRole = getAriaRole(element);
const candidate: SelectorToken[] = [];
if (ariaRole) {
if (ariaRole && !['none', 'presentation'].includes(ariaRole)) {
const ariaName = getElementAccessibleName(element, false, accessibleNameCache);
if (ariaName)
candidate.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: 10 });
candidate.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScore });
else
candidate.push({ engine: 'internal:role', selector: ariaRole, score: 10 });
candidate.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore });
} else {
candidate.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: 10 });
candidate.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: kCSSTagNameScore });
}
candidate.push({ engine: 'internal:has-text', selector: escaped, score: 0 });
candidate.push({ engine: 'internal:has-text', selector: escaped, score: kTextScore });
candidates.push(candidate);
return candidates;
}
@ -239,8 +252,7 @@ function makeSelectorForId(id: string) {
return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${cssEscape(id)}"]`;
}
function cssFallback(injectedScript: InjectedScript, targetElement: Element, strict: boolean): SelectorToken[] {
const kFallbackScore = 10000000;
function cssFallback(injectedScript: InjectedScript, targetElement: Element): SelectorToken[] {
const root: Node = targetElement.ownerDocument;
const tokens: string[] = [];
@ -255,9 +267,7 @@ function cssFallback(injectedScript: InjectedScript, targetElement: Element, str
}
function makeStrict(selector: string): SelectorToken[] {
const token = { engine: 'css', selector, score: kFallbackScore };
if (!strict)
return [token];
const token = { engine: 'css', selector, score: kCSSFallbackScore };
const parsedSelector = injectedScript.parseSelector(selector);
const elements = injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument);
if (elements.length === 1)
@ -340,7 +350,7 @@ function combineScores(tokens: SelectorToken[]): number {
return score;
}
function chooseFirstSelector(injectedScript: InjectedScript, scope: Element | Document, targetElement: Element, selectors: SelectorToken[][], allowNthMatch: boolean, strict: boolean): SelectorToken[] | null {
function chooseFirstSelector(injectedScript: InjectedScript, scope: Element | Document, targetElement: Element, selectors: SelectorToken[][], allowNthMatch: boolean): SelectorToken[] | null {
const joined = selectors.map(tokens => ({ tokens, score: combineScores(tokens) }));
joined.sort((a, b) => a.score - b.score);
@ -348,14 +358,13 @@ function chooseFirstSelector(injectedScript: InjectedScript, scope: Element | Do
for (const { tokens } of joined) {
const parsedSelector = injectedScript.parseSelector(joinTokens(tokens));
const result = injectedScript.querySelectorAll(parsedSelector, scope);
const isStrictEnough = !strict || result.length === 1;
const index = result.indexOf(targetElement);
if (index === 0 && isStrictEnough) {
// We are the first match - found the best selector.
if (result[0] === targetElement && result.length === 1) {
// We are the only match - found the best selector.
return tokens;
}
// Otherwise, perhaps we can use nth=?
const index = result.indexOf(targetElement);
if (!allowNthMatch || bestWithIndex || index === -1 || result.length > 5)
continue;

View File

@ -212,7 +212,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input id="input" name="name" oninput="console.log(input.value)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="name"]')`);
expect(locator).toBe(`locator('#input')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -221,18 +221,18 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="name"]').fill('John');`);
await page.locator('#input').fill('John');`);
expect(sources.get('Java').text).toContain(`
page.locator("input[name=\\\"name\\\"]").fill("John");`);
page.locator("#input").fill("John");`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[name=\\\"name\\\"]\").fill(\"John\")`);
page.locator("#input").fill(\"John\")`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[name=\\\"name\\\"]\").fill(\"John\")`);
await page.locator("#input").fill(\"John\")`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[name=\\\"name\\\"]\").FillAsync(\"John\");`);
await page.Locator("#input").FillAsync(\"John\");`);
expect(message.text()).toBe('John');
});
@ -243,7 +243,7 @@ test.describe('cli codegen', () => {
// In Japanese, "てすと" or "テスト" means "test".
await recorder.setContentAndWait(`<input id="input" name="name" oninput="input.value === 'てすと' && console.log(input.value)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="name"]')`);
expect(locator).toBe(`locator('#input')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -255,18 +255,18 @@ test.describe('cli codegen', () => {
})()
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="name"]').fill('てすと');`);
await page.locator('#input').fill('てすと');`);
expect(sources.get('Java').text).toContain(`
page.locator("input[name=\\\"name\\\"]").fill("てすと");`);
page.locator("#input").fill("てすと");`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[name=\\\"name\\\"]\").fill(\"てすと\")`);
page.locator("#input").fill(\"てすと\")`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[name=\\\"name\\\"]\").fill(\"てすと\")`);
await page.locator("#input").fill(\"てすと\")`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[name=\\\"name\\\"]\").FillAsync(\"てすと\");`);
await page.Locator("#input").FillAsync(\"てすと\");`);
expect(message.text()).toBe('てすと');
});
@ -276,7 +276,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<textarea id="textarea" name="name" oninput="console.log(textarea.value)"></textarea>`);
const locator = await recorder.focusElement('textarea');
expect(locator).toBe(`locator('textarea[name="name"]')`);
expect(locator).toBe(`locator('#textarea')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -284,7 +284,7 @@ test.describe('cli codegen', () => {
page.fill('textarea', 'John')
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('textarea[name="name"]').fill('John');`);
await page.locator('#textarea').fill('John');`);
expect(message.text()).toBe('John');
});
@ -294,7 +294,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input name="name" onkeypress="console.log('press')"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="name"]')`);
expect(locator).toBe(`getByRole('textbox')`);
const messages: any[] = [];
page.on('console', message => messages.push(message));
@ -305,19 +305,19 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="name"]').press('Shift+Enter');`);
await page.getByRole('textbox').press('Shift+Enter');`);
expect(sources.get('Java').text).toContain(`
page.locator("input[name=\\\"name\\\"]").press("Shift+Enter");`);
page.getByRole(AriaRole.TEXTBOX).press("Shift+Enter");`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[name=\\\"name\\\"]\").press(\"Shift+Enter\")`);
page.get_by_role("textbox").press("Shift+Enter")`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[name=\\\"name\\\"]\").press(\"Shift+Enter\")`);
await page.get_by_role("textbox").press("Shift+Enter")`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[name=\\\"name\\\"]\").PressAsync(\"Shift+Enter\");`);
await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
expect(messages[0].text()).toBe('press');
});
@ -357,7 +357,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input name="name" onkeydown="console.log('press:' + event.key)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="name"]')`);
expect(locator).toBe(`getByRole('textbox')`);
const messages: any[] = [];
page.on('console', message => {
@ -369,7 +369,7 @@ test.describe('cli codegen', () => {
page.press('input', 'ArrowDown')
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="name"]').press('ArrowDown');`);
await page.getByRole('textbox').press('ArrowDown');`);
expect(messages[0].text()).toBe('press:ArrowDown');
});
@ -379,7 +379,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input name="name" onkeydown="console.log('down:' + event.key)" onkeyup="console.log('up:' + event.key)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="name"]')`);
expect(locator).toBe(`getByRole('textbox')`);
const messages: any[] = [];
page.on('console', message => {
@ -392,7 +392,7 @@ test.describe('cli codegen', () => {
page.press('input', 'ArrowDown')
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="name"]').press('ArrowDown');`);
await page.getByRole('textbox').press('ArrowDown');`);
expect(messages.length).toBe(2);
expect(messages[0].text()).toBe('down:ArrowDown');
expect(messages[1].text()).toBe('up:ArrowDown');
@ -404,7 +404,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="console.log(checkbox.checked)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="accept"]')`);
expect(locator).toBe(`locator('#checkbox')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -413,19 +413,19 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="accept"]').check();`);
await page.locator('#checkbox').check();`);
expect(sources.get('Java').text).toContain(`
page.locator("input[name=\\\"accept\\\"]").check();`);
page.locator("#checkbox").check();`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[name=\\\"accept\\\"]\").check()`);
page.locator("#checkbox").check()`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[name=\\\"accept\\\"]\").check()`);
await page.locator("#checkbox").check()`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[name=\\\"accept\\\"]\").CheckAsync();`);
await page.Locator("#checkbox").CheckAsync();`);
expect(message.text()).toBe('true');
});
@ -436,7 +436,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input id="checkbox" type="radio" name="accept" onchange="console.log(checkbox.checked)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="accept"]')`);
expect(locator).toBe(`locator('#checkbox')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -445,7 +445,7 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="accept"]').check();`);
await page.locator('#checkbox').check();`);
expect(message.text()).toBe('true');
});
@ -455,7 +455,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="console.log(checkbox.checked)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="accept"]')`);
expect(locator).toBe(`locator('#checkbox')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -464,7 +464,7 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="accept"]').check();`);
await page.locator('#checkbox').check();`);
expect(message.text()).toBe('true');
});
@ -474,7 +474,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" checked name="accept" onchange="console.log(checkbox.checked)"></input>`);
const locator = await recorder.focusElement('input');
expect(locator).toBe(`locator('input[name="accept"]')`);
expect(locator).toBe(`locator('#checkbox')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -483,19 +483,19 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[name="accept"]').uncheck();`);
await page.locator('#checkbox').uncheck();`);
expect(sources.get('Java').text).toContain(`
page.locator("input[name=\\\"accept\\\"]").uncheck();`);
page.locator("#checkbox").uncheck();`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[name=\\\"accept\\\"]\").uncheck()`);
page.locator("#checkbox").uncheck()`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[name=\\\"accept\\\"]\").uncheck()`);
await page.locator("#checkbox").uncheck()`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[name=\\\"accept\\\"]\").UncheckAsync();`);
await page.Locator("#checkbox").UncheckAsync();`);
expect(message.text()).toBe('false');
});
@ -506,7 +506,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait('<select id="age" onchange="console.log(age.selectedOptions[0].value)"><option value="1"><option value="2"></select>');
const locator = await recorder.hoverOverElement('select');
expect(locator).toBe(`locator('select')`);
expect(locator).toBe(`locator('#age')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -515,19 +515,19 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('select').selectOption('2');`);
await page.locator('#age').selectOption('2');`);
expect(sources.get('Java').text).toContain(`
page.locator("select").selectOption("2");`);
page.locator("#age").selectOption("2");`);
expect(sources.get('Python').text).toContain(`
page.locator(\"select\").select_option(\"2\")`);
page.locator("#age").select_option("2")`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"select\").select_option(\"2\")`);
await page.locator("#age").select_option("2")`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"select\").SelectOptionAsync(new[] { \"2\" });`);
await page.Locator("#age").SelectOptionAsync(new[] { "2" });`);
expect(message.text()).toBe('2');
});
@ -624,8 +624,8 @@ test.describe('cli codegen', () => {
await recorder.page.keyboard.insertText('@');
await recorder.page.keyboard.type('example.com');
await recorder.waitForOutput('JavaScript', 'example.com');
expect(recorder.sources().get('JavaScript').text).not.toContain(`await page.locator('input').press('AltGraph');`);
expect(recorder.sources().get('JavaScript').text).toContain(`await page.locator('input').fill('playwright@example.com');`);
expect(recorder.sources().get('JavaScript').text).not.toContain(`await page.getByRole('textbox').press('AltGraph');`);
expect(recorder.sources().get('JavaScript').text).toContain(`await page.getByRole('textbox').fill('playwright@example.com');`);
});
test('should middle click', async ({ page, openRecorder, server }) => {

View File

@ -121,19 +121,19 @@ test.describe('cli codegen', () => {
const sources = await recorder.waitForOutput('JavaScript', 'setInputFiles');
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[type="file"]').setInputFiles('file-to-upload.txt');`);
await page.getByRole('textbox').setInputFiles('file-to-upload.txt');`);
expect(sources.get('Java').text).toContain(`
page.locator("input[type=\\\"file\\\"]").setInputFiles(Paths.get("file-to-upload.txt"));`);
page.getByRole(AriaRole.TEXTBOX).setInputFiles(Paths.get("file-to-upload.txt"));`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[type=\\\"file\\\"]\").set_input_files(\"file-to-upload.txt\")`);
page.get_by_role("textbox").set_input_files(\"file-to-upload.txt\")`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[type=\\\"file\\\"]\").set_input_files(\"file-to-upload.txt\")`);
await page.get_by_role("textbox").set_input_files(\"file-to-upload.txt\")`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[type=\\\"file\\\"]\").SetInputFilesAsync(new[] { \"file-to-upload.txt\" });`);
await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { \"file-to-upload.txt\" });`);
});
test('should upload multiple files', async ({ page, openRecorder, browserName, asset }) => {
@ -153,19 +153,19 @@ test.describe('cli codegen', () => {
const sources = await recorder.waitForOutput('JavaScript', 'setInputFiles');
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[type=\"file\"]').setInputFiles(['file-to-upload.txt', 'file-to-upload-2.txt']);`);
await page.getByRole('textbox').setInputFiles(['file-to-upload.txt', 'file-to-upload-2.txt']);`);
expect(sources.get('Java').text).toContain(`
page.locator("input[type=\\\"file\\\"]").setInputFiles(new Path[] {Paths.get("file-to-upload.txt"), Paths.get("file-to-upload-2.txt")});`);
page.getByRole(AriaRole.TEXTBOX).setInputFiles(new Path[] {Paths.get("file-to-upload.txt"), Paths.get("file-to-upload-2.txt")});`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[type=\\\"file\\\"]\").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
page.get_by_role("textbox").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[type=\\\"file\\\"]\").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
await page.get_by_role("textbox").set_input_files([\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[type=\\\"file\\\"]\").SetInputFilesAsync(new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`);
await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`);
});
test('should clear files', async ({ page, openRecorder, browserName, asset }) => {
@ -185,20 +185,19 @@ test.describe('cli codegen', () => {
const sources = await recorder.waitForOutput('JavaScript', 'setInputFiles');
expect(sources.get('JavaScript').text).toContain(`
await page.locator('input[type=\"file\"]').setInputFiles([]);`);
await page.getByRole('textbox').setInputFiles([]);`);
expect(sources.get('Java').text).toContain(`
page.locator("input[type=\\\"file\\\"]").setInputFiles(new Path[0]);`);
page.getByRole(AriaRole.TEXTBOX).setInputFiles(new Path[0]);`);
expect(sources.get('Python').text).toContain(`
page.locator(\"input[type=\\\"file\\\"]\").set_input_files([])`);
page.get_by_role("textbox").set_input_files([])`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"input[type=\\\"file\\\"]\").set_input_files([])`);
await page.get_by_role("textbox").set_input_files([])`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"input[type=\\\"file\\\"]\").SetInputFilesAsync(new[] { });`);
await page.GetByRole(AriaRole.Textbox).SetInputFilesAsync(new[] { });`);
});
test('should download files', async ({ page, openRecorder, server }) => {
@ -381,20 +380,20 @@ test.describe('cli codegen', () => {
await recorder.waitForOutput('JavaScript', 'TextB');
const sources = recorder.sources();
expect(sources.get('JavaScript').text).toContain(`await page1.locator('input').fill('TextA');`);
expect(sources.get('JavaScript').text).toContain(`await page2.locator('input').fill('TextB');`);
expect(sources.get('JavaScript').text).toContain(`await page1.locator('#name').fill('TextA');`);
expect(sources.get('JavaScript').text).toContain(`await page2.locator('#name').fill('TextB');`);
expect(sources.get('Java').text).toContain(`page1.locator("input").fill("TextA");`);
expect(sources.get('Java').text).toContain(`page2.locator("input").fill("TextB");`);
expect(sources.get('Java').text).toContain(`page1.locator("#name").fill("TextA");`);
expect(sources.get('Java').text).toContain(`page2.locator("#name").fill("TextB");`);
expect(sources.get('Python').text).toContain(`page1.locator(\"input\").fill(\"TextA\")`);
expect(sources.get('Python').text).toContain(`page2.locator(\"input\").fill(\"TextB\")`);
expect(sources.get('Python').text).toContain(`page1.locator("#name").fill("TextA")`);
expect(sources.get('Python').text).toContain(`page2.locator("#name").fill("TextB")`);
expect(sources.get('Python Async').text).toContain(`await page1.locator(\"input\").fill(\"TextA\")`);
expect(sources.get('Python Async').text).toContain(`await page2.locator(\"input\").fill(\"TextB\")`);
expect(sources.get('Python Async').text).toContain(`await page1.locator("#name").fill("TextA")`);
expect(sources.get('Python Async').text).toContain(`await page2.locator("#name").fill("TextB")`);
expect(sources.get('C#').text).toContain(`await page1.Locator(\"input\").FillAsync(\"TextA\");`);
expect(sources.get('C#').text).toContain(`await page2.Locator(\"input\").FillAsync(\"TextB\");`);
expect(sources.get('C#').text).toContain(`await page1.Locator("#name").FillAsync("TextA");`);
expect(sources.get('C#').text).toContain(`await page2.Locator("#name").FillAsync("TextB");`);
});
test('click should emit events in order', async ({ page, openRecorder }) => {
@ -429,7 +428,7 @@ test.describe('cli codegen', () => {
recorder.waitForActionPerformed(),
page.click('input')
]);
expect(models.hovered).toBe('input[name="updated"]');
expect(models.hovered).toBe('#checkbox');
});
test('should update active model on action', async ({ page, openRecorder, browserName, headless }) => {
@ -441,7 +440,7 @@ test.describe('cli codegen', () => {
recorder.waitForActionPerformed(),
page.click('input')
]);
expect(models.active).toBe('input[name="updated"]');
expect(models.active).toBe('#checkbox');
});
test('should check input with chaning id', async ({ page, openRecorder }) => {
@ -502,7 +501,7 @@ test.describe('cli codegen', () => {
await recorder.setContentAndWait(`<textarea spellcheck=false id="textarea" name="name" oninput="console.log(textarea.value)"></textarea>`);
const locator = await recorder.focusElement('textarea');
expect(locator).toBe(`locator('textarea[name="name"]')`);
expect(locator).toBe(`locator('#textarea')`);
const [message, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error'),
@ -511,19 +510,19 @@ test.describe('cli codegen', () => {
]);
expect(sources.get('JavaScript').text).toContain(`
await page.locator('textarea[name="name"]').fill('Hello\\'"\`\\nWorld');`);
await page.locator('#textarea').fill('Hello\\'"\`\\nWorld');`);
expect(sources.get('Java').text).toContain(`
page.locator("textarea[name=\\\"name\\\"]").fill("Hello'\\"\`\\nWorld");`);
page.locator("#textarea").fill("Hello'\\"\`\\nWorld");`);
expect(sources.get('Python').text).toContain(`
page.locator(\"textarea[name=\\\"name\\\"]\").fill(\"Hello'\\"\`\\nWorld\")`);
page.locator("#textarea").fill(\"Hello'\\"\`\\nWorld\")`);
expect(sources.get('Python Async').text).toContain(`
await page.locator(\"textarea[name=\\\"name\\\"]\").fill(\"Hello'\\"\`\\nWorld\")`);
await page.locator("#textarea").fill(\"Hello'\\"\`\\nWorld\")`);
expect(sources.get('C#').text).toContain(`
await page.Locator(\"textarea[name=\\\"name\\\"]\").FillAsync(\"Hello'\\"\`\\nWorld\");`);
await page.Locator("#textarea").FillAsync(\"Hello'\\"\`\\nWorld\");`);
expect(message.text()).toBe('Hello\'\"\`\nWorld');
});

View File

@ -304,29 +304,6 @@ it.describe(() => {
});
it('reverse engineer internal:has-text locators', async ({ page }) => {
await page.setContent(`
<div>Hello world</div>
<a>Hello <span>world</span></a>
<a>Goodbye <span>world</span></a>
`);
expect.soft(await generateForNode(page, 'a:has-text("Hello")')).toEqual({
csharp: 'Locator("a").Filter(new() { HasTextString = "Hello world" })',
java: 'locator("a").filter(new Locator.LocatorOptions().setHasText("Hello world"))',
javascript: `locator('a').filter({ hasText: 'Hello world' })`,
python: 'locator("a").filter(has_text="Hello world")',
});
await page.setContent(`
<div>Hello <span>world</span></div>
<b>Hello <span mark=1>world</span></b>
`);
expect.soft(await generateForNode(page, '[mark="1"]')).toEqual({
csharp: 'Locator("b").Filter(new() { HasTextString = "Hello world" }).Locator("span")',
java: 'locator("b").filter(new Locator.LocatorOptions().setHasText("Hello world")).locator("span")',
javascript: `locator('b').filter({ hasText: 'Hello world' }).locator('span')`,
python: 'locator("b").filter(has_text="Hello world").locator("span")',
});
await page.setContent(`
<div>Hello <span>world</span></div>
<div>Goodbye <span mark=1>world</span></div>

View File

@ -78,7 +78,7 @@ it.describe('selector generator', () => {
<select><option>foo</option></select>
<select mark=1><option>bar</option></select>
`);
expect(await generate(page, '[mark="1"]')).toBe('select >> nth=1');
expect(await generate(page, '[mark="1"]')).toBe('internal:role=combobox >> nth=1');
});
it('should use ordinal for identical nodes', async ({ page }) => {
@ -167,7 +167,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-text="Hello world"i >> span`);
expect(await generate(page, '[mark="1"]')).toBe(`b >> internal:text="world"i`);
});
it('should use parent text', async ({ page }) => {
@ -246,21 +246,25 @@ it.describe('selector generator', () => {
<input value="two" mark="1">
<input value="three">
`);
expect(await generate(page, 'input[mark="1"]')).toBe('input >> nth=1');
expect(await generate(page, 'input[mark="1"]')).toBe('internal:role=textbox >> nth=1');
});
it.describe('should prioritise input element attributes correctly', () => {
it('name', async ({ page }) => {
it.describe('should prioritise attributes correctly', () => {
it('role', async ({ page }) => {
await page.setContent(`<input name="foobar" type="text"/>`);
expect(await generate(page, 'input')).toBe('input[name="foobar"]');
expect(await generate(page, 'input')).toBe('internal:role=textbox');
});
it('placeholder', async ({ page }) => {
await page.setContent(`<input placeholder="foobar" type="text"/>`);
expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]');
});
it('name', async ({ page }) => {
await page.setContent(`<input role="presentation" aria-hidden="false" name="foobar" type="date"/>`);
expect(await generate(page, 'input')).toBe('input[name="foobar"]');
});
it('type', async ({ page }) => {
await page.setContent(`<input type="text"/>`);
expect(await generate(page, 'input')).toBe('input[type="text"]');
await page.setContent(`<input role="presentation" aria-hidden="false" type="checkbox"/>`);
expect(await generate(page, 'input')).toBe('input[type="checkbox"]');
});
});
@ -282,7 +286,7 @@ it.describe('selector generator', () => {
const input = document.createElement('input');
shadowRoot.appendChild(input);
});
expect(await generate(page, 'input')).toBe('input');
expect(await generate(page, 'input')).toBe('internal:role=textbox');
});
it('should match in deep shadow dom', async ({ page }) => {
@ -300,7 +304,7 @@ it.describe('selector generator', () => {
input2.setAttribute('value', 'foo');
shadowRoot2.appendChild(input2);
});
expect(await generate(page, 'input[value=foo]')).toBe('input >> nth=2');
expect(await generate(page, 'input[value=foo]')).toBe('internal:role=textbox >> nth=2');
});
it('should work in dynamic iframes without navigation', async ({ page }) => {
@ -352,7 +356,7 @@ it.describe('selector generator', () => {
});
it('should work without CSS.escape', async ({ page }) => {
await page.setContent(`<button></button>`);
await page.setContent(`<button role="presentation" aria-hidden="false"></button>`);
await page.$eval('button', button => {
delete window.CSS.escape;
button.setAttribute('name', '-tricky\u0001name');
@ -397,4 +401,9 @@ it.describe('selector generator', () => {
await page.setContent(`<label for=target>Coun"try</label><input id=target>`);
expect(await generate(page, 'input')).toBe('internal:label="Coun\\\"try"i');
});
it('should prefer role other input[type]', async ({ page }) => {
await page.setContent(`<input type=checkbox><div data-testid=wrapper><input type=checkbox></div>`);
expect(await generate(page, '[data-testid=wrapper] > input')).toBe('internal:testid=[data-testid="wrapper"s] >> internal:role=checkbox');
});
});

View File

@ -34,8 +34,8 @@ it('should fail page.fill in strict mode', async ({ page }) => {
await page.setContent(`<input></input><div><input></input></div>`);
const error = await page.fill('input', 'text', { strict: true }).catch(e => e);
expect(error.message).toContain('strict mode violation');
expect(error.message).toContain(`1) <input/> aka locator('input').first()`);
expect(error.message).toContain(`2) <input/> aka locator('div input')`);
expect(error.message).toContain(`1) <input/> aka getByRole('textbox').first()`);
expect(error.message).toContain(`2) <input/> aka locator('div').getByRole('textbox')`);
});
it('should fail page.$ in strict mode', async ({ page }) => {