# Multi-Context, Popups, and New Windows

> **When to use**: Handling popup windows, new tabs, OAuth authorization flows, payment gateway redirects, multi-tab coordination, and any scenario where your application opens additional browser windows or tabs.
> **Prerequisites**: [core/assertions-and-waiting.md](assertions-and-waiting.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)

## Quick Reference

```typescript
// Catch a popup triggered by a click
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open preview' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup.getByRole('heading')).toContainText('Preview');

// List all open pages in a context
const allPages = context.pages();
console.log(`Open tabs: ${allPages.length}`);
```

**Key concept**: In Playwright, a "popup" is any new page opened by `window.open()`, `target="_blank"` links, or JavaScript-triggered new windows. They all fire the `popup` event on the originating page.

## Patterns

### Handling Basic Popups

**Use when**: A user action opens a new tab or window and you need to interact with it.
**Avoid when**: The "popup" is actually a modal dialog within the same page -- use `getByRole('dialog')` instead.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('handle popup opened by target="_blank" link', async ({ page }) => {
  await page.goto('/help');

  // Set up the popup listener BEFORE the action that triggers it
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Documentation' }).click();
  const popup = await popupPromise;

  // Wait for the popup to load
  await popup.waitForLoadState();

  // Interact with the popup
  await expect(popup.getByRole('heading', { level: 1 })).toContainText('Documentation');
  expect(popup.url()).toContain('/docs');

  // Close the popup when done
  await popup.close();
});

