QA Labs
QA Labs
Phase 0

Kata 06: Iframes and Shadow DOM

Kata 06: Iframes and Shadow DOM

What You Will Learn

  • How to access and interact with elements inside <iframe> elements
  • How to handle nested iframes (iframe within iframe)
  • How to work with Shadow DOM and custom web components
  • Playwright frameLocator() API (no frame switching needed)
  • Cypress iframe pattern using contentDocument.body
  • Shadow DOM piercing: automatic in Playwright, includeShadowDom option in Cypress
  • Cross-boundary assertions: reading iframe content, acting on the parent page

Prerequisites

  • Completed Kata 01-05
  • Understanding of CSS selectors and data-testid attributes

Concepts Explained

What Are Iframes?

An <iframe> (inline frame) embeds a separate HTML document inside the current page. The iframe has its own DOM, separate from the parent page. This means normal selectors cannot reach inside an iframe -- you need special APIs.

Common real-world uses: embedded payment forms (Stripe, PayPal), third-party widgets, terms-and-conditions documents, ads.

What Is Shadow DOM?

Shadow DOM is a web standard that encapsulates a component's internal structure. Elements inside a shadow root are hidden from the main page's DOM. Custom elements (like <compliance-widget>) use shadow DOM to prevent style leakage and maintain encapsulation.

Playwright: frameLocator() API

// frameLocator() returns a FrameLocator scoped to the iframe's document.
// You pass a CSS selector that matches the <iframe> element on the parent page.
const frame = page.frameLocator('[data-testid="payment-iframe"]');

// Inside the frame, use normal locator methods.
await frame.getByTestId('input-cardholder').fill('Jane Doe');
await expect(frame.getByTestId('payment-title')).toHaveText('Payment');

// No "switching" needed -- you can use multiple frameLocators independently.
const termsFrame = page.frameLocator('[data-testid="terms-iframe"]');
await expect(termsFrame.getByTestId('terms-title')).toHaveText('Terms');

// You can still access the main page with `page` at any time.
await expect(page.getByTestId('page-header')).toBeVisible();

