by thetestingacademy
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.
npx @qaskills/cli add playwright-multi-tab-handlingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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."
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.BrowserContext, not a Page. All tabs in one context share cookies/storage. context.pages() is the live list of open tabs.await newPage.waitForLoadState() before asserting — the page object resolves the moment the tab exists, not when it has loaded.Page object.page event; you do not need a new context.target="_blank" linkThe canonical, race-free shape uses Promise.all: start listening, then click, in one expression.
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();
});
page.waitForEvent('popup') for window.openWhen 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.
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();
});
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.
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 });
});
Hold each Page in a variable. Never rely on context.pages()[1] order.
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);
});
Wrap the race-free dance once so tests stay readable.
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();
});
Sometimes a target="_blank" link makes assertions harder than they need to be. Strip the attribute before clicking so navigation stays in one page.
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/);
});
Promise.all([waitForEvent, click]) as the default shape — it makes the subscribe-before-click ordering structurally impossible to get wrong.page.waitForEvent('popup') over context.waitForEvent('page') when one specific element triggers the new window — it scopes the wait to the right opener.waitForLoadState on the new page before any locator or URL assertion.Page, BrowserContext) so downstream tests get autocomplete and the popup is never any.popup.waitForEvent('close') as the signal that auth finished, then assert on the main page.timeout on the post-popup assertion (OAuth redirects are slow); 15s is reasonable.storageState for repeated logins instead of driving the OAuth popup in every test — drive it once in global setup, save state, reuse it.context.waitForEvent('page'). The event may already have fired; you will hang until timeout. Subscribe first.await page.waitForTimeout(3000) to "let the tab open." Flaky and slow. Use the event.context.pages()[1]. Tab order is not portable across Chromium/Firefox/WebKit. Hold the returned reference.waitForLoadState. The page object exists immediately; its content does not.browser.newContext() for an OAuth popup. A popup in the same context shares the session you need; a new context throws away cookies.close) first.window.open in Playwright?"target=_blank link breaks my assertions"- name: Install QA Skills
run: npx @qaskills/cli add playwright-multi-tab-handling12 of 29 agents supported