The iframe is where most end-to-end suites quietly give up. A team writes solid coverage for their forms, their auth, their dashboards — and then the checkout step arrives, the card fields live inside a Stripe iframe, and page.getByLabel('Card number') finds nothing. The element is right there on screen. The test can't touch it.
This isn't a bug in your selector. It's the browser doing exactly what it's supposed to. An iframe is a separate document with its own DOM tree, and a cross-origin iframe is sealed off from the parent by the same-origin policy. Understanding why that wall exists is the difference between fighting it and driving through.
Why the boundary stops your selectors
When you write page.getByRole('textbox', { name: 'Card number' }), Playwright queries one document — the top-level page. An <iframe> embeds a different document. Its <html>, its <body>, its inputs are a self-contained tree hanging off the contentDocument of the frame element. A query rooted at the parent page walks the parent's DOM and stops dead at the <iframe> node. It never descends into the child, because as far as the DOM API is concerned, the child is a different page that happens to be painted inside a rectangle.
For a same-origin iframe you could, in raw JavaScript, reach through frame.contentDocument and keep walking. For a cross-origin iframe — the Stripe case, the embedded-chat case, the third-party-widget case — even that is forbidden. The same-origin policy blocks the parent from reading or scripting the child's document at all. For a cross-origin child, iframe.contentDocument returns null; other forbidden window/document access can throw a SecurityError. This is the correct, load-bearing security boundary that stops evil.com from reading the bank iframe it embedded. It also stops naive test code cold.
So the mental model to fix first: an embedded widget is not "part of the page." It's another page, deliberately isolated, that your test has to address on its own terms.
Frame-scoped locators, not document-wide ones
Playwright's answer is frameLocator. Instead of querying the whole page, you first select the frame, then query inside it. The API mirrors the DOM reality — you cross the boundary explicitly, once, and everything after that resolves relative to the child document:
const cardFrame = page.frameLocator('iframe[title="Secure card number input"]');
await cardFrame.getByRole('textbox', { name: 'Card number' }).fill('4242 4242 4242 4242');
The important property here is that frameLocator is a locator, not a one-time handle. It re-resolves on every action. If Stripe tears down and re-mounts its iframe — which embedded widgets do constantly as they load, retry, or re-render — the chain finds the current frame each time rather than holding a stale reference to a detached document. That trait avoids stale-frame-handle failures people blame on "iframes being unreliable." Other widget, network and state failures remain possible.
The same discipline that makes top-level locators durable applies inside the frame. Anchor to roles and accessible names, not to Stripe's internal class names, which are minified and change without notice. The resilient-locators rules don't stop at the frame boundary — they matter more inside a third-party widget you don't control, because you have zero say over its markup churn.
What TestVibe generates
When you describe a checkout flow in plain language, TestVibe records the actual elements you interact with — including the ones inside frames — and emits a spec with the frame scoping already wired in. You don't hand-author the frameLocator chain or guess at which iframe holds the card field. Generated files are .spec.js. This illustrative snippet assumes one payment frame containing these fields; inspect your integration’s actual frame tree:
await page.getByRole('button', { name: 'Proceed to payment' }).click();
// Scope to ONE intended frame; some Stripe integrations split fields across frames.
const payment = page.frameLocator('iframe[title="Secure payment input frame"]');
await payment.getByRole('textbox', { name: 'Card number' }).fill('4242 4242 4242 4242');
await payment.getByRole('textbox', { name: 'Expiration' }).fill('12 / 34');
await payment.getByRole('textbox', { name: 'CVC' }).fill('123');
await page.getByRole('button', { name: 'Pay $49.00' }).click();
await expect(page.getByText('Payment received')).toBeVisible();
This works for same-origin frames, cross-origin frames like Stripe's embedded checkout, and nested frames — an iframe inside an iframe, which legacy portals still produce. Each generated locator carries the full frame-scoped chain, so replay is deterministic: the runner re-resolves every frame on every step rather than assuming the tree it saw during authoring is the one it gets at run time.
And because the spec is verified against a live run of your actual app before generated status is reported; drafts can be visible earlier, a frame chain that resolved during recording but breaks on replay is caught in the isolated cloud run that gates generation — inspect the completed verification outcome; drafts can be visible before it succeeds.
Payments: use the provider's test keys
Card fields deserve their own note, because the instinct is to be nervous about automating them. Don't put real card numbers anywhere near a test. Every serious payment provider ships test keys and test card numbers for exactly this — Stripe's 4242 4242 4242 4242 is the canonical one, and there are dedicated numbers that force declines, authentication challenges, and specific error codes. Point your test environment at the provider's test mode, drive the iframe fields with test cards, and assert on the outcome your app renders. You get real coverage of the real embedded widget without live fund movement in correctly configured test mode; this does not establish your compliance obligations.
The pattern generalizes: any embedded widget with a sandbox or test mode — chat platforms, form builders, scheduling embeds — should be tested against that mode, so the assertion reflects the integration working end to end.
Iframe rich-text editors
The other common frame is the editor. TinyMCE, historically, renders its editable body inside an iframe (ProseMirror and Quill typically don't). So the content you're trying to type into and assert on lives one document down:
const editor = page.frameLocator('iframe.tox-edit-area__iframe');
const body = editor.locator('body');
await body.click();
await body.pressSequentially('The quarterly report is ready for review.');
await expect(body).toContainText('quarterly report');
Two things matter here. First, type with real key events (pressSequentially, or the recorded keystroke path TestVibe generates) rather than setting the DOM value — rich-text editors run their own input handling, and a value you set behind their back skips the formatting logic and the change events they depend on. Second, assert on the editor's own body content, scoped through the frame, not on some mirrored hidden <textarea> in the parent that may lag the editor's state. The same keystroke-level discipline you'd apply to masked inputs and other forms is what makes editor assertions reflect what the user actually sees.
The frame with no stable attribute
Sometimes a frame has no clean handle — no id, no name, no meaningful title, and an auto-generated class. This is real, and it's where honesty matters. Review the generated fallback: it prefers a stable attribute when one exists and falls back to a structural or title-based match when it doesn't, and the generated locator says plainly what it's keying on. When the only available anchor is fragile, the spec is fragile in a visible way you can fix — by asking the app team for a title on the frame, or by scoping through a stable parent — rather than failing mysteriously three frames deep. A locator you can read is one you can repair. It's the same principle behind keeping auth flows legible: when a step depends on something brittle, the spec should show you exactly what.
The honest boundaries
Frames are well handled. Two adjacent things are not, and pretending otherwise would waste your afternoon.
- File uploads and downloads need fixtures and explicit result checks. Standard Playwright APIs alone do not establish that a generated TestVibe flow handles your embedded widget. Verify generation and replay for the exact path before relying on it.
- HTML5 drag and drop needs the correct native events for the widget. Verify the generated gesture and its replay on your embedded editor; a generic drag helper is not evidence that every reordering interaction is covered.
Knowing the wall is there beats a tool that claims to climb it and silently no-ops. Frame-scoped code still requires verification on the exact widget, target and runtime.
Wrap-up
Iframes feel like the hard part of end-to-end testing because they are a genuine boundary — a separate document, a security wall the browser enforces on purpose. But the fix isn't clever; it's precise. Address the frame explicitly, scope every query inside it, re-resolve rather than cache, and anchor to roles instead of a third party's minified markup. Do that and checkout stops being the step where your suite gives up.
Want tests that cross the iframe boundary correctly — Stripe fields, embedded editors, nested frames — without you hand-writing a single frameLocator? Get early access, or read how AI generation works in the docs.