SurfSense/.cursor/skills/playwright-testing/iframes-and-shadow-dom.md

489 lines
19 KiB
Markdown
Raw Normal View History

2026-05-04 13:54:13 +05:30
# 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](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)
## Quick Reference
```typescript
// 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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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](locators.md) -- locator fundamentals including frame and shadow DOM basics
- [core/browser-apis.md](browser-apis.md) -- testing browser APIs that may live inside iframes
- [core/canvas-and-webgl.md](canvas-and-webgl.md) -- canvas elements inside iframes or web components
- [core/debugging.md](debugging.md) -- using Playwright Inspector to identify iframe boundaries and shadow roots