# Playwright Multi-Tab & Window Handling

> Teaches the agent to handle popups, new tabs, and multiple browser windows in Playwright using waitForEvent('page'), context pages, and reliable tab switching for OAuth and target=_blank links.

- Skill: `pramoddutta/playwright-multi-tab-window-handling` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pramoddutta/playwright-multi-tab-window-handling`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pramoddutta/playwright-multi-tab-window-handling/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: PramodDutta (https://skillmd.com/u/pramoddutta)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/pramoddutta/playwright-multi-tab-window-handling

---


# Playwright Multi-Tab & Window Handling

This skill makes the agent write correct, race-free code for any flow that opens a second tab or popup: `target="_blank"` links, "Open in new window" buttons, OAuth/SSO consent screens, payment redirects, and PDF preview tabs. The central rule is that a new page is an **event** you must subscribe to *before* the click, never a thing you poll for afterward.

Use this skill whenever a test clicks something and a new tab/window appears, or whenever the agent sees `page.waitForTimeout` being used to "wait for the popup."

## Core Principles

1. **Subscribe before you click.** Register `context.waitForEvent('page')` (or `page.waitForEvent('popup')`) *before* the action that triggers the popup, then `await` both together. Subscribing after the click is a race.
2. **A tab belongs to a `BrowserContext`, not a `Page`.** All tabs in one context share cookies/storage. `context.pages()` is the live list of open tabs.
3. **Always `await newPage.waitForLoadState()`** before asserting — the page object resolves the moment the tab exists, not when it has loaded.
4. **Switch by holding a reference, never by index.** Index order is not guaranteed across browsers. Capture the returned `Page` object.
5. **Close popups you opened.** Leaked tabs slow the suite and can hold modal focus. Close them or rely on context teardown.
6. **Cross-origin popups are fine within a context.** OAuth on a different domain still arrives as a `page` event; you do not need a new context.

## Workflow / Patterns

### Pattern 1 — Capture a popup from a `target="_blank"` link

The canonical, race-free shape uses `Promise.all`: start listening, then click, in one expression.

```typescript
import { test, expect } from '@playwright/test';

test('opens docs in a new tab', async ({ context, page }) => {
  await page.goto('https://example.com/app');

  // Subscribe BEFORE the click, await both together.
  const [newPage] = await Promise.all([
    context.waitForEvent('page'),
    page.getByRole('link', { name: 'Open docs' }).click(),
  ]);

  // The page object exists, but the tab may still be loading.
  await newPage.waitForLoadState('domcontentloaded');

  await expect(newPage).toHaveURL(/\/docs/);
  await expect(newPage.getByRole('heading', { name: 'Documentation' })).toBeVisible();

  await newPage.close();
  // Original tab is still fully usable.
  await expect(page.getByRole('button', { name: 'Back to app' })).toBeVisible();
});
```

### Pattern 2 — `page.waitForEvent('popup')` for window.open

When the element calls `window.open()`, the `popup` event on the *opener page* is the most precise listener — it ties the new page to the exact opener.

```typescript
test('handles a window.open popup', async ({ page }) => {
  await page.goto('https://example.com/share');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Share to LinkedIn' }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();
  await expect(popup).toHaveURL(/linkedin\.com/);
  await popup.close();
});
```

### Pattern 3 — Multi-tab OAuth / SSO consent flow

The provider page opens in a popup, you authenticate there, the popup closes itself, and the original tab becomes authenticated. Wait for the popup to *close* before asserting on the main page.

```typescript
test('logs in via Google OAuth popup', async ({ page }) => {
  await page.goto('https://example.com/login');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Continue with Google' }).click();
  const oauth = await popupPromise;

  // Drive the provider's consent screen inside the popup.
  await oauth.waitForLoadState('domcontentloaded');
  await oauth.getByLabel('Email').fill(process.env.OAUTH_EMAIL!);
  await oauth.getByRole('button', { name: 'Next' }).click();
  await oauth.getByLabel('Password').fill(process.env.OAUTH_PASSWORD!);
  await oauth.getByRole('button', { name: 'Sign in' }).click();
  await oauth.getByRole('button', { name: 'Allow' }).click();

  // The provider closes its own window; wait for that, then assert on main page.
  await oauth.waitForEvent('close');
  await expect(page.getByText('Signed in as')).toBeVisible({ timeout: 15_000 });
});
```

### Pattern 4 — Several tabs open at once, switch deterministically

Hold each `Page` in a variable. Never rely on `context.pages()[1]` order.

```typescript
test('manages three tabs by reference', async ({ context, page }) => {
  await page.goto('https://example.com/reports');

  const [reportA] = await Promise.all([
    context.waitForEvent('page'),
    page.getByRole('link', { name: 'Report A' }).click(),
  ]);
  const [reportB] = await Promise.all([
    context.waitForEvent('page'),
    page.getByRole('link', { name: 'Report B' }).click(),
  ]);

  await reportA.waitForLoadState();
  await reportB.waitForLoadState();

  // Bring a specific tab to the foreground (affects screenshots / focus).
  await reportB.bringToFront();
  await expect(reportB.getByRole('heading')).toHaveText('Report B');

  await reportA.bringToFront();
  await expect(reportA.getByRole('heading')).toHaveText('Report A');

  // context.pages() === [page, reportA, reportB] — but assert by reference, not index.
  expect(context.pages()).toHaveLength(3);
});
```

### Pattern 5 — Reusable helper to capture and load a new tab

Wrap the race-free dance once so tests stay readable.

```typescript
import { type BrowserContext, type Page } from '@playwright/test';

