From 7eae07322944dce17816af3a5c1f6536f6092824 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 7 Aug 2026 10:59:23 -0700 Subject: [PATCH 1/5] feat(locator): page-free `by` locators resolved with page.get() (#42157) --- docs/src/api/class-by.md | 254 ++++++++++ docs/src/api/class-frame.md | 13 + docs/src/api/class-framelocator.md | 13 + docs/src/api/class-locator.md | 13 + docs/src/api/class-page.md | 13 + docs/src/api/class-playwright.md | 18 + docs/src/api/params.md | 32 ++ docs/src/locators.md | 41 ++ packages/isomorphic/by.ts | 145 ++++++ packages/playwright-client/types/types.d.ts | 473 ++++++++++++++++++ packages/playwright-core/index.mjs | 1 + packages/playwright-core/src/client/frame.ts | 6 + .../playwright-core/src/client/locator.ts | 10 + packages/playwright-core/src/client/page.ts | 5 + .../playwright-core/src/client/playwright.ts | 4 + packages/playwright-core/types/types.d.ts | 473 ++++++++++++++++++ packages/playwright/test.mjs | 1 + tests/page/locator-get.spec.ts | 127 +++++ .../playwright-test/playwright.config.spec.ts | 27 + 19 files changed, 1669 insertions(+) create mode 100644 docs/src/api/class-by.md create mode 100644 packages/isomorphic/by.ts create mode 100644 tests/page/locator-get.spec.ts diff --git a/docs/src/api/class-by.md b/docs/src/api/class-by.md new file mode 100644 index 0000000000000..6e3831443f5d7 --- /dev/null +++ b/docs/src/api/class-by.md @@ -0,0 +1,254 @@ +# class: By +* since: v1.63 +* langs: js + +[By] describes an element without being bound to a [Page] or a [Frame]. It is built with the +top-level [`property: Playwright.by`] object and turned into a regular [Locator] with +[`method: Page.get`], [`method: Frame.get`] or [`method: Locator.get`]. + +Since a [By] carries no page, it can be defined once at module scope and reused by every test, +which makes it a natural fit for page objects. + +**Usage** + +```js +import { by, expect, test } from '@playwright/test'; + +const saveButton = by.role('button', { name: 'Save' }); +const todoItems = by.testId('todo-list').role('listitem'); + +test('saves a todo', async ({ page }) => { + await page.get(saveButton).click(); + await expect(page.get(todoItems)).toHaveCount(1); +}); +``` + +A [By] chain resolves to the same element as the matching [Locator] chain, so +`page.get(by.testId('list').text('Row'))` and `page.getByTestId('list').getByText('Row')` are +interchangeable. Chaining composes rather than replaces: `page.get(outer.get(inner))` and +`page.get(outer).get(inner)` describe the same element. + +## method: By.altText +* since: v1.63 +- returns: <[By]> + +Matches a descendant element by its `alt` text. + +### param: By.altText.text = %%-locator-get-by-text-text-%% +* since: v1.63 + +### option: By.altText.exact = %%-locator-get-by-text-exact-%% +* since: v1.63 + +## method: By.and +* since: v1.63 +- returns: <[By]> + +Narrows down the match to elements that match both this and the given [By]. + +**Usage** + +```js +const saveButton = by.role('button').and(by.title('Subscribe')); +``` + +### param: By.and.by +* since: v1.63 +- `by` <[By]> + +Additional locator to match. + +## method: By.describe +* since: v1.63 +- returns: <[By]> + +Describes the element, the description is used in the trace viewer and the reports. + +### param: By.describe.description +* since: v1.63 +- `description` <[string]> + +Locator description. + +## method: By.filter +* since: v1.63 +- returns: <[By]> + +Narrows down the match according to the options, for example filters by text. It can be chained to +filter multiple times. + +**Usage** + +```js +const rowWithButton = by.get('tr') + .filter({ hasText: 'text in column 1' }) + .filter({ has: by.role('button', { name: 'column 2 button' }) }); +``` + +### option: By.filter.has +* since: v1.63 +- `has` <[By]> + +Narrows down the results to those which contain elements matching this relative [By]. The inner +[By] is queried starting with the outer match, not the document root. + +### option: By.filter.hasNot +* since: v1.63 +- `hasNot` <[By]> + +Matches elements that do not contain an element matching this relative [By]. The inner [By] is +queried starting with the outer match, not the document root. + +### option: By.filter.hasNotText = %%-locator-option-has-not-text-%% +* since: v1.63 + +### option: By.filter.hasText = %%-locator-option-has-text-%% +* since: v1.63 + +### option: By.filter.visible = %%-locator-option-visible-%% +* since: v1.63 + +## method: By.first +* since: v1.63 +- returns: <[By]> + +Matches the first matching element. + +## method: By.get +* since: v1.63 +- returns: <[By]> + +Matches a descendant element by a selector or by another [By]. + +**Usage** + +```js +const firstCell = by.get('table').get('td').first(); + +const listItem = by.role('listitem'); +const unread = by.testId('inbox').get(listItem).filter({ hasText: 'Unread' }); +``` + +**Details** + +Passing a [By] composes rather than replaces, so `outer.get(inner.get(innermost))` and +`outer.get(inner).get(innermost)` describe the same element. + +### param: By.get.selectorOrBy +* since: v1.63 +- `selectorOrBy` <[string]|[By]> + +A selector or a [By] to match inside this one. + +## method: By.label +* since: v1.63 +- returns: <[By]> + +Matches an input element by the text of the associated `