Real applications do not fit into one flat document. A payment form comes in an iframe from Stripe, a map widget from a third-party service, an OAuth screen opens on someone else's domain, and a design system hides its markup inside web components and the shadow DOM. A test that can only click elements of the main page stumbles on exactly the most expensive scenarios - payment and sign-in. So professional E2E must be able to step across these boundaries.
It is worth starting with how an iframe differs from the shadow DOM, because the approaches differ. An iframe is a nested separate document with its own tree, often from another domain; an ordinary locator of the main page does not reach into it. The shadow DOM is an encapsulated subtree inside the same document, used by web components; it is not a separate document but a hidden part of the current one. The first requires an explicit entry, the second Playwright mostly handles by itself.
For an iframe there is frameLocator. page.frameLocator('iframe[title="Card payment"]') returns an object inside which the same semantic locators work as on the page: getByRole, getByLabel, getByText. That is, inside the frame you describe elements exactly as outside - the only difference is that you first explicitly state which frame to enter. Nested frames are addressed with a chain of frameLocator, step by step.
The frame itself is addressed by a stable trait, not by an ordinal number. The title attribute, the name or the frame's accessible name will survive a rearrangement of widgets on the page, whereas an nth-child over the list of iframes breaks at any change of surroundings. This is the same user-contract principle as with ordinary locators: describe the frame by its stable meaning, not by its position in the markup.
It helps to see both techniques side by side once - entering a frame and working with a web component - so as not to confuse them while writing a scenario. Below is a click on a button inside a payment iframe and access to an element inside a custom component; you return to this pair when a main-page locator hits the boundary of a nested document or encapsulation.
With the shadow DOM the situation is simpler, and this is an important practical relief. Playwright's semantic locators by role and text pierce the open shadow DOM by default: getByRole and getByText find an element inside a web component just as in ordinary markup, with no separate API. For web-component design systems this means tests are written the usual way, and encapsulation stays an implementation detail the scenario need not know about.
Encapsulation has a limit to keep in mind. Piercing works for an open shadow root; a closed one (mode: 'closed') is inaccessible to Playwright and to the user programmatically alike - if a component deliberately closed its subtree, its internals cannot be reached from the outside. This is not a tool limitation but a platform property: a closed shadow root is hidden from all external code by definition.
The typical failures here are recognizable. Looking for a payment-form element with an ordinary page locator and getting an eternal timeout without realizing it is in an iframe and frameLocator is needed. Addressing a frame by an ordinal number, because of which the test breaks when a neighboring widget is added. And trying to break into a closed shadow root instead of checking the component through its public behavior. Enter an iframe via frameLocator by a stable trait, and trust the open shadow DOM to semantic locators.
// iframe: first enter the frame by a stable trait, then ordinary locators
const payment = page.frameLocator('iframe[title="Card payment"]')
await payment.getByLabel('Card number').fill('4242 4242 4242 4242')
await payment.getByRole('button', { name: 'Pay' }).click()
// open shadow DOM: a semantic locator pierces it on its own
await page.getByRole('button', { name: 'Add to cart' }).click()