/** Runs `action`, returns the newly opened, fully-loaded tab. */
export async function openInNewTab(
  context: BrowserContext,
  action: () => Promise<void>,
  loadState: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
): Promise<Page> {
  const [newPage] = await Promise.all([context.waitForEvent('page'), action()]);
  await newPage.waitForLoadState(loadState);
  return newPage;
}

// Usage:
test('uses the helper', async ({ context, page }) => {
  await page.goto('https://example.com');
  const invoice = await openInNewTab(context, () =>
    page.getByRole('link', { name: 'View invoice' }).click(),
  );
  await expect(invoice).toHaveTitle(/Invoice/);
  await invoice.close();
});
```

### Pattern 6 — Force same-tab navigation when you do NOT want a popup

Sometimes a `target="_blank"` link makes assertions harder than they need to be. Strip the attribute before clicking so navigation stays in one page.

```typescript
test('forces same-tab navigation', async ({ page }) => {
  await page.goto('https://example.com');
  const link = page.getByRole('link', { name: 'Terms' });
  await link.evaluate((el) => el.removeAttribute('target'));
  await link.click();
  await expect(page).toHaveURL(/\/terms/);
});
```

## Best Practices

1. **Use `Promise.all([waitForEvent, click])`** as the default shape — it makes the subscribe-before-click ordering structurally impossible to get wrong.
2. **Prefer `page.waitForEvent('popup')`** over `context.waitForEvent('page')` when one specific element triggers the new window — it scopes the wait to the right opener.
3. **Always `waitForLoadState`** on the new page before any locator or URL assertion.
4. **Type your helpers** (`Page`, `BrowserContext`) so downstream tests get autocomplete and the popup is never `any`.
5. **For OAuth, wait on `popup.waitForEvent('close')`** as the signal that auth finished, then assert on the main page.
6. **Set a generous `timeout` on the post-popup assertion** (OAuth redirects are slow); 15s is reasonable.
7. **Reuse `storageState`** for repeated logins instead of driving the OAuth popup in every test — drive it once in global setup, save state, reuse it.

## Anti-Patterns

1. **Clicking, then calling `context.waitForEvent('page')`.** The event may already have fired; you will hang until timeout. Subscribe first.
2. **`await page.waitForTimeout(3000)` to "let the tab open."** Flaky and slow. Use the event.
3. **Selecting the tab via `context.pages()[1]`.** Tab order is not portable across Chromium/Firefox/WebKit. Hold the returned reference.
4. **Asserting on a new page before `waitForLoadState`.** The page object exists immediately; its content does not.
5. **Spawning a fresh `browser.newContext()` for an OAuth popup.** A popup in the same context shares the session you need; a new context throws away cookies.
6. **Leaving popups open** across many tests — memory grows and focus-stealing modals cause unrelated failures.
7. **Driving the main page while the popup is mid-auth.** Finish the popup flow (or wait for its `close`) first.

## When to Trigger This Skill

- "Clicking this link opens a new tab and my test can't find the element"
- "How do I handle a popup / `window.open` in Playwright?"
- "Test the Google / GitHub / SSO OAuth login flow"
- "A `target=_blank` link breaks my assertions"
- "I have multiple tabs open and need to switch between them"
- "Handle the payment redirect that opens in a new window"
- "My popup test is flaky / times out waiting for the new page"

