diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index a1c16fb63f..b8767ad086 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index f37437cfc2..b2c05d7123 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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 { diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 73231a86cd..639d004834 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -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; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index a04d7c104c..93487a1934 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -27,7 +27,20 @@ type SelectorToken = { const cacheAllowText = new Map(); const cacheDisallowText = new Map(); + +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): 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; diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index 08a9c982ba..cc5b77cf89 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -212,7 +212,7 @@ test.describe('cli codegen', () => { await recorder.setContentAndWait(``); 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(``); 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(``); 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(``); 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(``); 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(``); 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(``); 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(``); 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(``); 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(``); 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(''); 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 }) => { diff --git a/tests/library/inspector/cli-codegen-2.spec.ts b/tests/library/inspector/cli-codegen-2.spec.ts index 7167f16a80..5ec4445633 100644 --- a/tests/library/inspector/cli-codegen-2.spec.ts +++ b/tests/library/inspector/cli-codegen-2.spec.ts @@ -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(``); 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'); }); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 2cc0deae50..4365519c8e 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -304,29 +304,6 @@ it.describe(() => { }); it('reverse engineer internal:has-text locators', async ({ page }) => { - await page.setContent(` -
Hello world
- Hello world - Goodbye world - `); - 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(` -
Hello world
- Hello world - `); - 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(`
Hello world
Goodbye world
diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index c3c99fff3a..2f7e6d5007 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -78,7 +78,7 @@ it.describe('selector generator', () => { `); - 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', () => {
Hello world
Hello world `); - 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', () => { `); - 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(``); - 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(``); expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]'); }); + it('name', async ({ page }) => { + await page.setContent(``); + expect(await generate(page, 'input')).toBe('input[name="foobar"]'); + }); it('type', async ({ page }) => { - await page.setContent(``); - expect(await generate(page, 'input')).toBe('input[type="text"]'); + await page.setContent(``); + 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(``); + await page.setContent(``); 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(``); expect(await generate(page, 'input')).toBe('internal:label="Coun\\\"try"i'); }); + + it('should prefer role other input[type]', async ({ page }) => { + await page.setContent(`
`); + expect(await generate(page, '[data-testid=wrapper] > input')).toBe('internal:testid=[data-testid="wrapper"s] >> internal:role=checkbox'); + }); }); diff --git a/tests/page/page-strict.spec.ts b/tests/page/page-strict.spec.ts index 90ccc3c558..be92892c6b 100644 --- a/tests/page/page-strict.spec.ts +++ b/tests/page/page-strict.spec.ts @@ -34,8 +34,8 @@ it('should fail page.fill in strict mode', async ({ page }) => { await page.setContent(`
`); const error = await page.fill('input', 'text', { strict: true }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain(`1) aka locator('input').first()`); - expect(error.message).toContain(`2) aka locator('div input')`); + expect(error.message).toContain(`1) aka getByRole('textbox').first()`); + expect(error.message).toContain(`2) aka locator('div').getByRole('textbox')`); }); it('should fail page.$ in strict mode', async ({ page }) => {