A flow that opens a payment page in a new tab, pulls a verification code from email in a popup, or redirects through an SSO provider on another origin — these are the cases where Cypress runs out of road and where Playwright's architecture genuinely shines. Because Playwright drives the browser from outside, multiple tabs and multiple origins are first-class concepts. This lesson is about every multi-window pattern you'll meet in real testing: target="_blank" links, window.open popups, window.alert and window.confirm dialogs, and how to switch between several open pages.
The mental model — page, context, browser
Three layers, each with a job:
- Browser — the launched browser process. Expensive to start, shared across tests in a worker.
- BrowserContext — an isolated profile, like an incognito window. Has its own cookies, localStorage, cache. Each test gets a fresh one by default.
- Page — a tab inside a context. A test usually has one Page (the
pagefixture), but a context can have many.
When a target="_blank" link opens a new tab, that tab is a new Page inside the same context. When window.open fires a popup, it's also a new Page in the same context. The context is what fires the 'page' event so you can grab the new Page handle.
test("multi-tab flow", async ({ page, context }) => {
// page = the original tab
// context = the parent context — manages all tabs in this test
});Playwright passes both page and context as fixtures. Most tests only need page; the moment you need to handle a new tab, you destructure context too.
Opening a new tab — the target="_blank" pattern
The pattern: arm a waiter for the 'page' event before the click that triggers the new tab, then resolve both:
test("opens a product preview in a new tab", async ({ page, context }) => {
await page.goto("/products");
// Wait for the new tab to open, click the link that opens it
const newPagePromise = context.waitForEvent("page");
await page.getByRole("link", { name: "Open product preview" }).click();
const newPage = await newPagePromise;
// Make sure the new tab is fully loaded before interacting
await newPage.waitForLoadState();
// Drive the new tab as if it were any other page
await expect(newPage).toHaveURL(/preview/);
await expect(newPage.getByRole("heading", { name: "Product preview" })).toBeVisible();
await newPage.getByRole("button", { name: "Close" }).click();
});Two things to internalise:
- Arm the waiter first. If you click first and
await context.waitForEvent('page')afterwards, you can miss the event — the tab may already have opened. Always set up the waiter before the action. newPage.waitForLoadState()ensures the new tab finished loading before your assertions. It uses the same'load'semantics asgoto.
You can also use Promise.all if you find that pattern more familiar — it's equivalent:
const [newPage] = await Promise.all([
context.waitForEvent("page"),
page.getByRole("link", { name: "Open in new tab" }).click(),
]);Opening popups — the window.open pattern
window.open(...) triggers a 'popup' event on the originating page (not a 'page' event on the context). Same shape, slightly different listener:
test("opens a help popup", async ({ page }) => {
await page.goto("/dashboard");
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: "Open help" }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveURL(/help/);
await popup.getByRole("link", { name: "Pricing" }).click();
await popup.close();
});The popup is a Page just like a new tab — same API, same locators, same assertions. You can leave it open and switch back to the original page, or popup.close() when done.
Working with multiple open pages
Once you have several pages, you can list them and bring any of them to focus:
const allPages = context.pages();
console.log(allPages.length); // 2 or 3, depending on how many tabs are open
await allPages[0].bringToFront(); // focus the first tab
await allPages[1].bringToFront(); // focus the second tabMost tests don't need this — you keep references to each Page when you create it (const newPage = await ...waitForEvent('page')) and use those references directly. context.pages() is the escape hatch when you need to find a tab you didn't track explicitly.
JavaScript dialogs — alert, confirm, prompt
These three browser dialogs (window.alert, window.confirm, window.prompt) are out-of-band — they aren't DOM elements, you can't click them with locators. Playwright auto-dismisses them by default, but you can register a handler to interact:
test("accepts a confirm dialog", async ({ page }) => {
await page.goto("/settings");
// Register the handler BEFORE the action that triggers the dialog
page.on("dialog", async dialog => {
expect(dialog.type()).toBe("confirm");
expect(dialog.message()).toContain("Are you sure");
await dialog.accept();
});
await page.getByRole("button", { name: "Delete account" }).click();
await expect(page).toHaveURL(/farewell/);
});The dialog event fires synchronously when the page calls alert/confirm/prompt. The handler must call accept(), dismiss(), or accept('text') (for prompt) — or Playwright auto-dismisses after a timeout.
Common patterns:
// Accept any dialog (default-ish behaviour, useful for "Are you sure?" confirms)
page.on("dialog", dialog => dialog.accept());
// Dismiss any dialog (useful for testing the "Cancel" path)
page.on("dialog", dialog => dialog.dismiss());
// Provide text for a prompt
page.on("dialog", dialog => dialog.accept("My reason for leaving"));
// Inspect the dialog and decide
page.on("dialog", dialog => {
expect(dialog.message()).toBe("Save changes?");
dialog.accept();
});Two things to remember: register the handler before the action that triggers the dialog (the dialog fires synchronously, so a late handler is too late), and un-register it (page.removeListener('dialog', handler)) in the rare case where you need different behaviour later in the same test.
A multi-tab flow visualised
A complete payment-popup test
Putting it together against a hypothetical e-commerce flow:
import { test, expect } from "@playwright/test";
test.describe("Multi-tab payment flow", () => {
test("opens payment provider in new tab, completes, returns", async ({ page, context }) => {
// 1. Cart and checkout in the main tab
await page.goto("/cart");
await page.getByRole("button", { name: "Checkout" }).click();
await expect(page).toHaveURL(/checkout/);
// 2. Click "Pay with provider" — opens new tab
const paymentPagePromise = context.waitForEvent("page");
await page.getByRole("link", { name: "Pay with provider" }).click();
const paymentPage = await paymentPagePromise;
await paymentPage.waitForLoadState();
// 3. Drive the payment tab
await expect(paymentPage).toHaveURL(/provider\.example\.com/);
await paymentPage.getByLabel("Card number").fill("4242 4242 4242 4242");
await paymentPage.getByLabel("Expiry").fill("12/26");
await paymentPage.getByLabel("CVC").fill("123");
await paymentPage.getByRole("button", { name: "Confirm payment" }).click();
await paymentPage.waitForURL(/success/);
await paymentPage.close();
// 4. Back on the main page — verify the order placed
await page.bringToFront();
await expect(page.getByText("Order confirmed")).toBeVisible();
await expect(page).toHaveURL(/orders\/\d+/);
});
test("dialog handling — confirms account deletion", async ({ page }) => {
await page.goto("/settings");
page.on("dialog", async dialog => {
expect(dialog.message()).toContain("permanent");
await dialog.accept();
});
await page.getByRole("button", { name: "Delete account" }).click();
await expect(page).toHaveURL(/account-deleted/);
});
});Read the multi-tab test as a story: arm → click → grab → drive → close → verify. The same five beats apply to almost every multi-tab flow you'll write — SSO logins, payment iframes that open in new tabs, "view PDF" links, anything that yields a target="_blank".
Coming from Cypress?
This is the area where the migration story is the simplest:
- Cypress: multi-tab flows are not natively supported. Workarounds include rewriting the link as same-tab, asserting the URL would have been correct, or stubbing the new-tab open. Most teams just avoid these flows in Cypress tests.
- Playwright: native multi-tab via
context.waitForEvent('page')andpage.waitForEvent('popup'). Same API as a regular Page — drive both tabs in one test.
If a flow you couldn't reliably test in Cypress is on your roadmap (cross-origin SSO, payment-popup checkouts, "open in new tab" deep links), this is the lesson that unlocks it.
⚠️ Common mistakes
- Setting up
waitForEvent('page')after the click. The new tab can open before yourawaitruns, and the listener never sees it — your test hangs until the timeout fires. Always arm the waiter first, click second, await both. - Forgetting
await newPage.waitForLoadState()before asserting. Right afterwaitForEventresolves, the new tab may still be loading. Web-first assertions retry, soexpect(newPage).toHaveURL(...)survives, butawait newPage.locator(...).click()can race against an unloaded DOM. Always wait for load before driving the new tab. - Registering the dialog handler too late.
page.on('dialog', ...)must be set up before the click that triggers the dialog. If you set it up after, the dialog has already fired and Playwright auto-dismissed it — your handler never runs. Register first, click second.
🎯 Practice task
Build multi-tab and dialog-handling tests against a public sandbox. 25-30 minutes.
-
Set
baseURL: "https://the-internet.herokuapp.com"(a public sandbox with multi-window and dialog scenarios) — or hardcode the URL ingoto. -
Create
tests/multi-tab.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Multi-tab and dialogs", () => { test("clicking the link opens a new tab", async ({ page, context }) => { await page.goto("/windows"); const newTabPromise = context.waitForEvent("page"); await page.getByRole("link", { name: "Click Here" }).click(); const newTab = await newTabPromise; await newTab.waitForLoadState(); await expect(newTab).toHaveURL(/windows\/new/); await expect(newTab.getByRole("heading", { name: "New Window" })).toBeVisible(); await newTab.close(); await expect(page).toHaveURL(/windows$/); }); test("alert dialog — accept and assert message", async ({ page }) => { await page.goto("/javascript_alerts"); page.on("dialog", async dialog => { expect(dialog.type()).toBe("alert"); expect(dialog.message()).toBe("I am a JS Alert"); await dialog.accept(); }); await page.getByRole("button", { name: "Click for JS Alert" }).click(); await expect(page.locator("#result")).toHaveText("You successfully clicked an alert"); }); test("confirm dialog — dismiss path", async ({ page }) => { await page.goto("/javascript_alerts"); page.on("dialog", dialog => dialog.dismiss()); await page.getByRole("button", { name: "Click for JS Confirm" }).click(); await expect(page.locator("#result")).toHaveText("You clicked: Cancel"); }); test("prompt dialog — accept with text", async ({ page }) => { await page.goto("/javascript_alerts"); page.on("dialog", dialog => dialog.accept("Hello Playwright")); await page.getByRole("button", { name: "Click for JS Prompt" }).click(); await expect(page.locator("#result")).toHaveText("You entered: Hello Playwright"); }); }); -
Run all four tests across all three browsers. The dialog tests demonstrate
accept,dismiss, andaccept('text')paths. -
Demonstrate the late-handler bug. In the alert test, move
page.on('dialog', ...)to after theclick(). Run it. The test fails — the alert opened before the handler was set up. This is the single most common multi-tab/dialog mistake; once you've felt it, you won't make it again. -
Stretch: add a test that opens two tabs in sequence (click, get newPage1, click another link, get newPage2), then
context.pages()returns three pages. Bring each one to front in turn and assert their URLs are different. This is the pattern for tests that need to span three or more tabs — rare, but the API generalises cleanly.
You can now drive any multi-window flow Playwright supports — and that's most of them. The next (and final) lesson in this chapter handles the opposite boundary: iframes embedded inside a single page, used by Stripe, Braintree, OAuth screens, and rich-text editors.