SurfSense/.cursor/skills/playwright-testing/iframes-and-shadow-dom.md
2026-05-04 13:54:13 +05:30

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