diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 3a1452f14e..6e0fc548de 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -34,6 +34,7 @@ const kExactPenalty = kTextScoreRange / 2; const kTestIdScore = 1; // testIdAttributeName const kOtherTestIdScore = 2; // other data-test* attributes +const kIframeByAttributeScore = 10; const kBeginPenalizedScore = 50; const kPlaceholderScore = 100; @@ -174,14 +175,40 @@ 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: kTestIdScore }); + + // Start of generic candidates which are compatible for Locators and FrameLocators: 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: kOtherTestIdScore }); } + const idAttr = element.getAttribute('id'); + if (idAttr && !isGuidLike(idAttr)) + candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore }); + + candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore }); + + if (element.nodeName === 'IFRAME') { + for (const attribute of ['name', 'title']) { + if (element.getAttribute(attribute)) + candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[${attribute}=${quoteAttributeValue(element.getAttribute(attribute)!)}]`, score: kIframeByAttributeScore }); + } + + // Get via testIdAttributeName via CSS selector. + if (element.getAttribute(testIdAttributeName)) + candidates.push({ engine: 'css', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true!)}]`, score: kTestIdScore }); + + penalizeScoreForLength([candidates]); + return candidates; + } + + // Everything after that are candidates that are not applicable to iframes and designed for Locators only(getBy* methods): + + // Get via testIdAttributeName via GetByTestId(). + if (element.getAttribute(testIdAttributeName)) + candidates.push({ engine: 'internal:testid', selector: `[${testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(testIdAttributeName)!, true)}]`, score: kTestIdScore }); + if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { const input = element as HTMLInputElement | HTMLTextAreaElement; if (input.placeholder) { @@ -228,11 +255,6 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, testI 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: kCSSIdScore }); - - candidates.push({ engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore }); penalizeScoreForLength([candidates]); return candidates; } diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index f1b0a7e25d..856fc06feb 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -203,6 +203,134 @@ test.describe('cli codegen', () => { await page.FrameByUrl(\"about:blank\").GetByText(\"HelloNameAnonymous\").ClickAsync();`); }); + test('should generate frame locators with title attribute', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(` + + `, server.EMPTY_PAGE, 1); + + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'Click me'), + page.frameLocator('[title="hello world"]').getByRole('button', { name: 'Click me' }).click(), + ]); + + expect(sources.get('JavaScript').text).toContain( + `await page.frameLocator('iframe[title="hello world"]').getByRole('button', { name: 'Click me' }).click();` + ); + + expect(sources.get('Java').text).toContain( + `page.frameLocator(\"iframe[title=\\\"hello world\\\"]\").getByRole(AriaRole.BUTTON, new FrameLocator.GetByRoleOptions().setName(\"Click me\")).click();` + ); + + expect(sources.get('Python').text).toContain( + `page.frame_locator(\"iframe[title=\\\"hello world\\\"]\").get_by_role(\"button\", name=\"Click me\").click()` + ); + + expect(sources.get('Python Async').text).toContain( + `await page.frame_locator("iframe[title=\\\"hello world\\\"]").get_by_role("button", name="Click me").click()` + ); + + expect(sources.get('C#').text).toContain( + `await page.FrameLocator("iframe[title=\\\"hello world\\\"]").GetByRole(AriaRole.Button, new() { Name = "Click me" }).ClickAsync();` + ); + }); + + test('should generate frame locators with name attribute', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(` + + `, server.EMPTY_PAGE, 1); + + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'Click me'), + page.frameLocator('[name="hello world"]').getByRole('button', { name: 'Click me' }).click(), + ]); + + expect(sources.get('JavaScript').text).toContain( + `await page.frameLocator('iframe[name="hello world"]').getByRole('button', { name: 'Click me' }).click();` + ); + + expect(sources.get('Java').text).toContain( + `page.frameLocator(\"iframe[name=\\\"hello world\\\"]\").getByRole(AriaRole.BUTTON, new FrameLocator.GetByRoleOptions().setName(\"Click me\")).click();` + ); + + expect(sources.get('Python').text).toContain( + `page.frame_locator(\"iframe[name=\\\"hello world\\\"]\").get_by_role(\"button\", name=\"Click me\").click()` + ); + + expect(sources.get('Python Async').text).toContain( + `await page.frame_locator("iframe[name=\\\"hello world\\\"]").get_by_role("button", name="Click me").click()` + ); + + expect(sources.get('C#').text).toContain( + `await page.FrameLocator("iframe[name=\\\"hello world\\\"]").GetByRole(AriaRole.Button, new() { Name = "Click me" }).ClickAsync();` + ); + }); + + test('should generate frame locators with id attribute', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(` + + `, server.EMPTY_PAGE, 1); + + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'Click me'), + page.frameLocator('[id="hello-world"]').getByRole('button', { name: 'Click me' }).click(), + ]); + + expect(sources.get('JavaScript').text).toContain( + `await page.frameLocator('#hello-world').getByRole('button', { name: 'Click me' }).click();` + ); + + expect(sources.get('Java').text).toContain( + `page.frameLocator(\"#hello-world\").getByRole(AriaRole.BUTTON, new FrameLocator.GetByRoleOptions().setName(\"Click me\")).click();` + ); + + expect(sources.get('Python').text).toContain( + `page.frame_locator(\"#hello-world\").get_by_role(\"button\", name=\"Click me\").click()` + ); + + expect(sources.get('Python Async').text).toContain( + `await page.frame_locator("#hello-world").get_by_role("button", name="Click me").click()` + ); + + expect(sources.get('C#').text).toContain( + `await page.FrameLocator("#hello-world").GetByRole(AriaRole.Button, new() { Name = "Click me" }).ClickAsync();` + ); + }); + + test('should generate frame locators with testId', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(` + + `, server.EMPTY_PAGE, 1); + + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'my-testid'), + page.frameLocator('iframe[data-testid="my-testid"]').getByRole('button', { name: 'Click me' }).click(), + ]); + + expect(sources.get('JavaScript').text).toContain( + `await page.frameLocator('[data-testid="my-testid"]').getByRole('button', { name: 'Click me' }).click();` + ); + + expect(sources.get('Java').text).toContain( + `page.frameLocator(\"[data-testid=\\\"my-testid\\\"]\").getByRole(AriaRole.BUTTON, new FrameLocator.GetByRoleOptions().setName(\"Click me\")).click();` + ); + + expect(sources.get('Python').text).toContain( + `page.frame_locator(\"[data-testid=\\\"my-testid\\\"]\").get_by_role(\"button\", name=\"Click me\").click()` + ); + + expect(sources.get('Python Async').text).toContain( + `await page.frame_locator("[data-testid=\\\"my-testid\\\"]").get_by_role("button", name="Click me").click()` + ); + + expect(sources.get('C#').text).toContain( + `await page.FrameLocator("[data-testid=\\\"my-testid\\\"]").GetByRole(AriaRole.Button, new() { Name = "Click me" }).ClickAsync();` + ); + }); + test('should generate role locators undef frame locators', async ({ page, openRecorder, server }) => { const recorder = await openRecorder(); await recorder.setContentAndWait(`