Playwright Multi-Tab & Window Handling — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Playwright Multi-Tab & Window Handling (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.context.pages() is the live list of open tabs.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/);
});context.waitForEvent('page') when one specific element triggers the new window — it scopes the wait to the right opener.Page, BrowserContext) so downstream tests get autocomplete and the popup is never any.close) first.window.open in Playwright?"target=_blank link breaks my assertions"~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.