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. Touch iframe.contentDocument from the parent and the browser throws 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 single trait kills most of the flake people blame on "iframes being unreliable." They aren't; handle-based code that grabbed the frame once and kept using it is.
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. The generated .spec.ts reads the way you'd write it if you knew the frame tree cold:
await page.getByRole('button', { name: 'Proceed to payment' }).click();
const payment = page.frameLocator('iframe[name^="__privateStripeFrame"]');
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 you ever see it, a frame chain that resolved during recording but breaks on replay is caught in the isolated cloud run that gates generation — it never survives to your review.
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 with zero money moving and zero PCI exposure.
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. TestVibe degrades safely: 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 upload and download assertions are not something the generated specs cover. If your embedded widget's flow hinges on uploading a document and asserting the result, that specific assertion is outside what TestVibe replays today.
- HTML5 drag and drop — the native
dragstart/dropevent dance — is not supported. Some embedded editors lean on it for reordering; those interactions won't automate cleanly, iframe or not.
Knowing the wall is there beats a tool that claims to climb it and silently no-ops. Everything up to that wall — cross-origin payment fields, nested legacy frames, iframe editors driven by real keystrokes — is deterministic and covered.
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.