Key points:

  • frameLocator() does NOT switch context (unlike Selenium's switchTo().frame())
  • Each FrameLocator is an independent scoped view into one iframe
  • You can use multiple frame locators in the same test without switching back
  • All locator methods (.getByTestId(), .getByRole(), .locator()) work inside frames

Playwright: Nested Iframes

// Chain frameLocator() calls to access iframes within iframes.
const outer = page.frameLocator('[data-testid="outer-iframe"]');
const inner = outer.frameLocator('[data-testid="inner-iframe"]');

// Now interact with elements inside the nested iframe.
await expect(inner.getByTestId('inner-text')).toContainText('passed');

Playwright: Shadow DOM (Automatic Piercing)

// Playwright automatically pierces open shadow roots.
// No special configuration needed -- getByTestId, getByRole, locator() all work.
await page.getByTestId('widget-title');                    // finds inside shadow DOM
await page.getByTestId('input-entity-name').fill('Acme');  // fills inside shadow DOM

Cypress: Iframe Pattern

Cypress does not have a built-in frameLocator(). You must manually access the iframe's contentDocument.body:

// Helper function to get an iframe's body as a Cypress-wrapped element.
function getIframeBody(selector: string) {
  return cy
    .get(selector)                          // find the <iframe> element
    .its('0.contentDocument.body')          // get the DOM node of the iframe body
    .should('not.be.empty')                 // wait until the iframe has loaded
    .then(cy.wrap);                         // wrap it for chaining Cypress commands
}

// Usage:
getIframeBody('[data-testid="payment-iframe"]')
  .find('[data-testid="input-cardholder"]')
  .type('Jane Doe');

Key points:

  • .its('0.contentDocument.body') accesses the first matched element's content document
  • .should('not.be.empty') acts as a retry/wait until the iframe loads
  • cy.wrap() converts the raw DOM node into a Cypress chainable
  • Use .find() (not .get()) to scope searches within the iframe body

Cypress: Nested Iframes

// Get the outer iframe body, then find the inner iframe within it.
getIframeBody('[data-testid="outer-iframe"]').then(($outerBody) => {
  cy.wrap($outerBody)
    .find('[data-testid="inner-iframe"]')
    .its('0.contentDocument.body')
    .should('not.be.empty')
    .then(cy.wrap)
    .then(($innerBody) => {
      cy.wrap($innerBody).find('[data-testid="inner-text"]')
        .should('contain.text', 'passed');
    });
});

Cypress: Shadow DOM

// Option 1: Per-command -- pass { includeShadowDom: true } to cy.get() or .find()
cy.get('[data-testid="widget-title"]', { includeShadowDom: true })
  .should('have.text', 'Compliance Check');

// Option 2: Global -- set in cypress.config.ts (affects ALL commands)
// export default defineConfig({ includeShadowDom: true });

Function Signatures

Playwright

MethodSignaturePurpose
page.frameLocator()frameLocator(selector: string): FrameLocatorReturns a scoped view into an iframe
frameLocator.getByTestId()getByTestId(testId: string): LocatorFind element by data-testid inside the frame
frameLocator.locator()locator(selector: string): LocatorFind element by CSS/text inside the frame
frameLocator.frameLocator()frameLocator(selector: string): FrameLocatorAccess nested iframe within a frame

Cypress

MethodSignaturePurpose
.its().its(propertyPath: string)Access a property on the subject (used for contentDocument.body)
cy.wrap()cy.wrap(subject: any)Wrap a DOM node so Cypress commands can chain on it
.find().find(selector: string, options?)Find descendants within the current subject
{ includeShadowDom: true }option on cy.get() / .find()Pierce shadow DOM boundaries

Exercises

#ExerciseWhat You Practice
1Read text inside an iframeframeLocator() / getIframeBody() basics
2Fill payment form inside iframefill() / type() within iframe scope
3Submit form in iframe, verify resultClick + assertion inside iframe
4Read text inside Shadow DOMShadow DOM piercing
5Fill and submit Shadow DOM formFull interaction with shadow root elements
6Access nested iframe (iframe in iframe)Chained frameLocator() / nested contentDocument
7Switch between multiple iframesMultiple frame locators, no switching overhead
8Assert across iframe boundaryRead iframe, act on parent page

Solutions

  • Playwright: playwright/iframes-and-shadow-dom.spec.ts
  • Cypress: cypress/iframes-and-shadow-dom.cy.ts

Common Mistakes

1. Using page.locator() to find elements inside an iframe

// WRONG -- this searches the parent page DOM only
await page.getByTestId('input-cardholder').fill('Jane');

// RIGHT -- use frameLocator() first
const frame = page.frameLocator('[data-testid="payment-iframe"]');
await frame.getByTestId('input-cardholder').fill('Jane');

2. Using cy.get() instead of .find() inside an iframe body

// WRONG -- cy.get() searches from the root document, not the iframe
getIframeBody('[data-testid="payment-iframe"]');
cy.get('[data-testid="input-cardholder"]').type('Jane');  // searches main page!

// RIGHT -- chain .find() on the wrapped iframe body
getIframeBody('[data-testid="payment-iframe"]')
  .find('[data-testid="input-cardholder"]')
  .type('Jane');

3. Forgetting { includeShadowDom: true } in Cypress

// WRONG -- Cypress cannot see inside shadow roots by default
cy.get('[data-testid="widget-title"]').should('have.text', 'Check');
// Error: element not found

// RIGHT -- pass the option
cy.get('[data-testid="widget-title"]', { includeShadowDom: true })
  .should('have.text', 'Check');

4. Trying to "switch back" to the main page in Playwright

// UNNECESSARY -- Playwright does not have an active frame concept
// There is no need to "switch back" like in Selenium
const frame = page.frameLocator('[data-testid="payment-iframe"]');
await frame.getByTestId('input-cardholder').fill('Jane');
// You can immediately use `page` for the main page -- no switching needed
await page.getByTestId('page-header').click();

5. Not waiting for iframe to load in Cypress

// WRONG -- iframe body may not be ready yet
cy.get('[data-testid="payment-iframe"]')
  .its('0.contentDocument.body')
  .then(cy.wrap)  // might wrap an empty body!

// RIGHT -- add .should('not.be.empty') to retry until loaded
cy.get('[data-testid="payment-iframe"]')
  .its('0.contentDocument.body')
  .should('not.be.empty')   // retries until the iframe body has content
  .then(cy.wrap);

Quick Reference

Playwright                                   Cypress
----------------------------------------------  ------------------------------------------------
page.frameLocator(sel)                        cy.get(sel).its('0.contentDocument.body')
                                                .should('not.be.empty').then(cy.wrap)
frame.getByTestId('x')                        iframeBody.find('[data-testid="x"]')
outer.frameLocator(sel)  // nested            iframeBody.find(sel).its('0.contentDocument.body')
page.getByTestId('x')    // auto-pierce       cy.get(sel, { includeShadowDom: true })
No switching needed                           No switching needed (but more boilerplate)