Cypress sees what the browser sees — but only at the level of the main document. Two structures break that simplicity: iframes (a separate document embedded in the page) and shadow DOM (an encapsulated subtree inside a web component). Both are common in real apps. Stripe and PayPal payment fields live in iframes; modern design systems built with Lit, Stencil, or <slot>-based components live in shadow DOM. This lesson covers the patterns that pierce both.
Iframes — what's actually going on
An iframe loads a separate HTML document inside the host page. From the browser's perspective:
<body>
<h1>Checkout</h1>
<iframe src="https://payments.stripe.com/card-element"></iframe>
</body>The <iframe> element is in your document, but everything inside it — the card-number input, the Stripe-styled CSS, the iframe's own <body> — lives in a separate document. cy.get("input") from the main test scope can't reach it; the main document genuinely doesn't contain that input.
To work with iframe contents you need three steps: select the <iframe>, drill into its contentDocument, and wrap the result back into a Cypress chainable.
Accessing same-origin iframe content
Same-origin iframes (the iframe's URL has the same protocol/host/port as the page) are accessible. Here's the canonical pattern:
cy.get("iframe[data-testid='payment-frame']")
.its("0.contentDocument.body")
.should("not.be.empty")
.then(cy.wrap)
.find("[data-testid='card-number']")
.type("4242424242424242");Read it line by line:
cy.get("iframe[...]")— select the iframe wrapper element in the main document..its("0.contentDocument.body")—0indexes into Cypress's jQuery collection (every Cypress yield is a jQuery wrapper),contentDocumentis the iframe's document,bodyis its<body>element. Cypress's.itsaccepts dotted paths..should("not.be.empty")— wait until the iframe document has actually loaded its body. Without this, you race the iframe's load and try to query an empty document..then(cy.wrap)— re-wrap the raw DOM body into a Cypress chainable so we can keep using.find,.click, etc..find(...).type(...)— now scoped to the iframe's body, do whatever you'd do in the main document.
Once you've worked through it once, it's a recipe to memorise. Most teams immediately wrap it in a custom command.
A typed custom command for iframes
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
getIframeBody(iframeSelector: string): Chainable<JQuery<HTMLBodyElement>>;
}
}
}
Cypress.Commands.add("getIframeBody", (iframeSelector: string) => {
return cy
.get(iframeSelector)
.its("0.contentDocument.body")
.should("not.be.empty")
.then(cy.wrap) as Cypress.Chainable<JQuery<HTMLBodyElement>>;
});
export {};After this, every iframe interaction is one line:
cy.getIframeBody("iframe[data-testid='payment-frame']")
.find("[data-testid='card-number']")
.type("4242424242424242");
cy.getIframeBody("iframe[data-testid='payment-frame']")
.find("[data-testid='expiry']")
.type("12/29");The declare global block (introduced in typescript-with-cypress from the previous TypeScript course) merges your command into Cypress's Chainable interface so autocomplete and type-checking work everywhere.
Cross-origin iframes — the hard wall
When the iframe loads from a different origin (different domain, port, or protocol), the browser's same-origin policy blocks scripts in the parent from reading the child's contentDocument. Cypress respects that restriction. The iframe's body is genuinely unreadable.
Two practical paths around it:
-
Stub the iframe's network calls. Use
cy.intercept(chapter 4) to fake the response that would come back from the cross-origin service. The iframe never actually loads — you control what the app receives — and the test runs entirely in the main origin. -
Use
cy.origin()to switch the test runtime to the foreign origin and drive it directly:cy.origin("https://payments.stripe.com", () => { cy.get("[data-testid='card-number']").type("4242424242424242"); });Inside the
cy.origincallback you're effectively running a sub-test in the foreign origin. It's not pretty for complex flows, but it's the only way to drive a real cross-origin iframe.
For most QA teams, stubbing wins on speed and reliability. Reserve cy.origin for the one or two SSO/payment flows where you need real-iframe coverage.
Shadow DOM — what it is
Web components encapsulate their internal DOM in a shadow root, a subtree the host document doesn't see. A button rendered inside <my-button> might look like this in devtools:
<my-button>
#shadow-root (open)
<button class="internal">Buy now</button>
</my-button>The <button> exists, but cy.get("button") from the main scope finds zero matches. The shadow root is an explicit visibility boundary, by design — it's how design systems prevent global CSS from leaking into their components.
Two ways to traverse shadow DOM
Globally — flip a config flag and Cypress pierces every shadow root automatically:
// cypress.config.ts
export default defineConfig({
e2e: {
includeShadowDom: true,
},
});After this, cy.get("button") finds buttons anywhere — including inside shadow roots. This is the right default if your app uses web components heavily.
Per-query — chain .shadow() to dive into one specific shadow root:
cy.get("my-button").shadow().find(".internal").click();
cy.get("design-system-input")
.shadow()
.find("input[type='text']")
.type("Hello");This is the right pattern when shadow DOM is rare in your codebase and you want most tests to use the default DOM-only behaviour.
The two approaches compose: includeShadowDom: true makes cy.get traverse automatically; .shadow() is still available when you want to be explicit. Pick the one that fits how often your app uses shadow DOM.
A real payment-form test
A full typed example exercising both the iframe and the form around it:
describe("Stripe-style payment form", () => {
beforeEach(() => {
cy.visit("/checkout");
});
it("submits card details through the payment iframe", () => {
// Fill out the main-document fields first.
cy.get("[data-testid='cardholder-name']").type("Alice Reed");
cy.get("[data-testid='billing-zip']").type("90210");
// Drill into the payment iframe via the typed custom command.
cy.getIframeBody("iframe[data-testid='payment-frame']")
.find("[data-testid='card-number']")
.type("4242424242424242");
cy.getIframeBody("iframe[data-testid='payment-frame']")
.find("[data-testid='expiry']")
.type("12/29");
cy.getIframeBody("iframe[data-testid='payment-frame']")
.find("[data-testid='cvc']")
.type("123");
// Back in the main document — click the submit button.
cy.get("[data-testid='submit-payment']").click();
cy.get("[data-testid='confirmation']").should("contain", "Payment successful");
});
});The structure is the same as any other Cypress test — visit, type, click, assert — with the custom command quietly handling the iframe boundary. The test reads cleanly even though the underlying mechanism is doing real work to cross document boundaries.
How iframe access actually flows
⚠️ Common mistakes
- Forgetting
.should("not.be.empty")between selecting the iframe and reading its body. Cypress will happily yield an empty document if the iframe hasn't finished loading, and your subsequent.find()searches an empty body. The "wait for the body to be non-empty" assertion is the synchronisation point — don't skip it. - Trying to drive cross-origin iframes with the standard pattern. It silently fails because the browser blocks
contentDocumentaccess for security. Usecy.originif you genuinely need real iframe coverage, orcy.interceptto stub the underlying network call so the iframe never has to load. - Turning on
includeShadowDom: true"just in case" when the app doesn't use web components. It's not free — everycy.getdoes extra traversal work. If your app only has one or two web-component widgets, prefer per-query.shadow()instead.
🎯 Practice task
Drive an iframe and a web component in real tests. 25-30 minutes.
- Iframe practice — open
https://the-internet.herokuapp.com/iframe. The page contains a TinyMCE editor inside an iframe. SetbaseUrl: "https://the-internet.herokuapp.com"and createcypress/e2e/iframe.cy.ts. - Write a test that visits
/iframe, drills into the iframe's body, and types "Hello from Cypress" into the editor. Use the inline pattern (its("0.contentDocument.body").should("not.be.empty").then(cy.wrap)) the first time so you understand each step. - Add the typed
cy.getIframeBodycustom command tocypress/support/commands.ts. Refactor the test to use it. Confirm autocomplete works oncy.getIframeBody("..."). - Add a second test that types into the editor, then asserts the iframe body contains the typed text via a
should("contain", "Hello from Cypress")chained offgetIframeBody. - Shadow DOM practice — if you don't have an app with web components handy, use a public demo: open a page like a Lit-built widget or a Stencil-based component library. Pick any element inside the shadow root. Try to select it with plain
cy.get(it'll fail to find), then withcy.get(host).shadow().find(...)(it works), then turn onincludeShadowDom: truein the config and confirm plaincy.getworks again. - Stretch: find a public site with a real cross-origin iframe (a YouTube embed, an ad iframe, a Disqus comment widget). Confirm Cypress fails when you try the standard
.itspattern, and that the failure message points you at the same-origin restriction. Make a note of which of your real-world apps have cross-origin iframes — that's the list of tests that will needcy.intercept-based stubbing later.
The next lesson handles the cousins of iframes and shadow DOM — window.alert, window.confirm, window.prompt, and the cookie-banner/consent modals that interrupt every real-world test.