/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { contextTest as it, expect } from '../config/browserTest'; import type { Page, Frame } from 'playwright-core'; async function generate(pageOrFrame: Page | Frame, target: string): Promise { return pageOrFrame.$eval(target, e => (window as any).playwright.selector(e)); } it.describe('selector generator', () => { it.skip(({ mode }) => mode !== 'default'); it.beforeEach(async ({ context }) => { await (context as any)._enableRecorder({ language: 'javascript' }); }); it('should prefer button over inner span', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('#clickme'); }); it('should prefer role=button over inner span', async ({ page }) => { await page.setContent(`
`); expect(await generate(page, 'div')).toBe('internal:role=button'); }); it('should generate text and normalize whitespace', async ({ page }) => { await page.setContent(`
Text some\n\n\n more \t text
`); expect(await generate(page, 'div')).toBe('internal:text="Text some more text"i'); }); it('should not escape spaces inside named attr selectors', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Foo b ar\"i]'); }); it('should generate text for ', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'input')).toBe('internal:role=button[name=\"Click me\"i]'); }); it('should trim text', async ({ page }) => { await page.setContent(`
Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789
`); expect(await generate(page, 'div')).toBe('internal:text="Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text012345"i'); }); it('should not escape text with >>', async ({ page }) => { await page.setContent(`
text>>text
`); expect(await generate(page, 'div')).toBe('internal:text="text>>text"i'); }); it('should escape text with quote', async ({ page }) => { await page.setContent(`
text"text
`); expect(await generate(page, 'div')).toBe('internal:text="text\\\"text"i'); }); it('should escape text with slash', async ({ page }) => { await page.setContent(`
/text
`); expect(await generate(page, 'div')).toBe('internal:text="\/text"i'); }); it('should not use text for select', async ({ page }) => { await page.setContent(` `); expect(await generate(page, '[mark="1"]')).toBe('internal:role=combobox >> nth=1'); }); it('should use ordinal for identical nodes', async ({ page }) => { await page.setContent(`
Text
Text
Text
Text
`); expect(await generate(page, 'div[mark="1"]')).toBe(`internal:text="Text"i >> nth=2`); }); it('should prefer data-testid', async ({ page }) => { await page.setContent(`
Text
Text
Text
Text
`); expect(await generate(page, '[data-testid="a"]')).toBe('internal:testid=[data-testid=\"a\"s]'); }); it('should use data-testid in strict errors', async ({ page, playwright }) => { playwright.selectors.setTestIdAttribute('data-custom-id'); await page.setContent(`
`); const error = await page.locator('.foo').hover().catch(e => e); expect(error.message).toContain('strict mode violation'); expect(error.message).toContain('
{ await page.setContent(`
Text
Text
`); expect(await generate(page, 'div[mark="1"]')).toBe('internal:testid=[data-testid=\"a\"s] >> nth=0'); }); it('should handle second non-unique data-testid', async ({ page }) => { await page.setContent(`
Text
Text
`); expect(await generate(page, 'div[mark="1"]')).toBe(`internal:testid=[data-testid=\"a\"s] >> nth=1`); }); it('should use readable id', async ({ page }) => { await page.setContent(`
`); expect(await generate(page, 'div[mark="1"]')).toBe('#first-item'); }); it('should not use generated id', async ({ page }) => { await page.setContent(`
`); expect(await generate(page, 'div[mark="1"]')).toBe(`div >> nth=1`); }); it('should use internal:has-text', async ({ page }) => { await page.setContent(`
Hello world
Hello world Goodbye world `); expect(await generate(page, 'a:has-text("Hello")')).toBe(`a >> internal:has-text="Hello world"i`); }); it('should use internal:has-text with regexp', async ({ page }) => { await page.setContent(` Hello world
Hello world
extra
Goodbye world `); expect(await generate(page, 'div div')).toBe(`div >> internal:has-text=/^Hello world$/`); }); it('should chain text after parent', async ({ page }) => { await page.setContent(`
Hello world
Hello world `); expect(await generate(page, '[mark="1"]')).toBe(`b >> internal:text="world"i`); }); it('should use parent text', async ({ page }) => { await page.setContent(`
Hello world
Goodbye world
`); expect(await generate(page, '[mark="1"]')).toBe(`div >> internal:has-text="Goodbye world"i >> span`); }); it('should separate selectors by >>', async ({ page }) => { await page.setContent(`
Text
Text
`); expect(await generate(page, '#id > div')).toBe('#id >> internal:text="Text"i'); }); it('should trim long text', async ({ page }) => { await page.setContent(`
Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on
Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on
`); expect(await generate(page, '#id > div')).toBe(`#id >> internal:text="Text that goes on and on and on and on and on and on and on and on and on and on"i`); }); it('should use nested ordinals', async ({ page }) => { await page.setContent(`
`); expect(await generate(page, 'c[mark="1"]')).toBe('b:nth-child(2) > c'); }); it('should properly join child selectors under nested ordinals', async ({ page }) => { await page.setContent(`
`); expect(await generate(page, 'c[mark="1"]')).toBe('b:nth-child(2) > div > c'); }); it('should not use input[value]', async ({ page }) => { await page.setContent(` `); expect(await generate(page, 'input[mark="1"]')).toBe('internal:role=textbox >> nth=1'); }); it.describe('should prioritise attributes correctly', () => { it('role', async ({ page }) => { await page.setContent(``); 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="checkbox"]'); }); }); it('should find text in shadow dom', async ({ page }) => { await page.setContent(`
`); await page.$eval('div', div => { const shadowRoot = div.attachShadow({ mode: 'open' }); const span = document.createElement('span'); span.textContent = 'Target'; shadowRoot.appendChild(span); }); expect(await generate(page, 'span')).toBe('internal:text="Target"i'); }); it('should match in shadow dom', async ({ page }) => { await page.setContent(`
`); await page.$eval('div', div => { const shadowRoot = div.attachShadow({ mode: 'open' }); const input = document.createElement('input'); shadowRoot.appendChild(input); }); expect(await generate(page, 'input')).toBe('internal:role=textbox'); }); it('should match in deep shadow dom', async ({ page }) => { await page.setContent(`
`); await page.$eval('div', div1 => { const shadowRoot1 = div1.attachShadow({ mode: 'open' }); const input1 = document.createElement('input'); shadowRoot1.appendChild(input1); const divExtra3 = document.createElement('div'); shadowRoot1.append(divExtra3); const div2 = document.createElement('div'); shadowRoot1.append(div2); const shadowRoot2 = div2.attachShadow({ mode: 'open' }); const input2 = document.createElement('input'); input2.setAttribute('value', 'foo'); shadowRoot2.appendChild(input2); }); expect(await generate(page, 'input[value=foo]')).toBe('internal:role=textbox >> nth=2'); }); it('should work in dynamic iframes without navigation', async ({ page }) => { await page.setContent(`
`); const [frame] = await Promise.all([ page.waitForEvent('frameattached'), page.evaluate(() => { return new Promise(f => { const iframe = document.createElement('iframe'); iframe.onload = () => { iframe.contentDocument.body.innerHTML = '
Target
'; f(); }; document.body.appendChild(iframe); }); }), ]); expect(await generate(frame, 'div')).toBe('internal:text="Target"i'); }); it('should use the name attributes for elements that can have it', async ({ page }) => { for (const tagName of ['button', 'input', 'textarea']) { await page.setContent(`
<${tagName} name="foo"><${tagName} name="bar">
`); expect(await generate(page, '[name=bar]')).toBe(`${tagName}[name="bar"]`); } await page.setContent(``); expect(await generate(page, '[name=bar]')).toBe(`iframe[name="bar"]`); await page.setContent(``); expect(await generate(page, '[name=bar]')).toBe(`frame[name="bar"]`); }); it('should work with tricky attributes', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('[id="this\\:is-my-tricky\\.id"]'); await page.setContent(``); expect(await generate(page, 'ng\\:switch')).toBe('ng\\:switch'); await page.setContent(``); await page.$eval('button', button => button.setAttribute('aria-label', `!#'!?:`)); expect(await generate(page, 'button')).toBe(`internal:role=button[name="!#'!?:"i]`); expect(await page.$(`role=button[name="!#'!?:"]`)).toBeTruthy(); await page.setContent(`
`); await page.$eval('div', div => div.id = `!#'!?:`); expect(await generate(page, 'div')).toBe("[id=\"\\!\\#\\'\\!\\?\\:\"]"); }); it('should work without CSS.escape', async ({ page }) => { await page.setContent(``); await page.$eval('button', button => { delete window.CSS.escape; button.setAttribute('name', '-tricky\u0001name'); }); expect(await generate(page, 'button')).toBe(`button[name="-tricky\\1 name"]`); }); it('should ignore empty aria-label for candidate consideration', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('#buttonId'); }); it('should accept valid aria-label for candidate consideration', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"i]'); }); it('should ignore empty role for candidate consideration', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('#buttonId'); }); it('should not accept invalid role for candidate consideration', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('#buttonId'); }); it('should ignore empty data-test-id for candidate consideration', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('#buttonId'); }); it('should accept valid data-test-id for candidate consideration', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'button')).toBe('[data-test-id="testId"]'); }); it('should generate label selector', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'input')).toBe('internal:label="Country"i'); 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'); }); it('should generate title selector', async ({ page }) => { await page.setContent(`
`); expect(await generate(page, 'button')).toBe('internal:attr=[title=\"Send to\"i]'); }); it('should generate exact text when necessary', async ({ page }) => { await page.setContent(` Text Text and more `); expect(await generate(page, 'span')).toBe('internal:text=\"Text\"s'); }); it('should generate exact title when necessary', async ({ page }) => { await page.setContent(` `); expect(await generate(page, 'span')).toBe('internal:attr=[title=\"Text\"s]'); }); it('should generate exact placeholder when necessary', async ({ page }) => { await page.setContent(` `); expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Text\"s]'); }); it('should generate exact role when necessary', async ({ page }) => { await page.setContent(` Text Text and more `); expect(await generate(page, 'img')).toBe('internal:role=img[name=\"Text\"s]'); }); it('should generate exact label when necessary', async ({ page }) => { await page.setContent(` `); expect(await generate(page, 'input')).toBe('internal:label=\"Text\"s'); }); });