test('handle popup opened by window.open()', async ({ page }) => {
  await page.goto('/reports');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Print report' }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();
  await expect(popup.getByTestId('print-preview')).toBeVisible();

  // The original page is still accessible
  await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('handle popup opened by target="_blank" link', async ({ page }) => {
  await page.goto('/help');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Documentation' }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();
  await expect(popup.getByRole('heading', { level: 1 })).toContainText('Documentation');

  await popup.close();
});
```

### OAuth Popup Flows

**Use when**: Your app opens a third-party OAuth window (Google, GitHub, Microsoft, etc.) for authentication.
**Avoid when**: You can bypass OAuth entirely by injecting auth tokens directly -- see [core/authentication.md](authentication.md).

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('Google OAuth popup flow', async ({ page }) => {
  await page.goto('/login');

  // Listen for the popup before clicking the OAuth button
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Sign in with Google' }).click();
  const oauthPopup = await popupPromise;

  // Wait for the OAuth provider page to load
  await oauthPopup.waitForLoadState();
  expect(oauthPopup.url()).toContain('accounts.google.com');

  // Fill in credentials on the OAuth provider page
  await oauthPopup.getByLabel('Email or phone').fill('testuser@gmail.com');
  await oauthPopup.getByRole('button', { name: 'Next' }).click();
  await oauthPopup.getByLabel('Enter your password').fill('test-password');
  await oauthPopup.getByRole('button', { name: 'Next' }).click();

  // The popup closes automatically after authorization
  // Wait for the original page to receive the auth callback
  await page.waitForURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

test('GitHub OAuth popup flow', async ({ page }) => {
  await page.goto('/login');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Sign in with GitHub' }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();
  expect(popup.url()).toContain('github.com');

  await popup.getByLabel('Username or email address').fill('testuser');
  await popup.getByLabel('Password').fill('test-password');
  await popup.getByRole('button', { name: 'Sign in' }).click();

  // Authorize the app if prompted
  const authorizeButton = popup.getByRole('button', { name: 'Authorize' });
  if (await authorizeButton.isVisible({ timeout: 3000 }).catch(() => false)) {
    await authorizeButton.click();
  }

  // Popup closes, original page redirects
  await page.waitForURL('/dashboard');
  await expect(page.getByText(/Welcome/)).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('GitHub OAuth popup flow', async ({ page }) => {
  await page.goto('/login');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Sign in with GitHub' }).click();
  const popup = await popupPromise;

  await popup.waitForLoadState();
  await popup.getByLabel('Username or email address').fill('testuser');
  await popup.getByLabel('Password').fill('test-password');
  await popup.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL('/dashboard');
  await expect(page.getByText(/Welcome/)).toBeVisible();
});
```

### Payment Gateway Popups

**Use when**: Checkout flows open a payment provider in a new window (PayPal, 3D Secure verification).
**Avoid when**: The payment gateway loads in an iframe on the same page -- use `frameLocator` instead.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('PayPal popup checkout flow', async ({ page }) => {
  await page.goto('/checkout');

  await page.getByLabel('Email').fill('buyer@example.com');
  await page.getByRole('button', { name: 'Proceed to payment' }).click();

  // PayPal opens in a popup
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Pay with PayPal' }).click();
  const paypalPopup = await popupPromise;

  await paypalPopup.waitForLoadState();
  expect(paypalPopup.url()).toContain('paypal.com');

  // Complete PayPal flow
  await paypalPopup.getByLabel('Email').fill('buyer@paypal-test.com');
  await paypalPopup.getByRole('button', { name: 'Next' }).click();
  await paypalPopup.getByLabel('Password').fill('test-password');
  await paypalPopup.getByRole('button', { name: 'Log In' }).click();
  await paypalPopup.getByRole('button', { name: 'Complete Purchase' }).click();

  // Popup closes, return to original page
  await page.waitForURL('/order/confirmation');
  await expect(page.getByText('Payment successful')).toBeVisible();
});

test('3D Secure verification popup', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4000000000003220'); // 3DS test card
  await page.getByLabel('Expiry').fill('12/28');
  await page.getByLabel('CVC').fill('123');
  await page.getByRole('button', { name: 'Pay' }).click();

  // 3DS challenge opens in popup or iframe -- handle both
  const popupPromise = page.waitForEvent('popup', { timeout: 5000 }).catch(() => null);
  const popup = await popupPromise;

  if (popup) {
    await popup.waitForLoadState();
    await popup.getByRole('button', { name: 'Complete authentication' }).click();
  } else {
    // Fallback: 3DS in iframe
    const frame = page.frameLocator('iframe[name*="challenge"]');
    await frame.getByRole('button', { name: 'Complete authentication' }).click();
  }

  await expect(page.getByText('Payment successful')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('PayPal popup checkout flow', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Proceed to payment' }).click();

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Pay with PayPal' }).click();
  const paypalPopup = await popupPromise;

  await paypalPopup.waitForLoadState();
  await paypalPopup.getByLabel('Email').fill('buyer@paypal-test.com');
  await paypalPopup.getByRole('button', { name: 'Next' }).click();
  await paypalPopup.getByLabel('Password').fill('test-password');
  await paypalPopup.getByRole('button', { name: 'Log In' }).click();
  await paypalPopup.getByRole('button', { name: 'Complete Purchase' }).click();

  await page.waitForURL('/order/confirmation');
  await expect(page.getByText('Payment successful')).toBeVisible();
});
```

### Multi-Tab Coordination

**Use when**: Testing scenarios where multiple tabs share state -- real-time collaboration, shopping cart sync, or session management across tabs.
**Avoid when**: Each tab is independent and can be tested in separate test cases.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('cart updates reflect across tabs', async ({ context }) => {
  // Open two tabs in the same context (shared cookies/storage)
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  await page1.goto('/products');
  await page2.goto('/cart');

  // Add item in tab 1
  await page1.getByRole('button', { name: 'Add to cart' }).first().click();

  // Tab 2 should reflect the update (via WebSocket, polling, or storage event)
  await expect(page2.getByTestId('cart-count')).toHaveText('1', { timeout: 5000 });
});

test('logout in one tab logs out all tabs', async ({ context }) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  // Both tabs are on authenticated pages
  await page1.goto('/dashboard');
  await page2.goto('/settings');

  // Log out from tab 1
  await page1.getByRole('button', { name: 'Log out' }).click();
  await page1.waitForURL('/login');

  // Tab 2 should redirect to login on next action or automatically
  await page2.reload();
  expect(page2.url()).toContain('/login');
});

test('manage multiple tabs with context.pages()', async ({ context, page }) => {
  await page.goto('/dashboard');

  // Open several new tabs
  const popupPromise1 = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Report A' }).click();
  const tab1 = await popupPromise1;

  const popupPromise2 = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Report B' }).click();
  const tab2 = await popupPromise2;

  // List all pages in this context
  const allPages = context.pages();
  expect(allPages).toHaveLength(3); // original + 2 popups

  // Interact with specific tabs
  await tab1.waitForLoadState();
  await expect(tab1.getByRole('heading')).toContainText('Report A');

  await tab2.waitForLoadState();
  await expect(tab2.getByRole('heading')).toContainText('Report B');

  // Close tabs when done
  await tab2.close();
  await tab1.close();
  expect(context.pages()).toHaveLength(1);
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('cart updates reflect across tabs', async ({ context }) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  await page1.goto('/products');
  await page2.goto('/cart');

  await page1.getByRole('button', { name: 'Add to cart' }).first().click();
  await expect(page2.getByTestId('cart-count')).toHaveText('1', { timeout: 5000 });
});

test('manage tabs with context.pages()', async ({ context, page }) => {
  await page.goto('/dashboard');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Report A' }).click();
  const newTab = await popupPromise;

  expect(context.pages()).toHaveLength(2);
  await newTab.close();
  expect(context.pages()).toHaveLength(1);
});
```

### Download Triggers in New Tabs

**Use when**: A download is triggered by opening a new tab (e.g., PDF generation that opens in a new window before downloading).
**Avoid when**: The download starts directly without opening a new tab -- use `page.waitForEvent('download')` directly.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('PDF download from new tab', async ({ page }) => {
  await page.goto('/invoices');

  // Some apps open the PDF in a new tab, which then triggers a download
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Download Invoice #123' }).click();
  const popup = await popupPromise;

  // The popup may directly trigger a download
  const downloadPromise = popup.waitForEvent('download');
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toContain('invoice');
  const path = await download.path();
  expect(path).toBeTruthy();

  await popup.close();
});

test('export opens in new tab then auto-downloads', async ({ page }) => {
  await page.goto('/reports');

  // Handle both: popup that downloads AND popup that shows content
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Export CSV' }).click();
  const popup = await popupPromise;

  // Try to catch a download; if no download, the popup has the content
  try {
    const download = await popup.waitForEvent('download', { timeout: 5000 });
    const filename = download.suggestedFilename();
    expect(filename).toMatch(/\.csv$/);
    await download.saveAs(`./downloads/${filename}`);
  } catch {
    // No download event — content displayed in the popup
    const content = await popup.textContent('body');
    expect(content).toContain('Report Data');
  }

  await popup.close();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('PDF download from new tab', async ({ page }) => {
  await page.goto('/invoices');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Download Invoice #123' }).click();
  const popup = await popupPromise;

  const downloadPromise = popup.waitForEvent('download');
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toContain('invoice');
  await popup.close();
});
```

### Multi-Context for Isolated Sessions

**Use when**: Testing interactions between different users (e.g., admin and regular user, two chat participants).
**Avoid when**: You only need a single user perspective -- a single context is sufficient.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('admin sees user changes in real-time', async ({ browser }) => {
  // Create separate contexts for two users (separate sessions)
  const adminContext = await browser.newContext();
  const userContext = await browser.newContext();

  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();

  // Admin logs in and watches the user list
  await adminPage.goto('/admin/users');

  // User signs up
  await userPage.goto('/register');
  await userPage.getByLabel('Name').fill('New User');
  await userPage.getByLabel('Email').fill('newuser@example.com');
  await userPage.getByLabel('Password').fill('password123');
  await userPage.getByRole('button', { name: 'Register' }).click();

  // Admin should see the new user appear
  await expect(adminPage.getByText('newuser@example.com')).toBeVisible({ timeout: 10000 });

  await adminContext.close();
  await userContext.close();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('admin sees user changes in real-time', async ({ browser }) => {
  const adminContext = await browser.newContext();
  const userContext = await browser.newContext();

  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();

  await adminPage.goto('/admin/users');

  await userPage.goto('/register');
  await userPage.getByLabel('Name').fill('New User');
  await userPage.getByLabel('Email').fill('newuser@example.com');
  await userPage.getByLabel('Password').fill('password123');
  await userPage.getByRole('button', { name: 'Register' }).click();

  await expect(adminPage.getByText('newuser@example.com')).toBeVisible({ timeout: 10000 });

  await adminContext.close();
  await userContext.close();
});
```

### Observing New Contexts and Mirrored Events (Playwright 1.60+)

**Use when**: You're orchestrating many contexts/pages (multi-user suites, agent-driven sessions) and want a single place to react when a new context is created, or to listen for page lifecycle events at the context level rather than wiring a listener onto every page.
**Avoid when**: You have one context and one page — listen on the `page` directly.

Playwright 1.60 adds `browser.on('context')`, fired whenever a new context is created, and makes `BrowserContext` mirror its pages' lifecycle events (`download`, frame events, page close/load). This means you can attach logging or diagnostics once at the browser/context level.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('log every context and download across the browser', async ({ browser }) => {
  // Fires for each new context created on this browser (1.60+)
  browser.on('context', (context) => {
    console.log('New context created');

    // Context mirrors page-level events — no per-page wiring needed (1.60+)
    context.on('download', (download) => {
      console.log('Download started:', download.suggestedFilename());
    });
  });

  const userA = await browser.newContext();
  const userB = await browser.newContext();

  const pageA = await userA.newPage();
  await pageA.goto('/reports');
  await pageA.getByRole('button', { name: 'Export CSV' }).click();

  await userA.close();
  await userB.close();
});
```

**JavaScript**
```javascript
const { test } = require('@playwright/test');

test('log every context and download across the browser', async ({ browser }) => {
  browser.on('context', (context) => {
    context.on('download', (download) => {
      console.log('Download started:', download.suggestedFilename());
    });
  });

  const ctx = await browser.newContext();
  const page = await ctx.newPage();
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Export CSV' }).click();
  await ctx.close();
});
```

> Context-level mirrored events are for cross-cutting concerns (logging, artifacts, counters). For assertions tied to a specific page, still listen on that `page`.

## Decision Guide

| Scenario | Approach | Why |
|---|---|---|
| `target="_blank"` link | `page.waitForEvent('popup')` | Playwright fires `popup` for all new windows/tabs |
| `window.open()` call | `page.waitForEvent('popup')` | Same mechanism regardless of how the window opens |
| OAuth login popup | `waitForEvent('popup')` + interact + wait for close | OAuth providers redirect back and close the popup |
| Payment popup (PayPal) | `waitForEvent('popup')` + complete flow | Same as OAuth but with payment-specific UI |
| Download in new tab | `popup.waitForEvent('download')` | Catch the download event on the popup page |
| Multiple tabs, same user | Open pages in the same `context` | Shared cookies, localStorage, session |
| Multiple users (separate sessions) | Create separate `browser.newContext()` per user | Isolated cookies, storage, auth state |
| Tab sync testing | Multiple `context.newPage()` + assert shared state | Tests real-time state synchronization |
| Popup that may or may not appear | `waitForEvent('popup', { timeout })` with try/catch | Graceful handling of conditional popups |

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Clicking then calling `waitForEvent('popup')` | Popup may open before listener is registered (race condition) | Set up `waitForEvent` BEFORE the click |
| Using `context.pages()[1]` to get a popup | Index order is not guaranteed; brittle | Use `waitForEvent('popup')` which returns the exact page |
| Forgetting `popup.waitForLoadState()` | Popup page may not be loaded when you interact with it | Always call `waitForLoadState()` after receiving the popup |
| Not closing popups after test | Leaked pages consume memory and may affect subsequent tests | Close popups with `popup.close()` in the test or use fixtures |
| Using separate `browser.newContext()` when tabs should share state | Separate contexts have separate cookies/sessions | Use `context.newPage()` for tabs in the same session |
| Using `context.newPage()` for isolated users | Pages in the same context share state | Use `browser.newContext()` for separate user sessions |
| `page.waitForTimeout()` after popup trigger | Popup timing is unpredictable | Use `page.waitForEvent('popup')` which resolves when the popup opens |
| Catching popup on the wrong page | Popup event fires on the page that triggered it | Listen for `popup` on the page where the click/action happens |

## Troubleshooting

| Symptom | Likely Cause | Fix |
|---|---|---|
| `waitForEvent('popup')` times out | The action did not open a new window; it navigated in the same tab | Check if the link has `target="_blank"` or if `window.open` is called |
| Popup opens but is blank | Popup is still loading; you interacted too early | Add `await popup.waitForLoadState()` before interacting |
| Popup URL is `about:blank` | Popup is created empty, then navigated by JavaScript | Wait for `popup.waitForURL()` with the expected URL pattern |
| OAuth popup is blocked by browser | Pop-up blocker is active | Playwright disables pop-up blocking by default; check if a browser arg re-enables it |
| Two popups open but only one is caught | `waitForEvent` resolves for the first event only | Use `page.on('popup', callback)` to catch all popups, or chain two `waitForEvent` calls |
| `context.pages()` returns unexpected count | Previous test left pages open | Close all extra pages in `afterEach` or use per-test contexts |
| Popup closes before interaction completes | The app or OAuth provider auto-closes after timeout | Increase test speed or remove `slowMo`; interact with the popup immediately |
| Cross-origin popup interaction fails | Popup navigated to a different origin | Playwright handles cross-origin popups; ensure you are not setting `--disable-web-security` |

## Related

- [core/authentication.md](authentication.md) -- bypassing OAuth popups with stored auth state
- [core/multi-user-and-collaboration.md](multi-user-and-collaboration.md) -- multi-user real-time collaboration testing
- [core/file-operations.md](file-operations.md) -- file download handling without popups
- [core/third-party-integrations.md](third-party-integrations.md) -- mocking OAuth providers to avoid real popups
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- fixtures for multi-context setups
