19 KiB
Executable file
Iframes and Shadow DOM
When to use: When your application embeds content in
<iframe>elements (payment widgets, third-party embeds, legacy modules) or uses Web Components with Shadow DOM (design systems, custom elements, Salesforce Lightning). Prerequisites: core/locators.md, core/assertions-and-waiting.md
Quick Reference
// Iframes — use frameLocator to reach inside
const frame = page.frameLocator('iframe[title="Payment"]');
await frame.getByLabel('Card number').fill('4242424242424242');
// Nested iframes — chain frameLocator calls
const inner = page.frameLocator('#outer').frameLocator('#inner');
await inner.getByRole('button', { name: 'Submit' }).click();
// Shadow DOM — Playwright pierces open shadow roots automatically
await page.getByRole('button', { name: 'Toggle' }).click(); // auto-pierces
await page.locator('my-component').getByText('Hello').click(); // auto-pierces
Patterns
Basic iframe Interaction with frameLocator()
Use when: You need to interact with content inside an <iframe> -- payment forms, embedded editors, captchas, third-party widgets.
Avoid when: The content is in the main frame. Never use frameLocator for Shadow DOM.
frameLocator() returns a locator-like object scoped to the iframe's document. All standard locator methods work inside it.
TypeScript
import { test, expect } from '@playwright/test';
test('complete payment inside Stripe iframe', async ({ page }) => {
await page.goto('/checkout');
// Locate the iframe by its title, name, or a CSS selector
const paymentFrame = page.frameLocator('iframe[title="Secure payment"]');
// Use normal locators inside the frame
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
await paymentFrame.getByRole('button', { name: 'Pay' }).click();
// Assertion on content inside the iframe
await expect(paymentFrame.getByText('Payment successful')).toBeVisible();
// Assertion on the parent page (outside the iframe)
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('complete payment inside Stripe iframe', async ({ page }) => {
await page.goto('/checkout');
const paymentFrame = page.frameLocator('iframe[title="Secure payment"]');
await paymentFrame.getByLabel('Card number').fill('4242424242424242');
await paymentFrame.getByLabel('Expiry').fill('12/28');
await paymentFrame.getByLabel('CVC').fill('123');
await paymentFrame.getByRole('button', { name: 'Pay' }).click();
await expect(paymentFrame.getByText('Payment successful')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Selecting the Right iframe
Use when: Multiple iframes exist on the page or the iframe has no obvious identifier.
Avoid when: There is only one iframe and a simple page.frameLocator('iframe') works.
TypeScript
import { test, expect } from '@playwright/test';
test('interact with the correct iframe among many', async ({ page }) => {
await page.goto('/dashboard');
// By title attribute (best — accessible and stable)
const chatFrame = page.frameLocator('iframe[title="Live chat"]');
// By name attribute
const reportFrame = page.frameLocator('iframe[name="analytics-report"]');
// By src URL pattern
const adFrame = page.frameLocator('iframe[src*="ads.example.com"]');
// By index — when nothing else works (0-indexed)
const thirdFrame = page.frameLocator('iframe').nth(2);
// By parent container — scope to a section first
const sidebar = page.getByRole('complementary');
const sidebarFrame = sidebar.frameLocator('iframe');
await chatFrame.getByRole('textbox', { name: 'Message' }).fill('Help');
await expect(reportFrame.getByRole('heading')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('interact with the correct iframe among many', async ({ page }) => {
await page.goto('/dashboard');
const chatFrame = page.frameLocator('iframe[title="Live chat"]');
const reportFrame = page.frameLocator('iframe[name="analytics-report"]');
const adFrame = page.frameLocator('iframe[src*="ads.example.com"]');
const thirdFrame = page.frameLocator('iframe').nth(2);
await chatFrame.getByRole('textbox', { name: 'Message' }).fill('Help');
await expect(reportFrame.getByRole('heading')).toBeVisible();
});
Nested Iframes
Use when: An iframe contains another iframe (common in complex widget hierarchies, ad containers, or embedded third-party tools). Avoid when: There is only one level of iframe nesting.
TypeScript
import { test, expect } from '@playwright/test';
test('interact with deeply nested iframe content', async ({ page }) => {
await page.goto('/embed-page');
// Chain frameLocator calls for each level of nesting
const outerFrame = page.frameLocator('#widget-container');
const innerFrame = outerFrame.frameLocator('#payment-form');
await innerFrame.getByLabel('Amount').fill('99.99');
await innerFrame.getByRole('button', { name: 'Confirm' }).click();
// Three levels deep
const deepFrame = page
.frameLocator('#level-1')
.frameLocator('#level-2')
.frameLocator('#level-3');
await expect(deepFrame.getByText('Success')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('interact with deeply nested iframe content', async ({ page }) => {
await page.goto('/embed-page');
const outerFrame = page.frameLocator('#widget-container');
const innerFrame = outerFrame.frameLocator('#payment-form');
await innerFrame.getByLabel('Amount').fill('99.99');
await innerFrame.getByRole('button', { name: 'Confirm' }).click();
const deepFrame = page
.frameLocator('#level-1')
.frameLocator('#level-2')
.frameLocator('#level-3');
await expect(deepFrame.getByText('Success')).toBeVisible();
});
Cross-Origin Iframes
Use when: The iframe loads content from a different domain (payment providers, OAuth flows, third-party embeds). Avoid when: The iframe is same-origin.
Playwright handles cross-origin iframes transparently. frameLocator() works regardless of origin. No special configuration is needed.
TypeScript
import { test, expect } from '@playwright/test';
test('complete OAuth login in cross-origin iframe', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
// The OAuth provider renders in a cross-origin iframe or popup
// For iframes:
const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]');
await oauthFrame.getByLabel('Email').fill('user@gmail.com');
await oauthFrame.getByRole('button', { name: 'Next' }).click();
});
test('cross-origin payment widget', async ({ page }) => {
await page.goto('/checkout');
// Stripe, PayPal, etc. load in cross-origin iframes
const stripeFrame = page.frameLocator('iframe[src*="js.stripe.com"]');
// All locator methods work across origins
await stripeFrame.getByLabel('Card number').fill('4242424242424242');
await stripeFrame.getByLabel('MM / YY').fill('12 / 28');
await stripeFrame.getByLabel('CVC').fill('123');
});
JavaScript
const { test, expect } = require('@playwright/test');
test('cross-origin payment widget', async ({ page }) => {
await page.goto('/checkout');
const stripeFrame = page.frameLocator('iframe[src*="js.stripe.com"]');
await stripeFrame.getByLabel('Card number').fill('4242424242424242');
await stripeFrame.getByLabel('MM / YY').fill('12 / 28');
await stripeFrame.getByLabel('CVC').fill('123');
});
Using the Frame API for Advanced Scenarios
Use when: You need to access the frame's URL, wait for frame navigation, or run evaluate inside the frame.
Avoid when: frameLocator() covers your needs. It is simpler and auto-waits.
TypeScript
import { test, expect } from '@playwright/test';
test('use Frame API for URL checks and evaluate', async ({ page }) => {
await page.goto('/dashboard');
// Get the Frame object (not FrameLocator)
const frame = page.frame({ url: /analytics\.example\.com/ });
expect(frame).not.toBeNull();
// Check the frame's URL
expect(frame!.url()).toContain('analytics.example.com');
// Run JavaScript inside the frame
const title = await frame!.evaluate(() => document.title);
expect(title).toBe('Analytics Dashboard');
// Wait for a frame to navigate
const frameNavPromise = page.waitForEvent('framenavigated', {
predicate: (f) => f.url().includes('/reports'),
});
await page.frameLocator('iframe[name="analytics"]')
.getByRole('link', { name: 'Reports' }).click();
await frameNavPromise;
});
JavaScript
const { test, expect } = require('@playwright/test');
test('use Frame API for URL checks and evaluate', async ({ page }) => {
await page.goto('/dashboard');
const frame = page.frame({ url: /analytics\.example\.com/ });
expect(frame).not.toBeNull();
expect(frame.url()).toContain('analytics.example.com');
const title = await frame.evaluate(() => document.title);
expect(title).toBe('Analytics Dashboard');
});
Shadow DOM -- Automatic Piercing
Use when: Your app uses Web Components with open Shadow DOM. This is the default behavior -- no special configuration needed. Avoid when: The shadow root is closed (see workaround below).
Playwright's locator(), getByRole(), getByText(), and all semantic locators pierce open Shadow DOM by default.
TypeScript
import { test, expect } from '@playwright/test';
test('interact with web components using Shadow DOM', async ({ page }) => {
await page.goto('/design-system-demo');
// getByRole pierces shadow roots automatically
await page.getByRole('button', { name: 'Open menu' }).click();
// locator() with CSS also pierces
await page.locator('my-dropdown').getByRole('option', { name: 'Settings' }).click();
// Nested web components — each shadow root is pierced
await page
.locator('my-app')
.locator('my-sidebar')
.getByRole('link', { name: 'Dashboard' })
.click();
// Assertions pierce too
await expect(page.locator('my-card').getByText('Welcome back')).toBeVisible();
// getByTestId pierces shadow DOM
await expect(page.getByTestId('user-avatar')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('interact with web components using Shadow DOM', async ({ page }) => {
await page.goto('/design-system-demo');
await page.getByRole('button', { name: 'Open menu' }).click();
await page.locator('my-dropdown').getByRole('option', { name: 'Settings' }).click();
await page
.locator('my-app')
.locator('my-sidebar')
.getByRole('link', { name: 'Dashboard' })
.click();
await expect(page.locator('my-card').getByText('Welcome back')).toBeVisible();
});
Closed Shadow DOM Workaround
Use when: A third-party component uses attachShadow({ mode: 'closed' }), which blocks Playwright's auto-piercing.
Avoid when: The shadow root is open (the default). Auto-piercing handles open roots.
Override attachShadow before the page loads to force open mode.
TypeScript
import { test, expect } from '@playwright/test';
test('access closed shadow DOM by forcing open mode', async ({ page }) => {
// Intercept attachShadow before the page scripts run
await page.addInitScript(() => {
const originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init: ShadowRootInit) {
return originalAttachShadow.call(this, { ...init, mode: 'open' });
};
});
await page.goto('/third-party-widget');
// Now the previously closed shadow root is accessible
await page.locator('closed-component').getByRole('button', { name: 'Action' }).click();
await expect(page.locator('closed-component').getByText('Done')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('access closed shadow DOM by forcing open mode', async ({ page }) => {
await page.addInitScript(() => {
const originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init) {
return originalAttachShadow.call(this, { ...init, mode: 'open' });
};
});
await page.goto('/third-party-widget');
await page.locator('closed-component').getByRole('button', { name: 'Action' }).click();
await expect(page.locator('closed-component').getByText('Done')).toBeVisible();
});
Web Components with Slots and Custom Events
Use when: Testing web components that use <slot> for content projection or dispatch custom events.
Avoid when: The component does not use slots or custom events.
TypeScript
import { test, expect } from '@playwright/test';
test('slotted content is visible through web component', async ({ page }) => {
await page.goto('/components-demo');
// Content projected into a <slot> is in the light DOM (not shadow)
// Playwright sees it at its original location
const card = page.locator('my-card');
await expect(card.getByRole('heading', { name: 'Product Title' })).toBeVisible();
await expect(card.getByText('Product description here')).toBeVisible();
});
test('listen for custom events from web components', async ({ page }) => {
await page.goto('/components-demo');
// Set up a listener for a custom event
const eventPromise = page.evaluate(() => {
return new Promise<{ detail: unknown }>((resolve) => {
document.querySelector('my-color-picker')!.addEventListener(
'color-change',
(e: Event) => resolve({ detail: (e as CustomEvent).detail }),
{ once: true }
);
});
});
// Trigger the event by interacting with the component
await page.locator('my-color-picker').getByRole('button', { name: 'Red' }).click();
const event = await eventPromise;
expect(event.detail).toEqual({ color: '#ff0000' });
});
JavaScript
const { test, expect } = require('@playwright/test');
test('slotted content is visible through web component', async ({ page }) => {
await page.goto('/components-demo');
const card = page.locator('my-card');
await expect(card.getByRole('heading', { name: 'Product Title' })).toBeVisible();
await expect(card.getByText('Product description here')).toBeVisible();
});
test('listen for custom events from web components', async ({ page }) => {
await page.goto('/components-demo');
const eventPromise = page.evaluate(() => {
return new Promise((resolve) => {
document.querySelector('my-color-picker').addEventListener(
'color-change',
(e) => resolve({ detail: e.detail }),
{ once: true }
);
});
});
await page.locator('my-color-picker').getByRole('button', { name: 'Red' }).click();
const event = await eventPromise;
expect(event.detail).toEqual({ color: '#ff0000' });
});
Decision Guide
| Scenario | Approach | Why |
|---|---|---|
Content inside <iframe> |
page.frameLocator('selector') |
Returns a scoped locator for the iframe document |
| Multiple iframes on page | Use title, name, or src attribute selectors |
More stable than index-based nth() |
| Nested iframes | Chain frameLocator().frameLocator() |
Each call scopes one level deeper |
| Cross-origin iframe | Same as any iframe -- frameLocator() |
Playwright handles cross-origin transparently |
URL check or evaluate inside frame |
page.frame({ url }) (Frame API) |
FrameLocator does not expose URL or evaluate |
| Open Shadow DOM | Standard locators -- no changes needed | Playwright pierces open shadow roots by default |
| Closed Shadow DOM | addInitScript to override attachShadow |
Forces closed roots to open before page loads |
| Slotted content in Web Components | Locate within the custom element tag | Slotted content is light DOM, accessible normally |
| Non-piercing CSS (rare) | css:light=selector |
Explicitly restricts to light DOM only |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
page.locator('#element-inside-iframe') |
Locators do not cross iframe boundaries | page.frameLocator('iframe').locator('#element') |
page.frameLocator('iframe').frameLocator('iframe') without specific selectors |
Matches wrong iframes when multiple exist | Use specific attributes: frameLocator('iframe[title="..."]') |
page.$('>>> .shadow-element') |
>>> piercing selector is not standard in Playwright |
Use page.locator('host-element').getByRole(...) -- auto-piercing works |
Using contentFrame() on a locator for routine interactions |
More complex API than frameLocator for simple cases |
Use frameLocator() -- simpler, auto-waits |
page.evaluate to query inside shadow DOM |
Bypasses Playwright's auto-waiting and retry logic | Use page.locator() which auto-pierces |
Hardcoding iframe index (nth(0)) when attributes are available |
Index changes when iframes are added/removed | Use title, name, or src pattern |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Locator times out inside iframe | Using page.locator() instead of frameLocator().locator() |
Switch to page.frameLocator('selector').locator(...) |
frameLocator returns no elements |
Iframe not yet loaded when locator resolves | frameLocator auto-waits; check that the iframe selector matches |
| Cross-origin iframe content inaccessible | Rare: specific browser security policy | Playwright handles cross-origin; ensure you are not using page.frame() with wrong URL |
Shadow DOM element not found with locator() |
Shadow root is closed (mode: 'closed') |
Use addInitScript to override attachShadow to force open mode |
getByRole finds elements from wrong shadow root |
Multiple web components have elements with the same role and name | Scope the locator: page.locator('my-specific-component').getByRole(...) |
| Slotted content not found | Searching inside shadow root instead of light DOM | Slotted content stays in light DOM; locate it through the parent custom element |
frame.evaluate() returns null |
Frame navigated away or was removed from DOM | Re-acquire the frame reference after navigation |
Related
- core/locators.md -- locator fundamentals including frame and shadow DOM basics
- core/browser-apis.md -- testing browser APIs that may live inside iframes
- core/canvas-and-webgl.md -- canvas elements inside iframes or web components
- core/debugging.md -- using Playwright Inspector to identify iframe boundaries and shadow roots